Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00c7613f1a | |||
| 0ecffdab4a | |||
| 4cb9e8a535 | |||
| a12c85c585 | |||
| 890f7f1c6f | |||
| 4bb3acb913 | |||
| 0675648de8 | |||
| c2f8782cc2 | |||
| d365b60704 | |||
| cf797cbafe | |||
| 935cb98eed | |||
| 0178858e79 | |||
| 875f99d26f | |||
| 69ea207617 | |||
| 546eefc58a | |||
| 61c018845a | |||
| 9a0d1b3a62 | |||
| 507c5e5b34 | |||
| bc6dc997ef | |||
| 6ca8cec97d | |||
| f8cb50eaa3 | |||
| b6f2e86f78 | |||
| c57879a5c8 | |||
| 2404719c3b | |||
| 90cd443000 | |||
| ac9483c9c1 | |||
| ade5469a24 | |||
| 6d7c652b65 | |||
| 3aeedf4856 | |||
| f7c6293d09 | |||
| 89064fd82a | |||
| 1e1f68bf40 | |||
| 244feb6e89 | |||
| 135c928fea | |||
| 9477f37503 | |||
| 370150186b | |||
| 2fa967db65 | |||
| 4e276db858 | |||
| 9b3674e01f | |||
| c5ebf4d1cd | |||
| 83b8a84050 |
@@ -24,6 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -34,3 +35,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -60,3 +60,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
|
||||
Generated
+92
-16
@@ -433,6 +433,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "basic-udev"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -540,13 +546,14 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"hidapi",
|
||||
"log",
|
||||
"muda",
|
||||
"rfd",
|
||||
@@ -554,11 +561,12 @@ dependencies = [
|
||||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -569,14 +577,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -586,7 +594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -597,14 +605,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -613,7 +621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -632,7 +640,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust",
|
||||
@@ -642,14 +650,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -659,6 +667,7 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"miniz_oxide 0.8.9",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -666,7 +675,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -677,19 +686,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"lyon_tessellation",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -710,7 +721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -1390,6 +1401,12 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float_next_after"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1742,6 +1759,21 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "2.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6"
|
||||
dependencies = [
|
||||
"basic-udev",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"nix",
|
||||
"pkg-config",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
@@ -2119,6 +2151,38 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lyon_geom"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_path"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
|
||||
dependencies = [
|
||||
"lyon_geom",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_tessellation"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005"
|
||||
dependencies = [
|
||||
"float_next_after",
|
||||
"lyon_path",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -2272,6 +2336,18 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -4696,7 +4772,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -34,6 +34,7 @@ rayon = "1"
|
||||
|
||||
# rendering / formats
|
||||
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
lyon_tessellation = "1.0.22"
|
||||
skrifa = "0.47"
|
||||
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses)
|
||||
# and the Unicode Bidirectional Algorithm for right-to-left drawing text.
|
||||
@@ -41,6 +42,8 @@ harfrust = "0.12"
|
||||
unicode-bidi = "0.3.18"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
encoding_rs = "0.8.35"
|
||||
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
|
||||
quick-xml = "0.41"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
|
||||
@@ -111,7 +111,7 @@ numbers):
|
||||
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
|
||||
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
|
||||
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
|
||||
| Interface languages | English, Ukrainian (Українська) and German (Deutsch), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français) and Simplified Chinese (简体中文, with an installed CJK font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! cadcraft-cli commands [FILTER] command catalog (JSON)
|
||||
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
|
||||
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
|
||||
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
|
||||
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -60,7 +61,7 @@ const USAGE: &str = "usage:
|
||||
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
|
||||
cadcraft-cli commands [FILTER]
|
||||
cadcraft-cli mcp [--connect HOST:PORT]
|
||||
cadcraft-cli perf [N]
|
||||
cadcraft-cli perf [N | FILE]
|
||||
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
|
||||
cadcraft-cli --version
|
||||
|
||||
@@ -393,11 +394,55 @@ fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, Strin
|
||||
Ok(dr)
|
||||
}
|
||||
|
||||
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
|
||||
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
|
||||
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
|
||||
fn perf_file(path: &str) -> Result<(), String> {
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::render::{Options, build};
|
||||
use std::time::Instant;
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut s = Session::empty();
|
||||
let t = Instant::now();
|
||||
open(&mut s, path)?;
|
||||
let open_ms = ms(t);
|
||||
let st = s.state().map_err(|e| e.to_string())?;
|
||||
let (d, space) = (&st.doc, &st.space);
|
||||
let ext = d.extents(space);
|
||||
let size = ext.width().max(ext.height());
|
||||
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
|
||||
return Err("perf: the drawing is empty".into());
|
||||
}
|
||||
outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height());
|
||||
outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims");
|
||||
let mut finest = Options::default();
|
||||
for k in 0..5 {
|
||||
let zoom = 4f64.powi(k);
|
||||
let h = size / zoom;
|
||||
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
|
||||
let half = Vec2::new(h * 0.8, h * 0.5);
|
||||
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
|
||||
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
|
||||
let t = Instant::now();
|
||||
let n = build(d, space, &screen).prims.len();
|
||||
outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t));
|
||||
finest = screen;
|
||||
}
|
||||
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
|
||||
let t = Instant::now();
|
||||
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
|
||||
outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perf(args: &[String]) -> Result<(), String> {
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{select, snap};
|
||||
use std::time::Instant;
|
||||
if let Some(path) = args.first().filter(|a| a.parse::<usize>().is_err()) {
|
||||
return perf_file(path);
|
||||
}
|
||||
let n: usize = match args.first() {
|
||||
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
|
||||
None => 200_000,
|
||||
|
||||
@@ -24,6 +24,10 @@ winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
# View ▸ Theme ▸ System: the desktop's light/dark choice from the XDG desktop portal
|
||||
# (desktop_theme.rs). Already in Cargo.lock through accesskit; pinned to that version so nothing
|
||||
# is built twice.
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
@@ -31,3 +35,10 @@ sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
|
||||
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry
|
||||
/// whole drawings or images as base64 still fit.
|
||||
const MAX_LINE: usize = 256 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,25 +30,82 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
|
||||
/// its thread can't be started, or panics).
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, tx, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
|
||||
let Ok(line) = std::str::from_utf8(&buf) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -62,3 +130,73 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_line_is_refused_without_reading_the_rest() {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_with_their_ids() {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! The desktop's own light/dark choice, for View ▸ Theme ▸ System.
|
||||
//!
|
||||
//! winit reports no system theme on Linux and the BSDs (`RawInput::system_theme` stays `None`),
|
||||
//! so the System choice fell back to dark whatever the desktop said. This asks the desktop itself:
|
||||
//! the XDG Desktop Portal's Settings interface, which answers on GNOME, KDE and XFCE alike, over
|
||||
//! X11 and Wayland alike. The answer is handed to egui as the system theme
|
||||
//! (`App::raw_input_hook`), so the app and egui's own widgets follow it as they follow macOS and
|
||||
//! Windows.
|
||||
//!
|
||||
//! Ported from PdfCraft (storytold/pdfcraft#881).
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often the watcher re-reads the desktop's answer. The portal also publishes a change
|
||||
/// signal, but subscribing needs a connection kept alive for the life of the window; one round
|
||||
/// trip every few seconds is cheap and reacts soon enough after a theme switch.
|
||||
const POLL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Watches the desktop and reports its choice when it changes.
|
||||
#[derive(Default)]
|
||||
pub struct DesktopTheme {
|
||||
/// `None` when no watcher thread could be started (or on a platform where winit answers).
|
||||
changes: Option<Receiver<Option<egui::Theme>>>,
|
||||
/// The desktop's last answer.
|
||||
current: Option<egui::Theme>,
|
||||
}
|
||||
|
||||
impl DesktopTheme {
|
||||
/// Start watching. The first answer is sent straight away; `wake` is called after each
|
||||
/// answer, so an idle window repaints and picks it up.
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
if !ASKS_THE_DESKTOP {
|
||||
return Self::default();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("cadcraft-desktop-theme".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx), current: None }
|
||||
}
|
||||
|
||||
/// The desktop's choice, `None` when it states none or there is nobody to ask (no session
|
||||
/// bus, no portal). Polled once a frame; a desktop that stops answering keeps the last answer.
|
||||
pub fn current(&mut self) -> Option<egui::Theme> {
|
||||
if let Some(changes) = &self.changes {
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(Some(theme)) => self.current = Some(theme),
|
||||
Ok(None) => {}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
/// Where winit has no answer and the portal does.
|
||||
const ASKS_THE_DESKTOP: bool = cfg!(all(unix, not(target_os = "macos")));
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read() -> Option<egui::Theme> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The standard key first (GNOME and KDE answer it: 0 no preference, 1 dark, 2 light); then
|
||||
// GNOME's own schema, which XFCE's portal proxies without the standard key.
|
||||
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "color-scheme"), ("org.gnome.desktop.interface", "color-scheme")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (namespace, key) in KEYS {
|
||||
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
|
||||
// wraps it in one more variant. `parse` unwraps either.
|
||||
let reply = connection
|
||||
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
|
||||
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
|
||||
let Ok(reply) = reply else { continue };
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Some(scheme) = parse(&answer, 0) {
|
||||
return scheme;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme` answer: `Some(choice)` when it read as an answer (`Some(None)`
|
||||
/// for "no preference"), `None` when it didn't. Variants are unwrapped a few levels deep.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<Option<egui::Theme>> {
|
||||
use zbus::zvariant::Value;
|
||||
match value {
|
||||
Value::U32(1) => Some(Some(egui::Theme::Dark)),
|
||||
Value::U32(2) => Some(Some(egui::Theme::Light)),
|
||||
Value::U32(_) => Some(None),
|
||||
Value::Str(s) => Some(match s.as_str() {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}),
|
||||
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn read() -> Option<egui::Theme> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The standard key answers a number, GNOME's schema a string; either may come wrapped in
|
||||
/// variants (the older `Read`). Only "dark" and "light" are a choice.
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn the_portal_answer_reads_as_a_choice_or_as_none() {
|
||||
use zbus::zvariant::Value;
|
||||
assert_eq!(parse(&Value::U32(1), 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::U32(2), 0), Some(Some(egui::Theme::Light)));
|
||||
assert_eq!(parse(&Value::U32(0), 0), Some(None));
|
||||
assert_eq!(parse(&Value::U32(7), 0), Some(None));
|
||||
assert_eq!(parse(&Value::from("prefer-dark"), 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::from("prefer-light"), 0), Some(Some(egui::Theme::Light)));
|
||||
assert_eq!(parse(&Value::from("default"), 0), Some(None));
|
||||
let wrapped = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1)))));
|
||||
assert_eq!(parse(&wrapped, 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::Bool(true), 0), None);
|
||||
let mut deep = Value::U32(1);
|
||||
for _ in 0..10 {
|
||||
deep = Value::Value(Box::new(deep));
|
||||
}
|
||||
assert_eq!(parse(&deep, 0), None, "nesting is bounded");
|
||||
}
|
||||
|
||||
/// A watcher that was never started is inert rather than a panic.
|
||||
#[test]
|
||||
fn a_watcher_without_a_thread_is_inert() {
|
||||
assert_eq!(DesktopTheme::default().current(), None);
|
||||
}
|
||||
|
||||
/// Answers arrive as changes; "no preference" or silence keeps the last real answer.
|
||||
#[test]
|
||||
fn the_last_answer_stands_until_a_new_one() {
|
||||
let (tx, rx) = channel();
|
||||
let mut watcher = DesktopTheme { changes: Some(rx), current: None };
|
||||
assert_eq!(watcher.current(), None);
|
||||
tx.send(Some(egui::Theme::Light)).unwrap();
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Light));
|
||||
tx.send(Some(egui::Theme::Dark)).unwrap();
|
||||
tx.send(None).unwrap();
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
|
||||
drop(tx);
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,13 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod control_server;
|
||||
mod desktop_theme;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod spacemouse;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
@@ -27,6 +29,9 @@ use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
spacemouse: spacemouse::SpaceMouse,
|
||||
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
|
||||
desktop_theme: desktop_theme::DesktopTheme,
|
||||
workspace: workspace::Workspace,
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -48,6 +53,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System resolves against `raw.system_theme`, which winit leaves empty on Linux
|
||||
/// and the BSDs: the desktop's answer, where it gives one, stands in for it.
|
||||
fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
|
||||
if desktop.is_some() {
|
||||
raw.system_theme = desktop;
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -60,12 +73,14 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
follow_desktop(raw, self.desktop_theme.current());
|
||||
self.cad.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
@@ -107,6 +122,7 @@ fn services() -> Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -240,6 +256,17 @@ fn main() -> eframe::Result {
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
// Command previews (COPY, MOVE...) of large selections are tessellated by egui, whose index
|
||||
// buffer outgrew wgpu's default 256 MiB cap; the validation error is fatal. Ask for what the
|
||||
// adapter actually supports.
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
let base = std::sync::Arc::clone(&create.device_descriptor);
|
||||
create.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
let mut d = base(adapter);
|
||||
d.required_limits.max_buffer_size = d.required_limits.max_buffer_size.max(adapter.limits().max_buffer_size);
|
||||
d
|
||||
});
|
||||
}
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
@@ -298,8 +325,12 @@ fn main() -> eframe::Result {
|
||||
if app.session.docs.is_empty() {
|
||||
app.session.new_drawing(false);
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
Ok(Box::new(App {
|
||||
desktop_theme,
|
||||
cad: app,
|
||||
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
|
||||
workspace,
|
||||
workspace_error,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -311,7 +342,7 @@ fn main() -> eframe::Result {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{config_dir_from, control_port_from, filters_for_save};
|
||||
use super::{config_dir_from, control_port_from, filters_for_save, follow_desktop};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -377,4 +408,19 @@ mod tests {
|
||||
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
|
||||
);
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
|
||||
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
|
||||
#[test]
|
||||
fn the_desktop_answers_for_the_system_theme() {
|
||||
let mut raw = egui::RawInput::default();
|
||||
follow_desktop(&mut raw, Some(egui::Theme::Light));
|
||||
assert_eq!(raw.system_theme, Some(egui::Theme::Light));
|
||||
let mut raw = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
|
||||
follow_desktop(&mut raw, None);
|
||||
assert_eq!(raw.system_theme, Some(egui::Theme::Dark));
|
||||
let mut raw = egui::RawInput::default();
|
||||
follow_desktop(&mut raw, None);
|
||||
assert_eq!(raw.system_theme, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ impl NativeMenu {
|
||||
items.insert("cc-settings".into(), ("options".into(), "Settings...".into(), settings));
|
||||
let _ = menu.append(&app_menu);
|
||||
for (title, entries) in menus::tree(app) {
|
||||
let sub = Submenu::new(i18n::tr(language, &title), true);
|
||||
let sub = Submenu::new(i18n::tr_menu(language, &title), true);
|
||||
build(&sub, &entries, &mut items, &mut submenus, &mut counter, language);
|
||||
let _ = menu.append(&sub);
|
||||
submenus.push((title, sub));
|
||||
@@ -87,7 +87,7 @@ impl NativeMenu {
|
||||
item.set_text(i18n::tr(language, label));
|
||||
}
|
||||
for (label, submenu) in &self.submenus {
|
||||
submenu.set_text(i18n::tr(language, label));
|
||||
submenu.set_text(i18n::tr_menu(language, label));
|
||||
}
|
||||
}
|
||||
let now = cadcraft_ui_egui::now_ms();
|
||||
@@ -113,7 +113,7 @@ fn build(
|
||||
for e in entries {
|
||||
match e {
|
||||
Entry::Sub { label, children } => {
|
||||
let sub = Submenu::new(i18n::tr(language, label), true);
|
||||
let sub = Submenu::new(i18n::tr_menu(language, label), true);
|
||||
build(&sub, children, items, submenus, counter, language);
|
||||
let _ = parent.append(&sub);
|
||||
submenus.push((label.clone(), sub));
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Native 3Dconnexion input. The reader publishes one latest sample, never an input queue;
|
||||
//! only movement starting/stopping or status/buttons wake egui. Active motion runs at 60 Hz.
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod motion;
|
||||
|
||||
use cadcraft_ui_egui::{CadApp, spacemouse::Status};
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
status: Status,
|
||||
device: Option<String>,
|
||||
detail: Option<String>,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
motion: motion::Motion,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
last_report: Option<Instant>,
|
||||
buttons: u8,
|
||||
recalibrate: bool,
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
last_frame: Instant,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
moving: bool,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
pub fn start(ctx: egui::Context) -> Self {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
let (input, quit, context) = (Arc::clone(&shared), Arc::clone(&stop), ctx.clone());
|
||||
if let Err(error) = std::thread::Builder::new().name("spacemouse-hid".into()).spawn(move || read(input, quit, context)) {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
let _ = ctx;
|
||||
Self {
|
||||
shared,
|
||||
stop,
|
||||
last_frame: Instant::now(),
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
moving: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
let Ok(mut shared) = self.shared.lock() else { return };
|
||||
if std::mem::take(&mut app.spacemouse.calibrate_requested) {
|
||||
shared.recalibrate = true;
|
||||
}
|
||||
let changed = app.spacemouse.status != shared.status || app.spacemouse.device != shared.device || app.spacemouse.detail != shared.detail;
|
||||
app.spacemouse.status = shared.status;
|
||||
app.spacemouse.device.clone_from(&shared.device);
|
||||
app.spacemouse.detail.clone_from(&shared.detail);
|
||||
if changed && !matches!(shared.status, Status::Unavailable | Status::Searching) {
|
||||
app.set_status(cadcraft_ui_egui::i18n::tr(app.language(), shared.status.message()));
|
||||
}
|
||||
let buttons = std::mem::take(&mut shared.buttons);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let motion = if shared.last_report.is_some_and(|at| now.saturating_duration_since(at) < Duration::from_millis(250)) {
|
||||
shared.motion
|
||||
} else {
|
||||
motion::Motion::default()
|
||||
};
|
||||
drop(shared);
|
||||
let allowed = app.spacemouse.settings.enabled
|
||||
&& ctx.input(|i| i.focused)
|
||||
&& !app.ui.start_tab
|
||||
&& app.ui.dialog.is_none()
|
||||
&& app.closing.is_none()
|
||||
&& app.canvas.rect.is_some()
|
||||
&& !egui::Popup::is_any_open(ctx);
|
||||
if allowed && buttons & 1 != 0 {
|
||||
let _ = app.run("zoom", serde_json::json!({"mode": "extents"}));
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
let moving = allowed && motion.active();
|
||||
if moving {
|
||||
if self.moving {
|
||||
let settings = app.spacemouse.settings;
|
||||
let rate = if settings.reverse_zoom { -settings.zoom_speed } else { settings.zoom_speed };
|
||||
if let Err(error) = app.run(
|
||||
"ui.spacemouse.navigate",
|
||||
serde_json::json!({
|
||||
"pan": motion.pan.map(|v| v * settings.pan_speed * elapsed),
|
||||
"zoom": (motion.zoom * rate * elapsed).exp()
|
||||
}),
|
||||
) {
|
||||
app.set_status(error);
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
self.moving = moving;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
let _ = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SpaceMouse {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn set_status(shared: &Mutex<Shared>, ctx: &egui::Context, status: Status, device: Option<String>, detail: Option<String>) {
|
||||
let Ok(mut state) = shared.lock() else { return };
|
||||
let changed = state.status != status || state.device != device || state.detail != detail;
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Some(error) = &detail {
|
||||
log::warn!("3D mouse: {error}");
|
||||
}
|
||||
state.status = status;
|
||||
state.device = device;
|
||||
state.detail = detail;
|
||||
state.motion = motion::Motion::default();
|
||||
state.last_report = None;
|
||||
state.buttons = 0;
|
||||
drop(state);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn retry(stop: &AtomicBool) {
|
||||
for _ in 0..40 {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn read(shared: Arc<Mutex<Shared>>, stop: Arc<AtomicBool>, ctx: egui::Context) {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let mut api = match hidapi::HidApi::new() {
|
||||
Ok(api) => api,
|
||||
Err(error) => {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
retry(&stop);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
break;
|
||||
}
|
||||
let device =
|
||||
api.device_list().find(|device| matches!(device.vendor_id(), 0x046d | 0x256f) && device.usage_page() == 1 && device.usage() == 8);
|
||||
let Some(info) = device else {
|
||||
set_status(&shared, &ctx, Status::Searching, None, None);
|
||||
retry(&stop);
|
||||
continue;
|
||||
};
|
||||
let name = info.product_string().unwrap_or("3Dconnexion SpaceMouse").to_string();
|
||||
match info.open_device(&api) {
|
||||
Ok(device) => {
|
||||
set_status(&shared, &ctx, Status::Calibrating, Some(name.clone()), None);
|
||||
if let Err(error) = read_device(&device, &shared, &stop, &ctx, &name) {
|
||||
set_status(&shared, &ctx, Status::Disconnected, Some(name), Some(error.to_string()));
|
||||
}
|
||||
}
|
||||
Err(error) => set_status(&shared, &ctx, Status::OpenFailed, Some(name), Some(error.to_string())),
|
||||
}
|
||||
retry(&stop);
|
||||
}
|
||||
retry(&stop);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn read_device(device: &hidapi::HidDevice, shared: &Mutex<Shared>, stop: &AtomicBool, ctx: &egui::Context, name: &str) -> hidapi::HidResult<()> {
|
||||
let mut filter = motion::Filter::default();
|
||||
let mut buffer = [0; 64];
|
||||
let mut buttons = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let recalibrate = if let Ok(mut state) = shared.lock() { std::mem::take(&mut state.recalibrate) } else { return Ok(()) };
|
||||
if recalibrate {
|
||||
filter.recalibrate();
|
||||
set_status(shared, ctx, Status::Calibrating, Some(name.into()), None);
|
||||
}
|
||||
let len = device.read_timeout(&mut buffer, 50)?;
|
||||
let now = Instant::now();
|
||||
let report = buffer.get(..len).and_then(motion::decode);
|
||||
let Ok(mut state) = shared.lock() else { return Ok(()) };
|
||||
let mut wake = false;
|
||||
match report {
|
||||
Some(motion::Report::Buttons(next)) => {
|
||||
let pressed = next & !buttons;
|
||||
buttons = next;
|
||||
state.buttons |= pressed & 1;
|
||||
state.recalibrate |= pressed & 2 != 0;
|
||||
wake = pressed != 0;
|
||||
}
|
||||
Some(motion::Report::Axes { values, mask }) => {
|
||||
let motion = filter.update(values, mask, now);
|
||||
wake = state.motion.active() != motion.active();
|
||||
state.motion = motion;
|
||||
state.last_report = Some(now);
|
||||
if filter.calibrated() && state.status != Status::Connected {
|
||||
state.status = Status::Connected;
|
||||
wake = true;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if state.motion.active() && state.last_report.is_some_and(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
|
||||
state.motion = motion::Motion::default();
|
||||
wake = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
if wake {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Bounded, dependency-free decoding and rest calibration of 3Dconnexion HID input reports.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const FULL_SCALE: f64 = 500.0;
|
||||
const DEADZONE: f64 = 0.04;
|
||||
const REST_LIMIT: f64 = 60.0;
|
||||
const REST_RANGE: f64 = 10.0;
|
||||
const CALIBRATION_TIME: Duration = Duration::from_millis(400);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Report {
|
||||
/// Mask 1 = translation, 2 = rotation, 3 = a combined six-axis report.
|
||||
Axes {
|
||||
values: [i16; 6],
|
||||
mask: u8,
|
||||
},
|
||||
Buttons(u8),
|
||||
}
|
||||
|
||||
/// hidapi includes the report ID. A legacy motion report has six payload bytes; combined
|
||||
/// report 1 has twelve. Windows may pad the short button report to the input-report size.
|
||||
pub fn decode(bytes: &[u8]) -> Option<Report> {
|
||||
let (&id, payload) = bytes.split_first()?;
|
||||
if id == 3 {
|
||||
return payload.first().copied().map(Report::Buttons);
|
||||
}
|
||||
let (mask, offset, count) = match id {
|
||||
1 if payload.len() >= 12 => (3, 0, 6),
|
||||
1 if payload.len() >= 6 => (1, 0, 3),
|
||||
2 if payload.len() >= 6 => (2, 3, 3),
|
||||
_ => return None,
|
||||
};
|
||||
let mut values = [0; 6];
|
||||
for (out, pair) in values.iter_mut().skip(offset).take(count).zip(payload.as_chunks::<2>().0.iter()) {
|
||||
*out = i16::from_le_bytes([*pair.first()?, *pair.get(1)?]);
|
||||
}
|
||||
Some(Report::Axes { values, mask })
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Motion {
|
||||
pub pan: [f64; 2],
|
||||
pub zoom: f64,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn active(self) -> bool {
|
||||
self.pan.iter().any(|v| *v != 0.0) || self.zoom != 0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Rest {
|
||||
since: Option<Instant>,
|
||||
count: u32,
|
||||
sum: [f64; 6],
|
||||
min: [f64; 6],
|
||||
max: [f64; 6],
|
||||
}
|
||||
|
||||
impl Rest {
|
||||
fn observe(&mut self, raw: [f64; 6], now: Instant) -> Option<[f64; 6]> {
|
||||
if raw.iter().any(|v| v.abs() > REST_LIMIT) {
|
||||
*self = Self::default();
|
||||
return None;
|
||||
}
|
||||
if self.since.is_none() || raw.iter().zip(self.min.iter().zip(&self.max)).any(|(v, (min, max))| v.max(*max) - v.min(*min) > REST_RANGE) {
|
||||
*self = Self { since: Some(now), count: 1, sum: raw, min: raw, max: raw };
|
||||
return None;
|
||||
}
|
||||
for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(raw) {
|
||||
*min = min.min(value);
|
||||
*max = max.max(value);
|
||||
}
|
||||
// A fixed bound even if a device sends far more than the expected 125 reports/sec.
|
||||
if self.count < 128 {
|
||||
self.count += 1;
|
||||
for (sum, value) in self.sum.iter_mut().zip(raw) {
|
||||
*sum += value;
|
||||
}
|
||||
}
|
||||
if self.count >= 16 && self.since.is_some_and(|since| now.saturating_duration_since(since) >= CALIBRATION_TIME) {
|
||||
Some(self.sum.map(|sum| sum / f64::from(self.count)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Filter {
|
||||
raw: [f64; 6],
|
||||
bias: Option<[f64; 6]>,
|
||||
rest: Rest,
|
||||
parts: u8,
|
||||
seen: [Option<Instant>; 2],
|
||||
idle_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn calibrated(&self) -> bool {
|
||||
self.bias.is_some()
|
||||
}
|
||||
|
||||
pub fn recalibrate(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
|
||||
for (index, seen) in self.seen.iter_mut().enumerate() {
|
||||
if mask & (1 << index) != 0 {
|
||||
*seen = Some(now);
|
||||
}
|
||||
}
|
||||
for (index, (raw, value)) in self.raw.iter_mut().zip(values).enumerate() {
|
||||
if mask & if index < 3 { 1 } else { 2 } != 0 {
|
||||
*raw = f64::from(value);
|
||||
}
|
||||
}
|
||||
self.parts |= mask & 3;
|
||||
let complete = self.parts == 3;
|
||||
if complete {
|
||||
self.parts = 0;
|
||||
}
|
||||
if self.bias.is_none() && complete {
|
||||
self.bias = self.rest.observe(self.raw, now);
|
||||
}
|
||||
let Some(mut bias) = self.bias else { return Motion::default() };
|
||||
// Follow small resting drift only after two seconds entirely inside the deadzone.
|
||||
if complete {
|
||||
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
|
||||
let since = *self.idle_since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
|
||||
for (bias, raw) in bias.iter_mut().zip(self.raw) {
|
||||
*bias += (raw - *bias) * 0.01;
|
||||
}
|
||||
self.bias = Some(bias);
|
||||
}
|
||||
} else {
|
||||
self.idle_since = None;
|
||||
}
|
||||
}
|
||||
let mut axes = [0.0; 6];
|
||||
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
|
||||
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
|
||||
*axis = if value.abs() <= DEADZONE { 0.0 } else { value.signum() * (value.abs() - DEADZONE) / (1.0 - DEADZONE) };
|
||||
}
|
||||
// A missing rotation report must not keep zooming while translations still arrive.
|
||||
for (group, seen) in axes.as_chunks_mut::<3>().0.iter_mut().zip(self.seen) {
|
||||
if seen.is_none_or(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
|
||||
group.fill(0.0);
|
||||
}
|
||||
}
|
||||
let [tx, ty, tz, _rx, _ry, rz] = axes;
|
||||
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,14 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rangoDJ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Tru10]
|
||||
display_name = "by Truitt"
|
||||
sync_github_name = false
|
||||
@@ -37,3 +45,6 @@ sync_github_name = true
|
||||
[people.WladmirJunior]
|
||||
real_name = "Wladmir Júnior"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
+68
-21
@@ -153,9 +153,11 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
|
||||
name: "BRICK",
|
||||
description: "Brick or masonry-type surface",
|
||||
lines: vec![
|
||||
// Running bond: courses 0.25 high, bricks 0.5 long. Each vertical family draws the
|
||||
// joints of every other course, 0.5 apart; the second is offset half a brick.
|
||||
pl(0.0, (0.0, 0.0), (0.0, 0.25), &[]),
|
||||
pl(90.0, (0.0, 0.0), (0.25, 0.25), &[0.25, -0.25]),
|
||||
pl(90.0, (0.25, 0.0), (0.25, 0.25), &[-0.25, 0.25]),
|
||||
pl(90.0, (0.0, 0.0), (0.0, 0.5), &[0.25, -0.25]),
|
||||
pl(90.0, (0.25, 0.0), (0.0, 0.5), &[-0.25, 0.25]),
|
||||
],
|
||||
},
|
||||
HatchPattern {
|
||||
@@ -203,28 +205,73 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
|
||||
description: "Staircase effect",
|
||||
lines: vec![pl(0.0, (0.0, 0.0), (0.125, 0.125), &[0.125, -0.125]), pl(90.0, (0.125, 0.0), (0.125, 0.125), &[0.125, -0.125])],
|
||||
},
|
||||
HatchPattern {
|
||||
name: "AR-CONC",
|
||||
description: "Random dot and stone pattern",
|
||||
lines: vec![
|
||||
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
|
||||
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
|
||||
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
|
||||
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
|
||||
],
|
||||
},
|
||||
HatchPattern {
|
||||
name: "AR-SAND",
|
||||
description: "Random dot pattern",
|
||||
lines: vec![
|
||||
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
|
||||
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
|
||||
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
|
||||
],
|
||||
},
|
||||
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
|
||||
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
|
||||
scaled_pattern(
|
||||
HatchPattern {
|
||||
name: "AR-CONC",
|
||||
description: "Random dot and stone pattern",
|
||||
lines: vec![
|
||||
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
|
||||
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
|
||||
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
|
||||
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
|
||||
],
|
||||
},
|
||||
16.0,
|
||||
),
|
||||
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
|
||||
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
|
||||
scaled_pattern(
|
||||
HatchPattern {
|
||||
name: "AR-SAND",
|
||||
description: "Random dot pattern",
|
||||
lines: vec![
|
||||
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
|
||||
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
|
||||
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
|
||||
],
|
||||
},
|
||||
16.0,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn pattern(name: &str) -> Option<HatchPattern> {
|
||||
standard_patterns().into_iter().find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The library pattern `name` as drawn in `d`. In a metric drawing (`MEASUREMENT` 1) every
|
||||
/// length is multiplied by 25.4, as CAD programs use ISO-scaled pattern definitions there (as
|
||||
/// for linetypes, see [`standard_linetypes_for`]): a hatch at scale 1 measures in millimetres
|
||||
/// what it measures in inches in an imperial drawing.
|
||||
pub fn pattern_for(d: &Drawing, name: &str) -> Option<HatchPattern> {
|
||||
let p = pattern(name)?;
|
||||
Some(if d.header.i64("MEASUREMENT", 0) == 1 { scaled_pattern(p, 25.4) } else { p })
|
||||
}
|
||||
|
||||
/// `p` with every length (origins, offsets, dashes) multiplied by `k`.
|
||||
fn scaled_pattern(mut p: HatchPattern, k: f64) -> HatchPattern {
|
||||
for l in &mut p.lines {
|
||||
l.origin = (l.origin.0 * k, l.origin.1 * k);
|
||||
l.delta = (l.delta.0 * k, l.delta.1 * k);
|
||||
for d in &mut l.dashes {
|
||||
*d *= k;
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metric_drawings_draw_iso_scaled_patterns() {
|
||||
let imp = pattern_for(&Drawing::new_imperial(), "ANSI31").map(|p| p.lines[0].delta.1);
|
||||
let met = pattern_for(&Drawing::new_metric(), "ansi31").map(|p| p.lines[0].delta.1);
|
||||
assert_eq!(imp, Some(0.125));
|
||||
assert!(met.is_some_and(|m| (m - 0.125 * 25.4).abs() < 1e-12), "{met:?}");
|
||||
assert!(pattern_for(&Drawing::new_metric(), "NOPE").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,12 @@ impl Autosaver {
|
||||
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
|
||||
/// files of drawings that were saved (no unsaved changes left) or closed.
|
||||
pub fn plan(&mut self, s: &Session, now: f64, minutes: u32, dir: &str) -> Vec<Job> {
|
||||
self.plan_with(s, now, minutes, dir, false)
|
||||
}
|
||||
|
||||
/// [`Autosaver::plan`], with `urgent` writing every drawing with changes not yet autosaved
|
||||
/// now instead of `minutes` after the change (autosave still off when `minutes` is 0).
|
||||
fn plan_with(&mut self, s: &Session, now: f64, minutes: u32, dir: &str, urgent: bool) -> Vec<Job> {
|
||||
let mut jobs = Vec::new();
|
||||
let live: HashSet<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let busy = &self.busy;
|
||||
@@ -193,7 +199,7 @@ impl Autosaver {
|
||||
continue;
|
||||
}
|
||||
let since = *t.since.get_or_insert(now);
|
||||
if minutes == 0 || dir.trim().is_empty() || now - since < f64::from(minutes) * 60.0 {
|
||||
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
|
||||
continue;
|
||||
}
|
||||
let path = t
|
||||
@@ -351,6 +357,20 @@ mod native {
|
||||
self.next_due(now, minutes).map(Duration::from_secs_f64)
|
||||
}
|
||||
|
||||
/// Autosave every drawing with changes not yet autosaved right away, without waiting for
|
||||
/// SAVETIME (the app may not survive what just happened, e.g. a GPU error). Returns how
|
||||
/// many drawings are being written.
|
||||
pub fn save_now(&mut self, s: &Session) -> usize {
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let jobs = self.plan_with(s, now, savetime(), &savefilepath(), true);
|
||||
let writes = jobs.iter().filter(|j| matches!(j, Job::Write { .. })).count();
|
||||
self.perform(jobs);
|
||||
// The lock file marks the files as this running app's (Drawing Recovery skips them).
|
||||
self.last_beat = None;
|
||||
self.heartbeat(&savefilepath(), now);
|
||||
writes
|
||||
}
|
||||
|
||||
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
|
||||
pub fn perform(&mut self, jobs: Vec<Job>) {
|
||||
for job in jobs {
|
||||
|
||||
@@ -447,6 +447,9 @@ impl Interactive for ArrayM {
|
||||
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default()
|
||||
}
|
||||
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
|
||||
self.inner.as_ref().and_then(|m| m.drag(s, cursor))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ARRAYRECT ----------------
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, groups) and
|
||||
//! zero-length geometry or empty text.
|
||||
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, multiline
|
||||
//! styles, groups) and zero-length geometry or empty text.
|
||||
//!
|
||||
//! The JSON form takes `{type?, names?}` and purges without asking. Typed, it asks for the type,
|
||||
//! the names and whether to verify each one, as AutoCAD's -PURGE does.
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, STANDARD_MLINE_STYLE};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::super::qselect::wildcard;
|
||||
@@ -21,6 +21,7 @@ enum Kind {
|
||||
Layers,
|
||||
Linetypes,
|
||||
MleaderStyles,
|
||||
MlineStyles,
|
||||
TableStyles,
|
||||
TextStyles,
|
||||
ZeroLength,
|
||||
@@ -43,7 +44,7 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
|
||||
("Plotstyles", &["plotstyles"], Kind::Absent),
|
||||
("SHapes", &["shapes"], Kind::Absent),
|
||||
("textSTyles", &["textstyles", "textstyle", "styles", "style"], Kind::TextStyles),
|
||||
("Mlinestyles", &["mlinestyles"], Kind::Absent),
|
||||
("Mlinestyles", &["mlinestyles", "mlinestyle"], Kind::MlineStyles),
|
||||
("SEctionviewstyles", &["sectionviewstyles"], Kind::Absent),
|
||||
("Tablestyles", &["tablestyles", "tablestyle"], Kind::TableStyles),
|
||||
("Visualstyles", &["visualstyles"], Kind::Absent),
|
||||
@@ -56,8 +57,17 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
|
||||
|
||||
/// The named kinds `All` purges, in an order where purging one frees the next (blocks free
|
||||
/// layers, layers free linetypes, dimension styles free text styles).
|
||||
const ALL: &[Kind] =
|
||||
&[Kind::Blocks, Kind::Groups, Kind::Dimstyles, Kind::MleaderStyles, Kind::TableStyles, Kind::Layers, Kind::Linetypes, Kind::TextStyles];
|
||||
const ALL: &[Kind] = &[
|
||||
Kind::Blocks,
|
||||
Kind::Groups,
|
||||
Kind::Dimstyles,
|
||||
Kind::MleaderStyles,
|
||||
Kind::MlineStyles,
|
||||
Kind::TableStyles,
|
||||
Kind::Layers,
|
||||
Kind::Linetypes,
|
||||
Kind::TextStyles,
|
||||
];
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
@@ -77,6 +87,7 @@ fn noun(kind: Kind) -> &'static str {
|
||||
Kind::Layers => "layer",
|
||||
Kind::Linetypes => "linetype",
|
||||
Kind::MleaderStyles => "multileader style",
|
||||
Kind::MlineStyles => "multiline style",
|
||||
Kind::TableStyles => "table style",
|
||||
Kind::TextStyles => "text style",
|
||||
Kind::ZeroLength => "zero-length object",
|
||||
@@ -93,6 +104,7 @@ fn json_key(kind: Kind) -> &'static str {
|
||||
Kind::Layers => "layers",
|
||||
Kind::Linetypes => "linetypes",
|
||||
Kind::MleaderStyles => "mleaderstyles",
|
||||
Kind::MlineStyles => "mlinestyles",
|
||||
Kind::TableStyles => "tablestyles",
|
||||
Kind::TextStyles => "textstyles",
|
||||
Kind::ZeroLength => "zeroLength",
|
||||
@@ -198,6 +210,15 @@ fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::MlineStyles => {
|
||||
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::MLine(m) = &e.kind { Some(m.style.clone()) } else { None }));
|
||||
let cur = d.header.str("CMLSTYLE", STANDARD_MLINE_STYLE);
|
||||
d.mline_styles
|
||||
.iter()
|
||||
.filter(|t| !is(&t.name, &[STANDARD_MLINE_STYLE, &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
|
||||
.map(|t| t.name.clone())
|
||||
.collect()
|
||||
}
|
||||
Kind::TableStyles => {
|
||||
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t.style.clone()) } else { None }));
|
||||
let cur = d.header.str("CTABLESTYLE", "Standard");
|
||||
@@ -238,6 +259,7 @@ fn purge_one(s: &mut Session, kind: Kind, name: &str) -> Result<bool> {
|
||||
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, name),
|
||||
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, name),
|
||||
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, name),
|
||||
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, name),
|
||||
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, name),
|
||||
Kind::Groups => retain(&mut d.groups, |g| &g.name, name),
|
||||
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
|
||||
|
||||
@@ -609,6 +609,12 @@ impl Interactive for PasteM {
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if self.mode == PasteMode::Orig || s.clipboard.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::prompt::Drag { handles: Vec::new(), matrix: Mat3::translate(c - s.clipboard_base), extra: Vec::new(), clipboard: true })
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
|
||||
let m = Mat3::translate(c - s.clipboard_base);
|
||||
s.clipboard
|
||||
|
||||
@@ -12,7 +12,7 @@ pub use cadcraft_dxf::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
@@ -153,9 +153,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
|
||||
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
|
||||
.noundo(),
|
||||
CommandSpec::new("script", "Run Script...", run_script)
|
||||
.menu(&["Tools", "Run Script..."])
|
||||
.alias(&["scr"])
|
||||
.params("{path: script (.scr) file} | {data: base64} → {lines}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(ScriptM))),
|
||||
]
|
||||
}
|
||||
|
||||
/// SCRIPT: run the lines of a `.scr` file through [`Session::script`] — the same execution the
|
||||
/// CLI, MCP and the control channel already use, now reachable from the command line and menus.
|
||||
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = if let Some(_path) = str_param(p, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let path = _path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?;
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad("script", "paths are not available on the web; pass `data`"));
|
||||
} else {
|
||||
let data = str_param(p, "data").ok_or_else(|| bad("script", "`path` or `data` (base64) is required"))?;
|
||||
let bytes = base64_decode(data).ok_or_else(|| bad("script", "invalid base64"))?;
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
};
|
||||
let lines = text.lines().count();
|
||||
s.script(&text)?;
|
||||
Ok(json!({ "lines": lines }))
|
||||
}
|
||||
|
||||
/// SCRIPT typed at the command line or chosen from the menu: asks for the script file by name
|
||||
/// (the prompt form; the menu item runs this same machine).
|
||||
struct ScriptM;
|
||||
|
||||
impl Interactive for ScriptM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SCRIPT"
|
||||
}
|
||||
fn prompt(&self, _: &Session) -> Prompt {
|
||||
Prompt::new("Enter name of script file", Accept::TEXT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if matches!(i, Input::Cancel | Input::Enter) {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let Input::Text(t) = i else { return Ok(Step::Continue) };
|
||||
let t = t.trim();
|
||||
if t.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
match run_script(s, &json!({ "path": t })) {
|
||||
Ok(_) => Ok(Step::Done),
|
||||
Err(EngineError::BadParams { msg, .. }) => {
|
||||
s.echo(msg);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = template::run_new(s, p)? {
|
||||
return Ok(r);
|
||||
@@ -171,6 +231,9 @@ fn file_name(path: &str) -> String {
|
||||
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
|
||||
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
|
||||
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
|
||||
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
|
||||
// asks where to save instead of exporting over the original.
|
||||
let path = path.filter(|p| is_drawing_file(p));
|
||||
let i = s.open_drawing(d, &file_name(name), path);
|
||||
if let Some(st) = s.docs.get_mut(i) {
|
||||
// QSAVE keeps a binary DXF binary.
|
||||
@@ -462,6 +525,56 @@ mod tests {
|
||||
s.state().unwrap().title.clone()
|
||||
}
|
||||
|
||||
fn temp_script(name: &str, text: &str) -> String {
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id()));
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_command_runs_a_scr_file() {
|
||||
let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("script", &json!({ "path": path })).unwrap();
|
||||
assert_eq!(r["lines"], 3);
|
||||
assert_eq!(s.state().unwrap().doc.model.len(), 2, "the comment is skipped, circle and rectang draw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_command_reports_a_missing_file() {
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("script", &json!({ "path": "no/such/script.scr" })).unwrap_err().to_string();
|
||||
assert!(e.contains("no/such/script.scr"), "{e}");
|
||||
assert_eq!(s.state().unwrap().doc.model.len(), 0);
|
||||
}
|
||||
|
||||
/// A script that runs itself must be refused, not recurse until the stack dies.
|
||||
#[test]
|
||||
fn a_script_cannot_start_a_script() {
|
||||
let inner = temp_script("inner.scr", "circle 0,0 1\n");
|
||||
let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();
|
||||
assert!(e.contains("already running"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {
|
||||
let good = temp_script("typed.scr", "circle 0,0 1\n");
|
||||
let mut s = Session::new();
|
||||
s.cmdline("script").unwrap();
|
||||
assert!(s.running.is_some(), "SCRIPT asks for the file name");
|
||||
s.cmdline(&good).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.state().unwrap().doc.model.len(), 1);
|
||||
|
||||
// A file that cannot be read: the error is echoed, the command still ends.
|
||||
s.cmdline("script").unwrap();
|
||||
s.cmdline("no/such/script.scr").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("no/such/script.scr")), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_earlier_inactive_drawing_keeps_active_drawing() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects.
|
||||
|
||||
mod patfile;
|
||||
pub use patfile::{PatternChoice, pattern_choices};
|
||||
pub(super) use patfile::{lines_for, run_patterns};
|
||||
|
||||
use cadcraft_color::Color;
|
||||
@@ -10,7 +11,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::lwpoly;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
mod assoc;
|
||||
mod edit;
|
||||
@@ -355,9 +356,63 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
|
||||
Ok(json!({ "handle": hex.first(), "handles": hex }))
|
||||
}
|
||||
|
||||
/// True when two loops have the same vertex sequence (ignoring cyclic shift and winding order).
|
||||
fn same_loop(a: &HatchLoop, b: &HatchLoop) -> bool {
|
||||
if a.vertices.len() != b.vertices.len() {
|
||||
return false;
|
||||
}
|
||||
let n = a.vertices.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
let near = |v1: &PolyVertex, v2: &PolyVertex| v1.p.near(v2.p, 1e-6) && (v1.bulge - v2.bulge).abs() < 1e-6;
|
||||
(0..n).any(|k| (0..n).all(|i| near(&a.vertices[i], &b.vertices[(i + k) % n])))
|
||||
|| (0..n).any(|k| {
|
||||
(0..n).all(|i| {
|
||||
let j = (k + n - (i % n)) % n;
|
||||
let prev_j = (j + n - 1) % n;
|
||||
a.vertices[i].p.near(b.vertices[j].p, 1e-6) && (a.vertices[i].bulge + b.vertices[prev_j].bulge).abs() < 1e-6
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Deduplicate boundary loops that describe the same boundary.
|
||||
fn dedup_loops(loops: &mut Vec<HatchLoop>) {
|
||||
let mut unique: Vec<HatchLoop> = Vec::with_capacity(loops.len());
|
||||
for l in loops.drain(..) {
|
||||
if let Some(existing) = unique.iter_mut().find(|u| same_loop(u, &l)) {
|
||||
for s in l.sources {
|
||||
if !existing.sources.contains(&s) {
|
||||
existing.sources.push(s);
|
||||
}
|
||||
}
|
||||
existing.outer = existing.outer || l.outer;
|
||||
} else {
|
||||
unique.push(l);
|
||||
}
|
||||
}
|
||||
*loops = unique;
|
||||
}
|
||||
|
||||
/// Add the hatches for groups of loops: one per group when separate, otherwise one for all.
|
||||
fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>) -> Result<Vec<Handle>> {
|
||||
let groups: Vec<Vec<HatchLoop>> = if st.separate { groups } else { vec![groups.into_iter().flatten().collect()] };
|
||||
let groups: Vec<Vec<HatchLoop>> = if st.separate {
|
||||
let mut unique_groups: Vec<Vec<HatchLoop>> = Vec::new();
|
||||
for mut g in groups {
|
||||
dedup_loops(&mut g);
|
||||
if g.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !unique_groups.iter().any(|u| u.len() == g.len() && u.iter().zip(&g).all(|(a, b)| same_loop(a, b))) {
|
||||
unique_groups.push(g);
|
||||
}
|
||||
}
|
||||
unique_groups
|
||||
} else {
|
||||
let mut all: Vec<HatchLoop> = groups.into_iter().flatten().collect();
|
||||
dedup_loops(&mut all);
|
||||
vec![all]
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for loops in groups.into_iter().filter(|g| !g.is_empty()) {
|
||||
let h = s.add_entity(st.hatch(loops))?;
|
||||
@@ -528,7 +583,11 @@ impl Interactive for HatchM {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
// The sub-prompts offer the current value: Enter keeps it.
|
||||
match self.asking {
|
||||
Some(HatchAsk::Pattern) => return Prompt::new("Enter a pattern name", Accept::TEXT).default(self.settings.pattern.clone()),
|
||||
Some(HatchAsk::Pattern) => {
|
||||
return Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
.default(self.settings.pattern.clone())
|
||||
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
|
||||
}
|
||||
Some(HatchAsk::Scale) => {
|
||||
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
|
||||
}
|
||||
@@ -542,8 +601,10 @@ impl Interactive for HatchM {
|
||||
Some(HatchAsk::Origin) => return Prompt::new("Specify the pattern origin", Accept::POINT).kw(&ORIGIN_KW).default("Use current"),
|
||||
None => {}
|
||||
}
|
||||
// The pattern picker stays offered while boundaries are picked: a choice sets the pattern.
|
||||
let picker = |p: Prompt| if self.gradient { p } else { p.picker(Picker::HatchPattern(self.settings.pattern.clone())) };
|
||||
if self.selecting {
|
||||
return Prompt::new("Select objects", Accept::SELECT).kw(&[
|
||||
return picker(Prompt::new("Select objects", Accept::SELECT).kw(&[
|
||||
"picK internal point",
|
||||
"Undo",
|
||||
"seTtings",
|
||||
@@ -551,9 +612,9 @@ impl Interactive for HatchM {
|
||||
"Angle",
|
||||
"Origin",
|
||||
"sEparate",
|
||||
]);
|
||||
]));
|
||||
}
|
||||
Prompt::new("Pick internal point", Accept::POINT).kw(&[
|
||||
picker(Prompt::new("Pick internal point", Accept::POINT).kw(&[
|
||||
"Select objects",
|
||||
"Undo",
|
||||
"seTtings",
|
||||
@@ -562,7 +623,7 @@ impl Interactive for HatchM {
|
||||
"Angle",
|
||||
"Origin",
|
||||
"sEparate",
|
||||
])
|
||||
]))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
@@ -616,6 +677,11 @@ impl Interactive for HatchM {
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// A pattern chosen in the picker while boundaries are picked.
|
||||
Input::Text(t) if !self.gradient => {
|
||||
self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// Enter ends boundary picking in either mode and creates the hatch.
|
||||
Input::Enter => self.finish(s),
|
||||
_ => Ok(Step::Continue),
|
||||
@@ -683,4 +749,95 @@ mod tests {
|
||||
assert!(!hatch.solid);
|
||||
assert!(hatch.gradient.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.start("hatch").unwrap();
|
||||
let picker = s.current_prompt().and_then(|p| p.picker);
|
||||
assert_eq!(picker, Some(Picker::HatchPattern(HatchSettings::from(&s, &Value::Null, false).pattern)));
|
||||
// A choice made in the picker while boundaries are picked.
|
||||
s.input(Input::Text("AR-CONC".into())).unwrap();
|
||||
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("AR-CONC".into())));
|
||||
// And at the pattern prompt.
|
||||
s.input(Input::Keyword("Pattern".into())).unwrap();
|
||||
assert!(s.current_prompt().unwrap().picker.is_some());
|
||||
s.input(Input::Text("BRICK".into())).unwrap();
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
|
||||
// An unknown name keeps the pattern.
|
||||
s.start("hatch").unwrap();
|
||||
s.input(Input::Text("NO-SUCH".into())).unwrap();
|
||||
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("BRICK".into())));
|
||||
// GRADIENT offers no pattern picker.
|
||||
s.cancel();
|
||||
s.start("gradient").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_choices_list_the_library_with_its_lines() {
|
||||
let all = pattern_choices();
|
||||
assert_eq!(all.first().map(|c| c.name.as_str()), Some("SOLID"));
|
||||
assert!(all[0].lines.is_empty());
|
||||
let ansi31 = all.iter().find(|c| c.name == "ANSI31").unwrap();
|
||||
assert_eq!(ansi31.lines.len(), 1);
|
||||
assert!(!ansi31.description.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_picks_and_handles_in_one_region_do_not_produce_duplicate_loops() {
|
||||
// One internal point
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h1) = only_hatch(&s);
|
||||
assert_eq!(h1.loops.len(), 1);
|
||||
|
||||
// Two different points in the same rectangle
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[2, 2], [8, 8]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h2) = only_hatch(&s);
|
||||
assert_eq!(h2.loops.len(), 1, "two points in same region must deduplicate loops");
|
||||
|
||||
// The same point twice
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[5, 5], [5, 5]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h3) = only_hatch(&s);
|
||||
assert_eq!(h3.loops.len(), 1, "identical points must deduplicate loops");
|
||||
|
||||
// Three points in the same rectangle
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[2, 2], [5, 5], [8, 8]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h4) = only_hatch(&s);
|
||||
assert_eq!(h4.loops.len(), 1);
|
||||
|
||||
// Same boundary handle twice
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let handle = r["handle"].as_str().unwrap();
|
||||
s.execute("hatch", &json!({ "handles": [handle, handle], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h5) = only_hatch(&s);
|
||||
assert_eq!(h5.loops.len(), 1, "repeated boundary handle must deduplicate loops");
|
||||
|
||||
// Two points in disjoint rectangles: both regions preserved
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("rectang", &json!({ "p1": [20, 0], "p2": [30, 10] })).unwrap();
|
||||
s.execute("hatch", &json!({ "points": [[5, 5], [25, 5]], "pattern": "SOLID" })).unwrap();
|
||||
let (_, h6) = only_hatch(&s);
|
||||
assert_eq!(h6.loops.len(), 2, "disjoint rectangles must both be kept");
|
||||
|
||||
// Gradient with duplicate points in the same rectangle
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.execute("gradient", &json!({ "points": [[2, 2], [8, 8]], "color1": "1", "color2": "3" })).unwrap();
|
||||
let (_, hg) = only_hatch(&s);
|
||||
assert_eq!(hg.loops.len(), 1, "gradient with repeated picks in same region must deduplicate loops");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::assoc::{detach, same_shape};
|
||||
use super::{ORIGIN_KW, OriginMode, closed_loop, island_style};
|
||||
use crate::cmd::helpers::lwpoly;
|
||||
use crate::cmd::{bad, bool_or, point_param, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
/// Upper bound on hatches edited by one call.
|
||||
const MAX_EDITED: usize = 10_000;
|
||||
@@ -328,7 +328,10 @@ impl Interactive for HatchEditM {
|
||||
};
|
||||
Prompt::new("Island detection style", Accept::TEXT).kw(&["Ignore", "Outer", "Normal"]).default(cur)
|
||||
}
|
||||
Ask::Pattern => Prompt::new("Enter a pattern name", Accept::TEXT).default(h.map(|h| h.pattern).unwrap_or_default()),
|
||||
Ask::Pattern => {
|
||||
let current = h.map(|h| h.pattern).unwrap_or_default();
|
||||
Prompt::new("Enter a pattern name", Accept::TEXT).default(current.clone()).picker(Picker::HatchPattern(current))
|
||||
}
|
||||
Ask::Scale => Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", h.map_or(1.0, |h| h.scale))),
|
||||
Ask::Angle => Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER)
|
||||
.default(format!("{:.0}", h.map_or(0.0, |h| h.angle.to_degrees()))),
|
||||
|
||||
@@ -44,6 +44,25 @@ pub(crate) fn names() -> Vec<String> {
|
||||
v
|
||||
}
|
||||
|
||||
/// A pattern offered for new hatches: a library pattern or one a `.pat` file loaded.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PatternChoice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// The line families at unit scale (none for SOLID).
|
||||
pub lines: Vec<PatternLine>,
|
||||
}
|
||||
|
||||
/// Every pattern offered for new hatches, in the order of [`names`], for a pattern picker.
|
||||
pub fn pattern_choices() -> Vec<PatternChoice> {
|
||||
let mut v: Vec<PatternChoice> = standard_patterns()
|
||||
.into_iter()
|
||||
.map(|p| PatternChoice { name: p.name.to_string(), description: p.description.to_string(), lines: p.lines })
|
||||
.collect();
|
||||
v.extend(store().iter().map(|p| PatternChoice { name: p.name.clone(), description: p.description.clone(), lines: p.lines.clone() }));
|
||||
v
|
||||
}
|
||||
|
||||
/// Add the patterns of a `.pat` file's text (a pattern loaded before under the same name is
|
||||
/// replaced). Returns the names added and the file's problems.
|
||||
pub(crate) fn load(text: &str) -> (Vec<String>, Vec<String>) {
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("mview", "Viewports", run_mview)
|
||||
.alias(&["mv"])
|
||||
.params(
|
||||
"{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), layout?}",
|
||||
"{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), name? (restore: a model space viewport configuration, default *Active; its viewports fill p1-p2 or the printable area), layout?}",
|
||||
)
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
|
||||
CommandSpec::new("mview.polygonal", "Polygonal Viewport", |s, p| run_mview(s, &viewports::with(p.clone(), "option", json!("polygonal"))))
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
//! Clipped viewports and MVIEW's options: polygonal and object viewports (MVIEW Polygonal /
|
||||
//! Object), VPCLIP, ON/OFF, Lock and Restore, and the typed MVIEW prompts.
|
||||
//! Object), VPCLIP, ON/OFF, Lock and Restore (a model space viewport configuration as layout
|
||||
//! viewports), and the typed MVIEW prompts.
|
||||
//!
|
||||
//! A clipped viewport keeps its rectangle as the boundary's extents and points at a paper-space
|
||||
//! entity (a closed polyline, circle, ellipse or spline) whose outline clips what it shows
|
||||
//! ([`Viewport::clip`], DXF group 340).
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport, VportConfig, VportTile};
|
||||
use cadcraft_geom::{Bounds2, PolyVertex, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::{self, handle_value};
|
||||
use super::super::helpers::{lwpoly, rect_vertices};
|
||||
use super::super::{bad, bool_or, points_param, str_param, targets};
|
||||
use super::{fit_view, layout_viewports, mview, split, target_layout};
|
||||
use super::super::{bad, bool_or, point_param, points_param, str_param, targets};
|
||||
use super::{fit_view, layout_viewports, mview, printable, split, target_layout};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most vertices a typed or JSON polygonal boundary may have.
|
||||
const MAX_VERTICES: usize = 10_000;
|
||||
/// Most viewports MVIEW Restore creates (a configuration read from a file may hold more tiles).
|
||||
const MAX_RESTORED: usize = 64;
|
||||
|
||||
/// The polygon outline's extents of a closed paper-space entity usable as a clip boundary.
|
||||
fn boundary_bounds(k: &EntityKind) -> Option<Bounds2> {
|
||||
@@ -210,6 +213,66 @@ fn mview_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>(), "locked": locked }))
|
||||
}
|
||||
|
||||
/// Where tile `t` of a viewport configuration lands when the whole configuration fills `r`; `None`
|
||||
/// for a tile with no usable area (configurations come from files).
|
||||
fn tile_rect(t: &VportTile, r: Bounds2) -> Option<Bounds2> {
|
||||
let f = |v: f64| v.is_finite().then(|| v.clamp(0.0, 1.0));
|
||||
let (x0, y0, x1, y1) = (f(t.min.x)?, f(t.min.y)?, f(t.max.x)?, f(t.max.y)?);
|
||||
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let at = |x: f64, y: f64| Vec2::new(r.min.x + x * r.width(), r.min.y + y * r.height());
|
||||
Some(Bounds2::new(at(x0, y0), at(x1, y1)))
|
||||
}
|
||||
|
||||
/// The view a restored tile shows in a viewport of size `r`: its saved centre, and its height
|
||||
/// grown when the viewport is narrower than the tile was, so the whole saved view still shows.
|
||||
fn tile_view(t: &VportTile, r: Bounds2) -> Option<(Vec2, f64)> {
|
||||
if !(t.center.is_finite() && t.height.is_finite() && t.height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let aspect = r.width() / r.height().max(1e-12);
|
||||
let h = if t.aspect.is_finite() && t.aspect > 0.0 && aspect > 0.0 { t.height.max(t.height * t.aspect / aspect) } else { t.height };
|
||||
(h.is_finite() && h > 0.0).then_some((t.center, h))
|
||||
}
|
||||
|
||||
/// MVIEW Restore: `{name? (default *Active), p1?, p2? (default: the printable area), layout?}`.
|
||||
/// One viewport per tile of the model space viewport configuration, arranged as the tiles are
|
||||
/// over the window, each showing its tile's view.
|
||||
fn mview_restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let space = target_layout(s, "mview", p)?;
|
||||
let Space::Paper(layout) = &space else { return Err(bad("mview", "not a layout")) };
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(VportConfig::ACTIVE).to_string();
|
||||
let tiles = super::super::view::config_tiles(s, &name)
|
||||
.ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::<String>())))?;
|
||||
let rect = match (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(Some(a), Some(b)) => Bounds2::new(a, b),
|
||||
_ => printable(s, layout)?,
|
||||
};
|
||||
if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() || rect.width() < 1e-6 || rect.height() < 1e-6 {
|
||||
return Err(bad("mview", "the viewport has no area"));
|
||||
}
|
||||
if rect.width() > 1e7 || rect.height() > 1e7 {
|
||||
return Err(bad("mview", "the viewport is too large"));
|
||||
}
|
||||
let common = s.current_common();
|
||||
let d = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for t in tiles.iter().take(MAX_RESTORED) {
|
||||
let Some(r) = tile_rect(t, rect) else { continue };
|
||||
let id = next_viewport_id(d);
|
||||
let mut vp = new_viewport(d, r, id);
|
||||
if let Some((c, h)) = tile_view(t, r) {
|
||||
(vp.view_center, vp.view_height) = (c, h);
|
||||
}
|
||||
out.push(d.add(&space, common.clone(), EntityKind::Viewport(vp))?);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other(format!("Viewport configuration \"{name}\" has no viewports to restore.")));
|
||||
}
|
||||
Ok(json!({ "configuration": name, "viewports": handles_json(&out) }))
|
||||
}
|
||||
|
||||
/// MVIEW's options other than corners and Fit; `None` when `option` is not one of them.
|
||||
pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option<Result<Value>> {
|
||||
let opt = str_param(p, "option")?.trim().to_ascii_lowercase();
|
||||
@@ -219,7 +282,7 @@ pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option<Result<Value>>
|
||||
"on" => mview_on(s, p, true),
|
||||
"off" => mview_on(s, p, false),
|
||||
"lock" | "l" => mview_lock(s, p),
|
||||
"restore" | "r" => Err(bad("mview", "named viewport configurations are not available yet")),
|
||||
"restore" | "r" => mview_restore(s, p),
|
||||
"fit" | "f" | "corners" | "" => return None,
|
||||
_ => Err(bad("mview", "option must be fit, polygonal, object, on, off, lock or restore")),
|
||||
})
|
||||
@@ -471,6 +534,10 @@ enum Stage {
|
||||
Polygon(PolygonInput),
|
||||
Object,
|
||||
Lock,
|
||||
/// Restore: the configuration's name, then where its viewports go.
|
||||
RestoreName,
|
||||
RestoreCorner(String),
|
||||
RestoreOpposite(String, Vec2),
|
||||
/// ON/OFF (Some(on)) or Lock (None, with the chosen state) on picked viewports.
|
||||
Select {
|
||||
on: Option<bool>,
|
||||
@@ -507,6 +574,18 @@ impl MviewM {
|
||||
s.echo(format!("{n} viewport(s) created, zoomed to the model extents."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// MVIEW Restore with the typed name, over `rect` (None: Fit).
|
||||
fn restore(s: &mut Session, name: &str, rect: Option<Bounds2>) -> Result<Step> {
|
||||
let mut p = json!({ "option": "restore", "name": name });
|
||||
if let Some(r) = rect {
|
||||
p = with(p, "p1", json!([r.min.x, r.min.y]));
|
||||
p = with(p, "p2", json!([r.max.x, r.max.y]));
|
||||
}
|
||||
let r = mview_restore(s, &p)?;
|
||||
let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0);
|
||||
s.echo(format!("{n} viewport(s) restored from \"{name}\"."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
fn corner_keyword(&mut self, s: &mut Session, k: &str) -> Result<Step> {
|
||||
if k == "fit" || k == "f" {
|
||||
return self.create(s, None);
|
||||
@@ -524,7 +603,7 @@ impl MviewM {
|
||||
"lock" | "l" => Stage::Lock,
|
||||
"object" | "o" => Stage::Object,
|
||||
"polygonal" | "p" => Stage::Polygon(PolygonInput::default()),
|
||||
"restore" | "r" => return Err(EngineError::Other("Named viewport configurations are not available yet.".into())),
|
||||
"restore" | "r" => Stage::RestoreName,
|
||||
_ => return Err(EngineError::Other("Requires a point or option keyword.".into())),
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
@@ -562,6 +641,9 @@ impl Interactive for MviewM {
|
||||
Stage::Polygon(pi) => pi.prompt(),
|
||||
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
|
||||
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
|
||||
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
|
||||
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
||||
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
||||
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
|
||||
}
|
||||
}
|
||||
@@ -653,6 +735,53 @@ impl Interactive for MviewM {
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::RestoreName => {
|
||||
let name = match &i {
|
||||
Input::Enter => VportConfig::ACTIVE.to_string(),
|
||||
Input::Text(t) | Input::Keyword(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||||
_ => {
|
||||
self.stage = Stage::RestoreName;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
};
|
||||
self.stage = Stage::RestoreName;
|
||||
if name == "?" {
|
||||
let names = super::super::view::config_names(s);
|
||||
s.echo(if names.is_empty() { "No saved viewport configurations.".to_string() } else { names.join(", ") });
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if super::super::view::config_tiles(s, &name).is_none() {
|
||||
return Err(EngineError::Other(format!(
|
||||
"Could not find viewport configuration \"{}\".",
|
||||
name.chars().take(64).collect::<String>()
|
||||
)));
|
||||
}
|
||||
self.stage = Stage::RestoreCorner(name);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::RestoreCorner(name) => match i {
|
||||
Input::Enter => Self::restore(s, &name, None),
|
||||
Input::Keyword(k) | Input::Text(k) if matches!(k.trim().to_ascii_lowercase().as_str(), "fit" | "f") => Self::restore(s, &name, None),
|
||||
Input::Point(a) => {
|
||||
self.stage = Stage::RestoreOpposite(name, a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(_) | Input::Text(_) => {
|
||||
self.stage = Stage::RestoreCorner(name);
|
||||
Err(EngineError::Other("Requires a point or option keyword.".into()))
|
||||
}
|
||||
_ => {
|
||||
self.stage = Stage::RestoreCorner(name);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Stage::RestoreOpposite(name, a) => match i {
|
||||
Input::Point(b) => Self::restore(s, &name, Some(Bounds2::new(a, b))),
|
||||
_ => {
|
||||
self.stage = Stage::RestoreOpposite(name, a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Stage::Select { on, locked, mut picked } => match i {
|
||||
Input::Enter => {
|
||||
if picked.is_empty() {
|
||||
@@ -673,12 +802,22 @@ impl Interactive for MviewM {
|
||||
},
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match &self.stage {
|
||||
Stage::Opposite(a) => {
|
||||
split(Bounds2::new(*a, c), self.count, &self.arrangement).into_iter().map(|r| lwpoly(rect_vertices(r.min, r.max), true)).collect()
|
||||
}
|
||||
Stage::Polygon(pi) => pi.preview(c),
|
||||
Stage::RestoreOpposite(name, a) => {
|
||||
let r = Bounds2::new(*a, c);
|
||||
super::super::view::config_tiles(s, name)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take(MAX_RESTORED)
|
||||
.filter_map(|t| tile_rect(t, r))
|
||||
.map(|r| lwpoly(rect_vertices(r.min, r.max), true))
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1712,6 +1712,26 @@ impl SelectThen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects whose text MIRROR keeps readable instead of mirroring it with the geometry.
|
||||
fn keeps_reading_when_mirrored(k: &EntityKind) -> bool {
|
||||
match k {
|
||||
EntityKind::Insert(i) => !i.attribs.is_empty(),
|
||||
k => matches!(
|
||||
k,
|
||||
EntityKind::Text(_)
|
||||
| EntityKind::AttDef(_)
|
||||
| EntityKind::MText(_)
|
||||
| EntityKind::Dimension(_)
|
||||
| EntityKind::MLeader(_)
|
||||
| EntityKind::Table(_)
|
||||
| EntityKind::Tolerance(_)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text objects transformed for each frame of a MIRROR preview (on the CPU); more are not drawn.
|
||||
const MAX_MIRROR_TEXT_PREVIEW: usize = 1000;
|
||||
|
||||
/// A finite length greater than zero (rejects 0, negatives, NaN and infinity).
|
||||
fn positive_length(v: Option<f64>) -> Option<f64> {
|
||||
v.filter(|f| f.is_finite() && *f > 1e-12)
|
||||
@@ -1927,6 +1947,41 @@ impl Interactive for SelectThen {
|
||||
out.push(line(base, c));
|
||||
out
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if (self.op == Op::Rotate && self.reference) || !self.sel.done {
|
||||
return None;
|
||||
}
|
||||
let base = self.pts.first().copied()?;
|
||||
let matrix = match self.op {
|
||||
Op::Move | Op::Copy => Mat3::translate(c - base),
|
||||
Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)),
|
||||
Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)),
|
||||
Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?),
|
||||
Op::Mirror if !self.ask_erase => Mat3::mirror(base, c),
|
||||
_ => return None,
|
||||
};
|
||||
let mut extra = vec![line(base, c)];
|
||||
if self.op != Op::Mirror {
|
||||
return Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra, clipboard: false });
|
||||
}
|
||||
// Mirrored text keeps reading left to right (MIRRTEXT 0), which the matrix alone can't
|
||||
// draw: those objects are transformed one by one, the rest is mirrored on the GPU.
|
||||
let d = s.doc().ok()?;
|
||||
let mut handles = Vec::with_capacity(self.objs.len());
|
||||
let mut texts = 0;
|
||||
for h in &self.objs {
|
||||
let Some(e) = d.entity(*h) else { continue };
|
||||
if !keeps_reading_when_mirrored(&e.kind) {
|
||||
handles.push(*h);
|
||||
} else if texts < MAX_MIRROR_TEXT_PREVIEW {
|
||||
texts += 1;
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&matrix);
|
||||
extra.push(k);
|
||||
}
|
||||
}
|
||||
Some(crate::prompt::Drag { handles, matrix, extra, clipboard: false })
|
||||
}
|
||||
}
|
||||
|
||||
/// An OFFSET option prompt opened from "Specify offset distance".
|
||||
@@ -2730,6 +2785,12 @@ impl Interactive for DisplaceM {
|
||||
}
|
||||
self.inner.preview(s, c)
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if self.displacement {
|
||||
return None;
|
||||
}
|
||||
self.inner.drag(s, c)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn _unused(_: Circle) {}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfig};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -233,7 +234,7 @@ fn unknown_device(cmd: &str, name: &str) -> EngineError {
|
||||
/// paper's printable area. A device change also takes the device's default orientation unless
|
||||
/// `landscape` is given. Returns whether the paper was set here (else `paper`, if any, is a
|
||||
/// standard size for the caller to look up).
|
||||
pub(crate) fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
|
||||
pub fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
|
||||
let given = match p.get("device") {
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad(cmd, "`device` must be a plotter name"))?.trim()),
|
||||
None => None,
|
||||
@@ -325,10 +326,74 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- PLOTWINDOW ----------
|
||||
|
||||
fn run_plotwindow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, b) = (point_req("plotwindow", p, "p1")?, point_req("plotwindow", p, "p2")?);
|
||||
set_plot_window(s, a, b)
|
||||
}
|
||||
|
||||
fn set_plot_window(s: &mut Session, a: Vec2, b: Vec2) -> Result<Value> {
|
||||
let w = Bounds2::new(a, b);
|
||||
if !(w.width() > 0.0 && w.height() > 0.0) {
|
||||
return Err(bad("plotwindow", "the window needs a width and a height"));
|
||||
}
|
||||
s.plot_window = Some(w);
|
||||
Ok(json!({ "window": [[w.min.x, w.min.y], [w.max.x, w.max.y]] }))
|
||||
}
|
||||
|
||||
/// PLOTWINDOW: two corners picked in the drawing, with the window's rectangle following the cursor.
|
||||
#[derive(Default)]
|
||||
struct PlotWindowM {
|
||||
first: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Interactive for PlotWindowM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLOTWINDOW"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (i, self.first) {
|
||||
(Input::Point(p), None) => {
|
||||
self.first = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Input::Point(p), Some(a)) => {
|
||||
set_plot_window(s, a, p)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Input::Cancel, _) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
|
||||
let Some(a) = self.first else { return Vec::new() };
|
||||
let corners = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y), a];
|
||||
corners
|
||||
.windows(2)
|
||||
.filter_map(|w| match w {
|
||||
[p, q] => Some(super::helpers::line(*p, *q)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- PLOTTERMANAGER ----------
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("plotwindow", "Plot Window", run_plotwindow)
|
||||
.params("{p1: [x,y], p2: [x,y]} (typed: two corners): the window the Plot dialog's \"Window <\" plots, kept in the session")
|
||||
.enabled(has_doc)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(PlotWindowM::default()))),
|
||||
CommandSpec::new("plottermanager", "Plotter Manager...", run_plottermanager)
|
||||
.menu(&["File", "Plotter Manager..."])
|
||||
.params(
|
||||
|
||||
@@ -60,7 +60,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["Format", "Multileader Style..."])
|
||||
.alias(&["mls"])
|
||||
.params("{name, arrowSize?, textHeight?, landingGap?, dogleg?, textStyle?, annotative?, current?} → styles"),
|
||||
CommandSpec::new("ddptype", "Point Style...", run_ptype).menu(&["Format", "Point Style..."]).params("{pdmode, pdsize}"),
|
||||
CommandSpec::new("ddptype", "Point Style...", run_ptype)
|
||||
.menu(&["Format", "Point Style..."])
|
||||
.alias(&["ptype"])
|
||||
.params("{pdmode, pdsize}"),
|
||||
CommandSpec::new("rename", "Rename...", run_rename)
|
||||
.menu(&["Format", "Rename..."])
|
||||
.alias(&["ren", "-rename"])
|
||||
@@ -371,6 +374,8 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
h.pattern_lines = super::hatch::lines_for(v);
|
||||
}
|
||||
h.pattern = v.to_ascii_uppercase();
|
||||
// The old pattern's definition lines no longer apply.
|
||||
h.pattern_lines.clear();
|
||||
h.solid = h.pattern == "SOLID";
|
||||
if !h.solid {
|
||||
// A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -26,10 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::<PickM>::default())),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
|
||||
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
|
||||
.params("{handles?}")
|
||||
.noundo(),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom).params("{mode: distance|radius|angle|area, ...}").noundo(),
|
||||
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(),
|
||||
@@ -276,69 +272,8 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "matches": found, "total": total, "message": msg }))
|
||||
}
|
||||
|
||||
// ---------------- MASSPROP / MEASUREGEOM ----------------
|
||||
|
||||
/// Area, perimeter, centroid and second moments of a closed boundary.
|
||||
fn region_props(pts: &[Vec2]) -> Option<(f64, f64, Vec2, f64, f64)> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return None;
|
||||
}
|
||||
let (mut a, mut cx, mut cy, mut ixx, mut iyy, mut per) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (p, q) = (*pts.get(i)?, *pts.get((i + 1) % n)?);
|
||||
let c = p.cross(q);
|
||||
a += c;
|
||||
cx += (p.x + q.x) * c;
|
||||
cy += (p.y + q.y) * c;
|
||||
ixx += (p.y * p.y + p.y * q.y + q.y * q.y) * c;
|
||||
iyy += (p.x * p.x + p.x * q.x + q.x * q.x) * c;
|
||||
per += p.dist(q);
|
||||
}
|
||||
a /= 2.0;
|
||||
if a.abs() < 1e-15 {
|
||||
return None;
|
||||
}
|
||||
let c = Vec2::new(cx / (6.0 * a), cy / (6.0 * a));
|
||||
Some((a.abs(), per, c, (ixx / 12.0).abs(), (iyy / 12.0).abs()))
|
||||
}
|
||||
|
||||
fn closed_points(k: &EntityKind) -> Option<Vec<Vec2>> {
|
||||
match k {
|
||||
EntityKind::Circle(c) => {
|
||||
let mut v = Vec::new();
|
||||
cadcraft_geom::Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-5, &mut v);
|
||||
v.pop();
|
||||
Some(v)
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let mut v = Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(1e-5);
|
||||
if v.len() > 1 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
|
||||
v.pop();
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
EntityKind::Hatch(h) => h.loops.first().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(1e-5)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let Some((a, per, c, ixx, iyy)) = closed_points(&e.kind).and_then(|v| region_props(&v)) else { continue };
|
||||
lines.push(format!("---------------- REGIONS ----------------\nArea: {a:.4}\nPerimeter: {per:.4}\nCentroid: X: {:.4} Y: {:.4}\nMoments of inertia: X: {ixx:.4} Y: {iyy:.4}", c.x, c.y));
|
||||
out.push(json!({ "handle": h.hex(), "area": a, "perimeter": per, "centroid": [c.x, c.y], "ixx": ixx, "iyy": iyy }));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Select closed objects (regions, closed polylines, circles).".into()));
|
||||
}
|
||||
Ok(json!({ "objects": out, "message": lines.join("\n") }))
|
||||
}
|
||||
// ---------------- MEASUREGEOM ----------------
|
||||
// (MASSPROP is in `inquiry/regions.rs`.)
|
||||
|
||||
fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("radius.measure", "`handle` is required"))?;
|
||||
@@ -410,12 +345,4 @@ mod tests {
|
||||
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
|
||||
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_mass_properties() {
|
||||
let (a, per, c, _, _) = region_props(&[Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(4.0, 2.0), Vec2::new(0.0, 2.0)]).unwrap();
|
||||
assert!((a - 8.0).abs() < 1e-12);
|
||||
assert!((per - 12.0).abs() < 1e-12);
|
||||
assert!(c.near(Vec2::new(2.0, 1.0), 1e-12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
|
||||
mod named_views;
|
||||
mod vports;
|
||||
|
||||
pub(super) use vports::{model_vports, tile_model};
|
||||
pub(super) use vports::{config_names, config_tiles, model_vports, tile_model};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
|
||||
@@ -71,6 +71,22 @@ fn with_option(p: &Value, option: &str) -> Value {
|
||||
p
|
||||
}
|
||||
|
||||
/// The tiles of viewport configuration `name` (any case), for MVIEW Restore: `*Active` is the
|
||||
/// model space as shown now (one full tile with the model view when it isn't tiled), any other
|
||||
/// name a saved configuration. `None` when there is no such configuration.
|
||||
pub(in crate::cmd) fn config_tiles(s: &Session, name: &str) -> Option<Vec<VportTile>> {
|
||||
let name = name.trim();
|
||||
if name.eq_ignore_ascii_case(VportConfig::ACTIVE) {
|
||||
return Some(live(s.state().ok()?, s.viewport_px).0);
|
||||
}
|
||||
s.doc().ok()?.vports.iter().find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name)).map(|c| c.tiles.clone())
|
||||
}
|
||||
|
||||
/// The saved viewport configurations' names.
|
||||
pub(in crate::cmd) fn config_names(s: &Session) -> Vec<String> {
|
||||
s.doc().map(|d| d.vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------- tile state ----------------
|
||||
|
||||
type Rect = (Vec2, Vec2);
|
||||
|
||||
@@ -75,6 +75,15 @@ impl Interactive for Guarded {
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if self.broken.get().is_some() {
|
||||
return None;
|
||||
}
|
||||
catch_unwind(AssertUnwindSafe(|| self.inner.drag(s, cursor))).unwrap_or_else(|_| {
|
||||
self.broken.set(Some("panic in preview"));
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -37,8 +37,9 @@ use serde_json::Value;
|
||||
pub use cadcraft_doc as doc;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use cadcraft_render as render;
|
||||
pub use cmd::hatch::{PatternChoice, pattern_choices};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use prompt::{Accept, Input, Interactive, Prompt, Step};
|
||||
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
@@ -542,6 +543,8 @@ pub struct Session {
|
||||
pub active: usize,
|
||||
pub settings: Settings,
|
||||
pub running: Option<Running>,
|
||||
/// A script is running ([`Session::script`]); a SCRIPT command inside it is refused.
|
||||
in_script: bool,
|
||||
/// The command suspended while a transparent command (`'ZOOM`, `'PAN`…) prompts inside it;
|
||||
/// it resumes when that command ends. One level, as in AutoCAD.
|
||||
pub suspended: Option<Running>,
|
||||
@@ -582,6 +585,8 @@ pub struct Session {
|
||||
pub last_used: LastUsed,
|
||||
/// Where `clipboard` came from and the layers, styles and blocks its objects need.
|
||||
pub clipboard_source: Option<cmd::clipboard::ClipSource>,
|
||||
/// The window last picked with PLOTWINDOW (the Plot dialog's "Window <").
|
||||
pub plot_window: Option<cadcraft_geom::Bounds2>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -603,6 +608,7 @@ impl Session {
|
||||
active: 0,
|
||||
settings: Settings::default(),
|
||||
running: None,
|
||||
in_script: false,
|
||||
suspended: None,
|
||||
log: Vec::new(),
|
||||
last_command: None,
|
||||
@@ -624,6 +630,7 @@ impl Session {
|
||||
last_dim: None,
|
||||
last_used: LastUsed::default(),
|
||||
clipboard_source: None,
|
||||
plot_window: None,
|
||||
}
|
||||
}
|
||||
pub fn new_drawing(&mut self, metric: bool) -> usize {
|
||||
@@ -1183,6 +1190,16 @@ impl Session {
|
||||
r.machine.preview(self, cursor).into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
|
||||
}
|
||||
|
||||
/// The active command's preview as a transform of existing objects (see
|
||||
/// [`Interactive::drag`]), with its extra rubber-band geometry as entities.
|
||||
pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec<Entity>)> {
|
||||
let r = self.running.as_ref()?;
|
||||
let d = r.machine.drag(self, cursor)?;
|
||||
let common = self.current_common();
|
||||
let extra = d.extra.iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k.clone() }).collect();
|
||||
Some((d, extra))
|
||||
}
|
||||
|
||||
/// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a
|
||||
/// script line (see [`Session::script`]), so `MOVE L 0,0 5,5` ends the selection with the
|
||||
/// second space and `ERASE L ` with the trailing one. Input after the command ends is ignored.
|
||||
@@ -1300,7 +1317,20 @@ impl Session {
|
||||
/// Run a script, as AutoCAD reads one: a space or a line end ends an input, and an empty
|
||||
/// input (two spaces in a row, a space at the end of a line, an empty line) is Enter. At
|
||||
/// free-text prompts spaces are part of the text. Lines starting with `;` are comments.
|
||||
///
|
||||
/// A script cannot run a script: the SCRIPT command (or another `cmd {json}` call) inside a
|
||||
/// running one is refused, so two files that run each other cannot recurse until the stack dies.
|
||||
pub fn script(&mut self, text: &str) -> Result<()> {
|
||||
if self.in_script {
|
||||
return Err(EngineError::BadParams { cmd: "script".into(), msg: "a script is already running; nested scripts are not supported".into() });
|
||||
}
|
||||
self.in_script = true;
|
||||
let r = self.script_lines(text);
|
||||
self.in_script = false;
|
||||
r
|
||||
}
|
||||
|
||||
fn script_lines(&mut self, text: &str) -> Result<()> {
|
||||
for line in text.lines() {
|
||||
// Trailing spaces are inputs (Enter); other trailing whitespace (`\r`) is not.
|
||||
let line = line.trim_end_matches(|c: char| c.is_whitespace() && c != ' ');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Interactive commands: prompts, inputs and the command-line coordinate grammar.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Result, Session};
|
||||
@@ -45,17 +45,34 @@ pub struct Prompt {
|
||||
/// index): Dynamic Input shows its dimensions ([`crate::grip_dims`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grip: Option<(Handle, usize)>,
|
||||
/// A visual chooser the UI can show for this prompt; a choice made in it is entered as
|
||||
/// [`Input::Text`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub picker: Option<Picker>,
|
||||
}
|
||||
|
||||
/// A visual chooser for a prompt ([`Prompt::picker`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Picker {
|
||||
/// A hatch pattern by name (see [`crate::pattern_choices`]); holds the current pattern.
|
||||
HatchPattern(String),
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
pub fn new(message: impl Into<String>, accept: Accept) -> Self {
|
||||
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None }
|
||||
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false, grip: None, picker: None }
|
||||
}
|
||||
/// The grip being stretched (the `grip` field).
|
||||
pub fn grip(mut self, g: (Handle, usize)) -> Self {
|
||||
self.grip = Some(g);
|
||||
self
|
||||
}
|
||||
/// The visual chooser the UI can show (the `picker` field).
|
||||
pub fn picker(mut self, p: Picker) -> Self {
|
||||
self.picker = Some(p);
|
||||
self
|
||||
}
|
||||
pub fn kw(mut self, k: &[&str]) -> Self {
|
||||
self.keywords = k.iter().map(|s| s.to_string()).collect();
|
||||
self
|
||||
@@ -165,6 +182,24 @@ pub trait Interactive: Send {
|
||||
fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec<EntityKind> {
|
||||
Vec::new()
|
||||
}
|
||||
/// The preview as existing objects under one transform, when it is that (MOVE, COPY,
|
||||
/// ROTATE, SCALE). The UI then draws them from geometry uploaded once instead of rebuilding
|
||||
/// the transformed copies every frame; `None` falls back to [`Interactive::preview`].
|
||||
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`Interactive::drag`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Drag {
|
||||
pub handles: Vec<Handle>,
|
||||
pub matrix: Mat3,
|
||||
/// Rubber-band geometry drawn as well (the base-to-cursor line), and dragged objects the
|
||||
/// matrix can't draw (text MIRROR keeps readable), already transformed.
|
||||
pub extra: Vec<EntityKind>,
|
||||
/// The dragged objects are the clipboard's ([`Session::clipboard`]), not `handles`.
|
||||
pub clipboard: bool,
|
||||
}
|
||||
|
||||
/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist<angle`, `dist<angle`, `#x,y` (absolute),
|
||||
|
||||
@@ -154,13 +154,31 @@ pub(crate) fn osnap_with(
|
||||
base: Option<Vec2>,
|
||||
deferred: bool,
|
||||
) -> Option<SnapHit> {
|
||||
osnap_with_neighborhood(d, space, ix, cursor, aperture, osmode, base, deferred).0
|
||||
}
|
||||
|
||||
/// Find the best object snap and retain the local-space primitives gathered for that query.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn osnap_with_neighborhood(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
||||
cursor: Vec2,
|
||||
aperture: f64,
|
||||
osmode: u32,
|
||||
base: Option<Vec2>,
|
||||
deferred: bool,
|
||||
) -> (Option<SnapHit>, Option<Vec<Prim>>) {
|
||||
if osmode == 0 || osmode & mode::OFF != 0 || !cursor.is_finite() || !(aperture.is_finite() && aperture > 0.0) {
|
||||
return None;
|
||||
return (None, None);
|
||||
}
|
||||
let mut best = osnap_in(d, space, ix, cursor, aperture, osmode, base, deferred, &[], None);
|
||||
let (mut best, near_prims) = match osnap_in_with_prims(d, space, ix, cursor, aperture, osmode, base, deferred, &[], None) {
|
||||
Some((hit, prims)) => (hit, Some(prims)),
|
||||
None => (None, None),
|
||||
};
|
||||
let views = viewport::near(d, space, cursor, aperture);
|
||||
if views.is_empty() {
|
||||
return best;
|
||||
return (best, near_prims);
|
||||
}
|
||||
let model = Space::Model;
|
||||
let model_ix = crate::spatial::index(d, &model);
|
||||
@@ -190,7 +208,7 @@ pub(crate) fn osnap_with(
|
||||
best = Some(hit);
|
||||
}
|
||||
}
|
||||
best
|
||||
(best, near_prims)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -206,6 +224,22 @@ fn osnap_in(
|
||||
frozen: &[String],
|
||||
clip: Option<Bounds2>,
|
||||
) -> Option<SnapHit> {
|
||||
osnap_in_with_prims(d, space, ix, cursor, aperture, osmode, base, deferred, frozen, clip).and_then(|(hit, _)| hit)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn osnap_in_with_prims(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
||||
cursor: Vec2,
|
||||
aperture: f64,
|
||||
osmode: u32,
|
||||
base: Option<Vec2>,
|
||||
deferred: bool,
|
||||
frozen: &[String],
|
||||
clip: Option<Bounds2>,
|
||||
) -> Option<(Option<SnapHit>, Vec<Prim>)> {
|
||||
let (mut cands, near_prims) = gather_in(d, space, ix, cursor, aperture, osmode, frozen)?;
|
||||
// Intersections between nearby primitives; Apparent Intersection also where their extensions
|
||||
// meet (lines extended, arcs as full circles: in 2D, the "apparent" crossing of objects
|
||||
@@ -236,7 +270,8 @@ fn osnap_in(
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(cands, &near_prims, cursor, aperture, osmode, base, deferred, clip)
|
||||
let hit = finish(cands, &near_prims, cursor, aperture, osmode, base, deferred, clip);
|
||||
Some((hit, near_prims))
|
||||
}
|
||||
|
||||
/// Whether a primitive has a meaningful extension (a line, an arc, a circle, an xline or ray).
|
||||
|
||||
@@ -116,9 +116,10 @@ pub fn lattice_snap(p: Vec2, d: f64, origin: Vec2, angle: f64) -> Vec2 {
|
||||
|
||||
/// Grid snap for a cursor update: with snap on and isometric style, the cursor snapped to the
|
||||
/// isometric lattice, and the settings with grid snap off so the rectangular grid leaves it be.
|
||||
/// Otherwise both unchanged.
|
||||
/// Otherwise both unchanged, also when PolarSnap (SNAPTYPE 1) is the snap type: snap mode then
|
||||
/// steps along the tracking paths instead.
|
||||
pub(crate) fn grid_snap(doc: &cadcraft_doc::Drawing, mut s: Settings, p: Vec2) -> (Vec2, Settings) {
|
||||
if !(s.snapmode && s.isodraft) {
|
||||
if !(s.snapmode && s.isodraft) || s.polarsnap {
|
||||
return (p, s);
|
||||
}
|
||||
let (origin, angle) = super::grid_frame(doc);
|
||||
|
||||
@@ -587,14 +587,42 @@ pub fn candidate(
|
||||
cfg: &TrackConfig,
|
||||
base: Option<Vec2>,
|
||||
) -> Option<Acquired> {
|
||||
let nothing_to_acquire = !cfg.otrack && !cfg.running(mode::EXT | mode::PAR | mode::APP);
|
||||
if nothing_to_acquire || !(cursor.is_finite() && aperture.is_finite() && aperture > 0.0) {
|
||||
if !can_acquire(cursor, aperture, cfg) {
|
||||
return None;
|
||||
}
|
||||
let ix = crate::spatial::index(d, space);
|
||||
let prims = super::gather(d, space, &ix, cursor, aperture, mode::NEA).map(|(_, p)| p).unwrap_or_default();
|
||||
candidate_from_prims(cursor, hit, aperture, cfg, base, &prims)
|
||||
}
|
||||
|
||||
pub(crate) fn candidate_with_prims(
|
||||
cursor: Vec2,
|
||||
hit: Option<&SnapHit>,
|
||||
aperture: f64,
|
||||
cfg: &TrackConfig,
|
||||
base: Option<Vec2>,
|
||||
prims: &[Prim],
|
||||
) -> Option<Acquired> {
|
||||
if !can_acquire(cursor, aperture, cfg) {
|
||||
return None;
|
||||
}
|
||||
candidate_from_prims(cursor, hit, aperture, cfg, base, prims)
|
||||
}
|
||||
|
||||
fn can_acquire(cursor: Vec2, aperture: f64, cfg: &TrackConfig) -> bool {
|
||||
(cfg.otrack || cfg.running(mode::EXT | mode::PAR | mode::APP)) && cursor.is_finite() && aperture.is_finite() && aperture > 0.0
|
||||
}
|
||||
|
||||
fn candidate_from_prims(
|
||||
cursor: Vec2,
|
||||
hit: Option<&SnapHit>,
|
||||
aperture: f64,
|
||||
cfg: &TrackConfig,
|
||||
base: Option<Vec2>,
|
||||
prims: &[Prim],
|
||||
) -> Option<Acquired> {
|
||||
let hit = hit.filter(|h| h.deferred.is_none() && h.mode != mode::NEA && h.point.is_finite());
|
||||
let ext_end = if cfg.running(mode::EXT) { extension_near(&prims, hit.map_or(cursor, |h| h.point), aperture) } else { None };
|
||||
let ext_end = if cfg.running(mode::EXT) { extension_near(prims, hit.map_or(cursor, |h| h.point), aperture) } else { None };
|
||||
if let Some((at, ext)) = ext_end
|
||||
&& hit.is_none_or(|h| h.point.dist(at) <= cadcraft_geom::EPS * (1.0 + at.len()))
|
||||
{
|
||||
@@ -761,8 +789,12 @@ impl crate::Session {
|
||||
let last = self.last_segment.filter(|(_, b)| q.base.is_some_and(|base| base.near(*b, 1e-9 * (1.0 + b.len())))).map(|(a, b)| a.angle_to(b));
|
||||
let cfg = TrackConfig::relative_to(&s, angbase, last);
|
||||
let osmode = s.osmode | if s.osnaphatch { mode::HATCH } else { 0 };
|
||||
let hit = super::osnap(&doc, &space, raw, q.aperture, osmode, q.base, q.deferred);
|
||||
let cand = candidate(&doc, &space, raw, hit.as_ref(), q.aperture, &cfg, q.base);
|
||||
let ix = crate::spatial::index(&doc, &space);
|
||||
let (hit, near_prims) = super::osnap_with_neighborhood(&doc, &space, &ix, raw, q.aperture, osmode, q.base, q.deferred);
|
||||
let cand = match (osmode & mode::HATCH, near_prims.as_deref()) {
|
||||
(0, Some(prims)) => candidate_with_prims(raw, hit.as_ref(), q.aperture, &cfg, q.base, prims),
|
||||
_ => candidate(&doc, &space, raw, hit.as_ref(), q.aperture, &cfg, q.base),
|
||||
};
|
||||
let wait = self.tracking.hover(cand, q.now, cfg.shift_acquire, q.shift);
|
||||
if let Some(h) = hit {
|
||||
self.tracking.last = None;
|
||||
|
||||
@@ -342,6 +342,50 @@ fn copy_rotate_scale_mirror() {
|
||||
assert_eq!(s.doc().unwrap().model.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_and_rotate_drag_as_a_transform_of_the_selection() {
|
||||
fn start(cmd: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
for t in [cmd, "all", "", "0,0"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
let s = start("copy");
|
||||
let (d, extra) = s.drag(Vec2::new(3.0, 4.0)).expect("COPY drags its selection");
|
||||
assert_eq!(d.handles.len(), 1);
|
||||
assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(4.0, 4.0), 1e-9));
|
||||
assert_eq!(extra.len(), 1, "the base-to-cursor line");
|
||||
|
||||
let (d, _) = start("rotate").drag(Vec2::new(0.0, 5.0)).expect("ROTATE drags its selection");
|
||||
assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(0.0, 1.0), 1e-9));
|
||||
|
||||
// MIRROR: geometry is mirrored by the matrix; text, which stays readable, is transformed
|
||||
// object by object and comes with the extra geometry.
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
s.execute("text", &json!({"at": [2, 2], "height": 1, "text": "A"})).unwrap();
|
||||
for t in ["mirror", "all", "", "0,0"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
let (d, extra) = s.drag(Vec2::new(0.0, 5.0)).expect("MIRROR drags its selection");
|
||||
assert_eq!(d.handles.len(), 1, "the line is mirrored by the matrix");
|
||||
assert!(d.matrix.apply(Vec2::new(1.0, 0.0)).near(Vec2::new(-1.0, 0.0), 1e-9));
|
||||
assert_eq!(extra.len(), 2, "the base-to-cursor line and the text");
|
||||
let EntityKind::Text(t) = &extra[1].kind else { panic!("expected the text") };
|
||||
assert!(t.insert.xy().near(Vec2::new(-2.0, 2.0), 1e-9) && t.rotation.abs() < 1e-9, "mirrored, still reading left to right");
|
||||
|
||||
// PASTECLIP drags the clipboard.
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
s.execute("copyclip", &json!({"handles": [l["handles"][0].clone()]})).unwrap();
|
||||
s.cmdline("pasteclip").unwrap();
|
||||
let (d, _) = s.drag(Vec2::new(5.0, 5.0)).expect("PASTECLIP drags the clipboard");
|
||||
assert!(d.clipboard && d.handles.is_empty());
|
||||
assert!(d.matrix.apply(s.clipboard_base).near(Vec2::new(5.0, 5.0), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_reference_typed_and_picked() {
|
||||
// Issue #17: SCALE ▸ Reference scales by new length / reference length.
|
||||
|
||||
@@ -65,10 +65,24 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String {
|
||||
let deg = cadcraft_geom::norm_angle(a).to_degrees();
|
||||
match aunits {
|
||||
1 => {
|
||||
let d = deg.floor();
|
||||
let m = ((deg - d) * 60.0).floor();
|
||||
let s = (deg - d - m / 60.0) * 3600.0;
|
||||
format!("{}d{}'{:.*}\"", d as i64, m as i64, prec.saturating_sub(4), s)
|
||||
// Round at the displayed precision before splitting, carrying through a full turn.
|
||||
let scale = 10i64.pow(prec.saturating_sub(4) as u32);
|
||||
let seconds_per_unit = match prec {
|
||||
0 => 3600,
|
||||
1 | 2 => 60,
|
||||
_ => 1,
|
||||
};
|
||||
let units = (deg * (3600 / seconds_per_unit * scale) as f64).round() as i64;
|
||||
let total = units * seconds_per_unit % (360 * 3600 * scale);
|
||||
let (d, m) = (total / (3600 * scale), (total / (60 * scale)) % 60);
|
||||
match prec {
|
||||
0 => format!("{d}d"),
|
||||
1 | 2 => format!("{d}d{m}'"),
|
||||
_ => {
|
||||
let s = (total % (60 * scale)) as f64 / scale as f64;
|
||||
format!("{d}d{m}'{:.*}\"", prec.saturating_sub(4), s)
|
||||
}
|
||||
}
|
||||
}
|
||||
2 => format!("{:.*}g", prec, deg / 0.9),
|
||||
3 => format!("{:.*}r", prec, cadcraft_geom::norm_angle(a)),
|
||||
@@ -280,6 +294,36 @@ mod tests {
|
||||
assert_eq!(format_distance(30.0, 3, 2), "2'-6.00\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dms_display_precision() {
|
||||
let angle = (12.0 + 34.0 / 60.0 + 56.78912 / 3600.0_f64).to_radians();
|
||||
let expected = ["13d", "12d35'", "12d35'", "12d34'57\"", "12d34'57\"", "12d34'56.8\"", "12d34'56.79\"", "12d34'56.789\"", "12d34'56.7891\""];
|
||||
for (prec, expected) in expected.into_iter().enumerate() {
|
||||
assert_eq!(format_angle(angle, 1, prec as i64), expected, "AUPREC {prec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dms_rounding_carries() {
|
||||
let dms = |d: f64, m: f64, s: f64| (d + m / 60.0 + s / 3600.0).to_radians();
|
||||
let negative = (-0.0001_f64).to_radians();
|
||||
let cases = [
|
||||
(dms(29.0, 59.0, 59.6), 0, "30d"),
|
||||
(dms(29.0, 59.0, 59.6), 2, "30d0'"),
|
||||
(dms(29.0, 59.0, 59.6), 4, "30d0'0\""),
|
||||
(dms(29.0, 0.0, 59.99996), 2, "29d1'"),
|
||||
(dms(29.0, 0.0, 59.99996), 4, "29d1'0\""),
|
||||
(dms(29.0, 59.0, 59.99996), 5, "30d0'0.0\""),
|
||||
(dms(29.0, 59.0, 59.99996), 8, "30d0'0.0000\""),
|
||||
(negative, 0, "0d"),
|
||||
(negative, 4, "0d0'0\""),
|
||||
(negative, 5, "359d59'59.6\""),
|
||||
];
|
||||
for (angle, prec, expected) in cases {
|
||||
assert_eq!(format_angle(angle, 1, prec), expected, "AUPREC {prec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_distances() {
|
||||
assert_eq!(parse_distance("12.5"), Some(12.5));
|
||||
|
||||
@@ -198,6 +198,13 @@ fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
|
||||
assert!(dir.join(format!("{}.lock", a.instance)).exists());
|
||||
assert!(autosave::recoverable(&dir, "someone-else").is_empty());
|
||||
|
||||
// After a GPU error a change made a moment ago is autosaved right away, not SAVETIME later.
|
||||
assert!(a.tick(&s).is_none(), "no change waiting");
|
||||
draw(&mut s);
|
||||
assert!(a.tick(&s).is_some(), "SAVETIME (3 minutes) is not up yet");
|
||||
assert_eq!(a.save_now(&s), 1);
|
||||
assert_eq!(a.save_now(&s), 0, "being written or written: nothing more to save");
|
||||
|
||||
a.shutdown();
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
assert!(!dir.join(format!("{}.lock", a.instance)).exists());
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Drafting follow-ups (#626): isometric snap gives way to PolarSnap, and MVIEW Restore puts a
|
||||
//! model space viewport configuration on a layout.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Space, Viewport, VportTile};
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::snap::tracking::CursorQuery;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
fn setvar(s: &mut Session, name: &str, value: Value) {
|
||||
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
|
||||
}
|
||||
|
||||
/// Move the cursor to `raw` as the canvas does.
|
||||
fn hover(s: &mut Session, raw: Vec2, base: Option<Vec2>) -> Vec2 {
|
||||
let q = CursorQuery { base, deferred: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false };
|
||||
let r = s.snap_cursor(raw, &q);
|
||||
s.cursor = r.point;
|
||||
r.point
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isometric_snap_gives_way_to_polarsnap() {
|
||||
let mut s = Session::new();
|
||||
setvar(&mut s, "OSMODE", json!(0));
|
||||
s.execute("isodraft", &json!({ "plane": "left" })).unwrap();
|
||||
setvar(&mut s, "SNAPMODE", json!(1));
|
||||
setvar(&mut s, "SNAPUNIT", json!([1, 1]));
|
||||
s.cmdline("line 0,0").unwrap();
|
||||
let base = Some(v(0.0, 0.0));
|
||||
// Grid snap (SNAPTYPE 0): the isometric lattice.
|
||||
let off = v(0.3, 2.2);
|
||||
let lattice = hover(&mut s, off, base);
|
||||
assert!(!lattice.near(off, 1e-6), "snapped to the lattice: {lattice:?}");
|
||||
// PolarSnap: steps of POLARDIST along the polar paths, and the cursor is free off them.
|
||||
setvar(&mut s, "SNAPTYPE", json!(1));
|
||||
setvar(&mut s, "POLARDIST", json!(2.5));
|
||||
let p = hover(&mut s, v(0.02, 6.1), base);
|
||||
assert!(p.near(v(0.0, 5.0), 1e-9), "PolarSnap along 90°: {p:?}");
|
||||
let q = hover(&mut s, off, base);
|
||||
assert!(q.near(off, 1e-9), "off the paths the isometric lattice no longer snaps: {q:?}");
|
||||
// Back to grid snap: the lattice again.
|
||||
setvar(&mut s, "SNAPTYPE", json!(0));
|
||||
assert!(hover(&mut s, off, base).near(lattice, 1e-9));
|
||||
}
|
||||
|
||||
/// The layout viewports with the given handles, in order.
|
||||
fn viewports(s: &Session, layout: &str, handles: &Value) -> Vec<Viewport> {
|
||||
let d = s.doc().unwrap();
|
||||
let st = d.space(&Space::Paper(layout.into())).unwrap();
|
||||
handles
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let h = cadcraft_doc::Handle::parse_hex(h.as_str().unwrap()).unwrap();
|
||||
match &st.get(h).unwrap().kind {
|
||||
EntityKind::Viewport(v) => v.clone(),
|
||||
other => panic!("not a viewport: {other:?}"),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A model space with two side-by-side tiles saved as "Two" (the left tile zoomed in).
|
||||
fn two_tiles() -> (Session, Vec<VportTile>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
|
||||
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
|
||||
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
|
||||
s.execute("zoom", &json!({ "mode": "center", "center": [10, 10], "height": 8 })).unwrap();
|
||||
s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap();
|
||||
let tiles = s.doc().unwrap().vports.iter().find(|c| c.name == "Two").unwrap().tiles.clone();
|
||||
assert_eq!(tiles.len(), 2);
|
||||
(s, tiles)
|
||||
}
|
||||
|
||||
fn check_restored(vps: &[Viewport], tiles: &[VportTile], window: Bounds2) {
|
||||
assert_eq!(vps.len(), tiles.len());
|
||||
for (vp, t) in vps.iter().zip(tiles) {
|
||||
let at = |f: Vec2| v(window.min.x + f.x * window.width(), window.min.y + f.y * window.height());
|
||||
let (lo, hi) = (at(t.min), at(t.max));
|
||||
assert!(vp.center.xy().near((lo + hi) * 0.5, 1e-9), "{vp:?} {t:?}");
|
||||
assert!((vp.width - (hi.x - lo.x)).abs() < 1e-9 && (vp.height - (hi.y - lo.y)).abs() < 1e-9);
|
||||
assert!(vp.view_center.near(t.center, 1e-9), "each viewport shows its tile's view");
|
||||
assert!(vp.view_height >= t.height - 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mview_restore_puts_a_saved_configuration_on_a_layout() {
|
||||
let (mut s, tiles) = two_tiles();
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let window = Bounds2::new(v(10.0, 10.0), v(210.0, 110.0));
|
||||
let r = s.execute("mview", &json!({ "option": "restore", "name": "two", "p1": [10, 10], "p2": [210, 110] })).unwrap();
|
||||
assert_eq!(r["configuration"], "two");
|
||||
check_restored(&viewports(&s, "Layout1", &r["viewports"]), &tiles, window);
|
||||
|
||||
// Typed: name (? lists the saved ones), then two corners.
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("r").unwrap();
|
||||
assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text());
|
||||
s.cmdline("?").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Two")), "{:?}", s.log);
|
||||
assert!(s.cmdline("nope").is_err() || s.running.is_some(), "an unknown name asks again");
|
||||
assert!(s.prompt_text().contains("configuration name"), "{}", s.prompt_text());
|
||||
s.cmdline("Two").unwrap();
|
||||
s.cmdline("10,120").unwrap();
|
||||
s.cmdline("110,170").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.log.iter().any(|l| l.contains("2 viewport(s) restored")), "{:?}", s.log);
|
||||
|
||||
// Enter takes *Active (the tiles on show) and Fit.
|
||||
let before = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().count();
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("r").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let after = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().count();
|
||||
assert_eq!(after, before + 2, "the model space shows two tiles");
|
||||
|
||||
// Unknown names, model space and hostile windows are refused.
|
||||
assert!(s.execute("mview", &json!({ "option": "restore", "name": "nope" })).is_err());
|
||||
assert!(s.execute("mview", &json!({ "option": "restore", "p1": [0, 0], "p2": [0, 5] })).is_err());
|
||||
assert!(s.execute("mview", &json!({ "option": "restore", "p1": [0, 0], "p2": [1e300, 5] })).is_err());
|
||||
assert!(s.execute("mview", &json!({ "option": "restore", "layout": "Model" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mview_restore_ignores_unusable_tiles_from_files() {
|
||||
let (mut s, _) = two_tiles();
|
||||
let bad = [
|
||||
VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
|
||||
VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
|
||||
VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN },
|
||||
];
|
||||
if let Some(c) = s.doc_mut().unwrap().vports.iter_mut().find(|c| c.name == "Two") {
|
||||
c.tiles = bad.to_vec();
|
||||
}
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let r = s.execute("mview", &json!({ "option": "restore", "name": "Two", "p1": [0, 0], "p2": [100, 50] })).unwrap();
|
||||
let vps = viewports(&s, "Layout1", &r["viewports"]);
|
||||
assert_eq!(vps.len(), 1, "only the third tile has an area (clamped to the window)");
|
||||
assert!((vps[0].width - 100.0).abs() < 1e-9 && vps[0].view_height.is_finite() && vps[0].view_height > 0.0);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Opening a file that isn't a drawing (SVG, read into entities): the drawing gets no file name,
|
||||
//! so QSAVE asks for one instead of writing CADCraft's SVG export over the original.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn an_opened_svg_is_not_saved_over() {
|
||||
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"exported".to_vec()), plot: None });
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-open-svg-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let svg = dir.join("Logo.svg");
|
||||
std::fs::write(&svg, b"<svg/>").unwrap();
|
||||
let mut s = Session::empty();
|
||||
s.execute("open", &json!({"path": svg.to_string_lossy()})).unwrap();
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!(st.title, "Logo.svg");
|
||||
assert_eq!(st.path, None);
|
||||
assert!(s.execute("qsave", &json!({})).is_err(), "QSAVE asks for a file name");
|
||||
assert_eq!(std::fs::read(&svg).unwrap(), b"<svg/>", "the original is untouched");
|
||||
|
||||
// A drawing file keeps its path.
|
||||
let dxf = dir.join("Plan.dxf");
|
||||
std::fs::write(&dxf, b"0\nEOF\n").unwrap();
|
||||
s.execute("open", &json!({"path": dxf.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.state().unwrap().path.as_deref(), Some(dxf.to_string_lossy().as_ref()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Model space is built on all cores for the screen: the chunks are joined in drawing order, so
|
||||
//! the display list is exactly what one thread builds (vertex and triangle indices included).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::render::{DisplayList, Options, build, build_entities};
|
||||
use serde_json::json;
|
||||
|
||||
fn same(a: &DisplayList, b: &DisplayList) {
|
||||
assert_eq!(a.prims.len(), b.prims.len());
|
||||
assert!(a.prims == b.prims, "primitives differ");
|
||||
assert!(a.verts == b.verts, "vertices differ");
|
||||
assert!(a.tris == b.tris, "triangles differ");
|
||||
assert_eq!((a.bounds.min, a.bounds.max), (b.bounds.min, b.bounds.max));
|
||||
assert_eq!(a.expanded, b.expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_model_space_matches_one_thread() {
|
||||
let mut s = Session::new();
|
||||
// A block with a hatch in it, inserted many times: block expansion in every chunk.
|
||||
let r = s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 3]})).unwrap();
|
||||
let h = r["handle"].clone();
|
||||
s.execute("hatch", &json!({ "handles": [h.clone()] })).unwrap();
|
||||
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": [h], "keep": "delete"})).unwrap();
|
||||
// Well over two chunks of mixed entities: lines, circles (curves), text, inserts, points,
|
||||
// solid fills (triangles), so both vertex and triangle offsets are exercised.
|
||||
for i in 0..700 {
|
||||
let x = f64::from(i) * 10.0;
|
||||
match i % 7 {
|
||||
0 => s.execute("line", &json!({"points": [[x, 0], [x + 5.0, 5]]})),
|
||||
1 => s.execute("circle", &json!({"center": [x, 20], "radius": 3})),
|
||||
2 => s.execute("text", &json!({"at": [x, 40], "height": 2, "text": format!("T{i}")})),
|
||||
3 => s.execute("insert", &json!({"name": "B", "at": [x, 60]})),
|
||||
4 => s.execute("point", &json!({"at": [x, 80]})),
|
||||
5 => {
|
||||
let r = s.execute("rectang", &json!({"p1": [x, 100], "p2": [x + 4.0, 104]})).unwrap();
|
||||
s.execute("hatch", &json!({ "handles": [r["handle"].clone()], "pattern": "SOLID" }))
|
||||
}
|
||||
_ => s.execute("line", &json!({"points": [[x, 120], [x, 125]]})),
|
||||
}
|
||||
.unwrap();
|
||||
}
|
||||
let st = s.state().unwrap();
|
||||
let d = &st.doc;
|
||||
let n = d.space(&Space::Model).map(|m| m.len()).unwrap_or(0);
|
||||
assert!(n > 600, "{n} entities: enough for several parallel chunks");
|
||||
let one_thread = build_entities(d, d.space(&Space::Model).unwrap().iter().map(|e| e.as_ref()), &Options::default());
|
||||
let parallel = build(d, &Space::Model, &Options::default());
|
||||
assert!(!parallel.tris.is_empty() && !parallel.verts.is_empty());
|
||||
same(¶llel, &one_thread);
|
||||
}
|
||||
@@ -172,3 +172,21 @@ fn hostile_names_and_values_are_refused() {
|
||||
assert!(s.execute("plottermanager", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plotwindow_keeps_the_picked_window_for_the_plot_dialog() {
|
||||
let mut s = Session::new();
|
||||
// Typed: two corners, with the window's rectangle following the cursor after the first.
|
||||
s.cmdline("plotwindow").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify first corner");
|
||||
s.cmdline("10,20").unwrap();
|
||||
assert_eq!(s.preview(cadcraft_engine::geom::Vec2::new(40.0, 5.0)).len(), 4, "the rubber-band rectangle");
|
||||
s.cmdline("40,5").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let w = s.plot_window.expect("the picked window");
|
||||
assert_eq!((w.min.x, w.min.y, w.max.x, w.max.y), (10.0, 5.0, 40.0, 20.0));
|
||||
// JSON, and a window without area is refused.
|
||||
let r = s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 4]})).unwrap();
|
||||
assert_eq!(r["window"], json!([[0.0, 0.0], [3.0, 4.0]]));
|
||||
assert!(s.execute("plotwindow", &json!({"p1": [0, 0], "p2": [3, 0]})).is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! PURGE removes multiline styles no multiline uses (#626): not STANDARD, not the current
|
||||
//! style, not one used by a multiline in model space or in a block that is still inserted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, MLine, MLineJust, MLineStyle, Space};
|
||||
use cadcraft_engine::geom::{Vec2, Vec3};
|
||||
use serde_json::json;
|
||||
|
||||
fn mline(style: &MLineStyle) -> EntityKind {
|
||||
EntityKind::MLine(MLine::new(style, &[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)], false, MLineJust::Zero, 1.0))
|
||||
}
|
||||
|
||||
fn block(name: &str, style: &MLineStyle, handle: u64) -> Arc<Block> {
|
||||
let mut b = Block::new(name);
|
||||
b.entities.push(Entity::new(Handle(handle), mline(style)));
|
||||
Arc::new(b)
|
||||
}
|
||||
|
||||
/// Styles: Model (used in model space), InBlock (used in the inserted block WALLS), Gone (used
|
||||
/// only in the unreferenced block OLD), Spare (unused), Current (unused but current).
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let style = |n: &str| MLineStyle { name: n.into(), ..MLineStyle::default() };
|
||||
for n in ["Model", "InBlock", "Gone", "Spare", "Current"] {
|
||||
d.mline_styles.push(style(n));
|
||||
}
|
||||
d.header.set_str("CMLSTYLE", "Current");
|
||||
d.add(&Space::Model, Common::default(), mline(&style("Model"))).unwrap();
|
||||
d.blocks.insert("WALLS".into(), block("WALLS", &style("InBlock"), 0x9000));
|
||||
d.blocks.insert("OLD".into(), block("OLD", &style("Gone"), 0x9001));
|
||||
let ins = Insert {
|
||||
block: "WALLS".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn styles(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().mline_styles.iter().map(|m| m.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_mlinestyles_keeps_used_standard_and_current() {
|
||||
let mut s = session();
|
||||
let r = s.execute("purge", &json!({ "type": "mlinestyles" })).unwrap();
|
||||
assert_eq!(styles(&s), ["STANDARD", "Model", "InBlock", "Gone", "Current"], "{r}");
|
||||
assert_eq!(r["purged"]["mlinestyles"], json!(["Spare"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_all_frees_styles_of_purged_blocks() {
|
||||
let mut s = session();
|
||||
s.script("-PURGE A * N\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(s.doc().unwrap().block("OLD").is_none());
|
||||
assert_eq!(styles(&s), ["STANDARD", "Model", "InBlock", "Current"], "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_mlinestyles_at_the_command_line() {
|
||||
let mut s = session();
|
||||
s.script("-PURGE M Sp* N\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(styles(&s), ["STANDARD", "Model", "InBlock", "Gone", "Current"]);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Object snap tracking (OTRACK, F11) and the Extension, Parallel and Apparent Intersection
|
||||
//! snaps, driven through `Session::snap_cursor` as the UI does, without the UI.
|
||||
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_doc::{Common, EntityKind, Insert, Line, Space};
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::geom::{Vec2, Vec3};
|
||||
use cadcraft_engine::snap::tracking::{Acquired, CursorQuery, CursorSnap, DWELL, MAX_ACQUIRED, PathKind, polar_angles};
|
||||
use cadcraft_engine::snap::{self, mode};
|
||||
use serde_json::json;
|
||||
@@ -25,6 +25,28 @@ fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) {
|
||||
s.execute("line", &json!({ "points": [[a.0, a.1], [b.0, b.1]] })).unwrap();
|
||||
}
|
||||
|
||||
fn insert_block(s: &mut Session, name: &str, at: Vec2) -> String {
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::Insert(Insert {
|
||||
block: name.into(),
|
||||
insert: Vec3::new(at.x, at.y, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
)
|
||||
.unwrap()
|
||||
.hex()
|
||||
}
|
||||
|
||||
fn setvar(s: &mut Session, name: &str, value: serde_json::Value) {
|
||||
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
|
||||
}
|
||||
@@ -37,6 +59,45 @@ fn at(s: &mut Session, cursor: Vec2, base: Option<Vec2>) -> CursorSnap {
|
||||
s.snap_cursor(cursor, &query(base, 100.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_cursor_tracking_acquires_nested_block_geometry() {
|
||||
let mut s = session(mode::END | mode::CEN | mode::INT | mode::EXT);
|
||||
assert!(s.settings.otrack);
|
||||
let line = s
|
||||
.doc_mut()
|
||||
.unwrap()
|
||||
.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) }))
|
||||
.unwrap()
|
||||
.hex();
|
||||
s.execute("block", &json!({ "name": "INNER", "base": [0, 0], "handles": [line], "keep": "delete" })).unwrap();
|
||||
let inner = insert_block(&mut s, "INNER", Vec2::ZERO);
|
||||
s.execute("block", &json!({ "name": "OUTER", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap();
|
||||
insert_block(&mut s, "OUTER", Vec2::ZERO);
|
||||
|
||||
rest(&mut s, v(10.02, 0.02), None);
|
||||
|
||||
assert_eq!(s.tracking.acquired.len(), 1);
|
||||
assert!(matches!(
|
||||
s.tracking.acquired.first(),
|
||||
Some(Acquired::Point { at, name, ext: Some(_) }) if at.near(v(10.0, 0.0), 1e-9) && *name == "Endpoint"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osnaphatch_geometry_is_not_used_for_tracking_extension() {
|
||||
let mut s = session(mode::END | mode::EXT);
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 5] })).unwrap();
|
||||
let boundary = s.doc().unwrap().model.iter().last().unwrap().handle.hex();
|
||||
s.execute("hatch", &json!({ "handles": [boundary] })).unwrap();
|
||||
s.execute("erase", &json!({ "handles": [boundary] })).unwrap();
|
||||
setvar(&mut s, "OSNAPHATCH", json!(1));
|
||||
|
||||
rest(&mut s, v(10.02, 4.98), None);
|
||||
|
||||
assert_eq!(s.tracking.acquired.len(), 1);
|
||||
assert!(matches!(s.tracking.acquired.first(), Some(Acquired::Point { ext: None, .. })));
|
||||
}
|
||||
|
||||
/// Rest the cursor at `p` long enough to acquire (or release) what is there, then move off.
|
||||
fn rest(s: &mut Session, p: Vec2, base: Option<Vec2>) {
|
||||
s.snap_cursor(p, &query(base, 0.0));
|
||||
|
||||
@@ -162,8 +162,8 @@ fn on_off_lock_restore_and_arrangements() {
|
||||
assert!(viewport(&s, &vh).locked, "{:?}", s.log);
|
||||
s.execute("mview", &json!({"option": "lock", "locked": false, "handles": [vh]})).unwrap();
|
||||
assert!(!viewport(&s, &vh).locked);
|
||||
// Restore needs named viewport configurations.
|
||||
assert!(s.execute("mview", &json!({"option": "restore"})).is_err());
|
||||
// Restore needs a viewport configuration that exists (followups_626.rs covers the rest).
|
||||
assert!(s.execute("mview", &json!({"option": "restore", "name": "none saved"})).is_err());
|
||||
// 3 viewports arranged Above: the large one on top.
|
||||
s.cmdline("mview 3 a 200,0 300,100").unwrap();
|
||||
let vps = layout_viewports(&s);
|
||||
|
||||
@@ -213,6 +213,17 @@ pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 {
|
||||
(n - stroke::GAP / stroke::CAP * height * width_factor).max(0.0)
|
||||
}
|
||||
|
||||
/// Width of the gap MTEXT leaves between two words. Words are measured without their trailing
|
||||
/// letter gap, so the stroke font's gap is added on both sides of the space, as TEXT lays it out.
|
||||
pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 {
|
||||
if let TextFont::Outline(face) = font
|
||||
&& let Some(w) = ttf::width(face, " ", height, width_factor)
|
||||
{
|
||||
return w;
|
||||
}
|
||||
stroke_advance(' ', height, width_factor) + stroke::GAP / stroke::CAP * height * width_factor
|
||||
}
|
||||
|
||||
/// The font a piece of text is set in: the built-in stroke font or an installed TrueType /
|
||||
/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -571,7 +571,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
}
|
||||
Atom::Word(segs) => {
|
||||
let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum();
|
||||
let sw = pending_space.map(|f| text_width(&fmt(f).font, " ", fmt(f).height, fmt(f).wf)).unwrap_or(0.0);
|
||||
let sw = pending_space.map(|f| crate::space_width(&fmt(f).font, fmt(f).height, fmt(f).wf)).unwrap_or(0.0);
|
||||
if width > 0.0 && !line.items.is_empty() && line.width + sw + ww > width + 1e-9 {
|
||||
finish(&mut lines, &mut line, fmt(last_fmt), para);
|
||||
} else if !line.items.is_empty() || pending_space.is_some() {
|
||||
@@ -693,6 +693,12 @@ mod tests {
|
||||
assert!((c.x - 5.0).abs() < 1e-9 && (c.y - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_word_space_matches_text() {
|
||||
let l = layout_mtext("A B", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0);
|
||||
assert!((l.bounds.width() - crate::line_width("A B", 1.0, 1.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
fn lay(s: &str, width: f64) -> MTextLayout {
|
||||
layout_mtext(s, Vec2::ZERO, 1.0, width, 1, 0.0, 1.0)
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
|
||||
let paras = Paragraphs::scan(contents);
|
||||
let (atoms, fmts) = parse(contents, base.clone());
|
||||
let fmt = |i: usize| fmts.get(i).unwrap_or(base);
|
||||
let space_w = |f: usize| crate::text_width(&fmt(f).font, " ", fmt(f).height, fmt(f).wf);
|
||||
let space_w = |f: usize| crate::space_width(&fmt(f).font, fmt(f).height, fmt(f).wf);
|
||||
|
||||
let mut lines: Vec<PLine> = Vec::new();
|
||||
let mut k = 0usize;
|
||||
|
||||
+16
-9
@@ -16,7 +16,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock};
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
@@ -580,7 +580,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
|
||||
/// font's cap height.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GlyphOutline {
|
||||
pub(crate) contours: Vec<Vec<Vec2>>,
|
||||
pub(crate) contours: Arc<[Vec<Vec2>]>,
|
||||
pub(crate) advance: f64,
|
||||
pub(crate) cap: f64,
|
||||
/// The font maps the character (false: the missing-glyph shape).
|
||||
@@ -598,12 +598,13 @@ enum GlyphKey {
|
||||
}
|
||||
|
||||
/// Cached glyph outlines keyed by (font bytes address, length, face, glyph). Fonts live for the
|
||||
/// whole process in the font table, so the address identifies them.
|
||||
/// whole process in the font table, so the address identifies them. A read-write lock: display
|
||||
/// lists are built on all cores and nearly every lookup is a hit, which a mutex serialized.
|
||||
type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc<GlyphOutline>>;
|
||||
|
||||
fn glyph_cache() -> &'static Mutex<GlyphCache> {
|
||||
static C: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
fn glyph_cache() -> &'static RwLock<GlyphCache> {
|
||||
static C: OnceLock<RwLock<GlyphCache>> = OnceLock::new();
|
||||
C.get_or_init(|| RwLock::new(HashMap::new()))
|
||||
}
|
||||
|
||||
const MAX_CACHED_GLYPHS: usize = 40_000;
|
||||
@@ -691,9 +692,15 @@ pub(crate) fn glyph_by_id(face: &Face, gid: u32) -> Arc<GlyphOutline> {
|
||||
outline(face, GlyphKey::Id(gid))
|
||||
}
|
||||
|
||||
/// Closed, flattened contours of a glyph at 1000 font units per em. Shared with the outline
|
||||
/// cache so renderers can tessellate once before applying each occurrence's affine placement.
|
||||
pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec<Vec2>]> {
|
||||
glyph_by_id(face, u32::from(gid)).contours.clone()
|
||||
}
|
||||
|
||||
fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
|
||||
let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k);
|
||||
if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||
return g.clone();
|
||||
}
|
||||
let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) };
|
||||
@@ -709,8 +716,8 @@ fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
|
||||
}
|
||||
let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5));
|
||||
let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32());
|
||||
let g = Arc::new(GlyphOutline { contours: pen.contours, advance, cap, mapped, gid, face: Some(face.clone()) });
|
||||
let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let g = Arc::new(GlyphOutline { contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) });
|
||||
let mut cache = glyph_cache().write().unwrap_or_else(PoisonError::into_inner);
|
||||
if cache.len() > MAX_CACHED_GLYPHS {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ thiserror = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
//!
|
||||
//! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the
|
||||
//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS`, `CADCRAFT_LAYERSTATES`,
|
||||
//! `CADCRAFT_PLOTSTYLES` and `CADCRAFT_HANDSEED` (XRECORDs). Other readers keep or ignore them.
|
||||
//! `CADCRAFT_PLOTSTYLES`, `CADCRAFT_HANDSEED` and `CADCRAFT_MLEADERSTYLE` (XRECORDs). Other
|
||||
//! readers keep or ignore them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{ArrayParams, AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_dxf::{Tag, Version};
|
||||
use cadcraft_render::Arrowhead;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -32,6 +33,8 @@ pub(crate) const ANNOTATION_KEY: &str = "CADCRAFT_ANNOTATION";
|
||||
pub(crate) const PLOT_STYLE_NAMES_KEY: &str = "ACAD_PLOTSTYLENAME";
|
||||
/// Named-object-dictionary key of the XRECORD holding the drawing's next entity handle (hex).
|
||||
pub(crate) const HANDSEED_KEY: &str = "CADCRAFT_HANDSEED";
|
||||
/// Named-object-dictionary key of the XRECORD holding the current multileader style.
|
||||
pub(crate) const MLEADER_STYLE_KEY: &str = "CADCRAFT_MLEADERSTYLE";
|
||||
/// Most plot style tables and plot style names read from a file.
|
||||
pub(crate) const MAX_PLOT_STYLE_TABLES: usize = 1000;
|
||||
pub(crate) const MAX_PLOT_STYLE_NAMES: usize = 100_000;
|
||||
@@ -138,11 +141,14 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[
|
||||
/// DIMSAH: separate arrow blocks in use.
|
||||
pub(crate) const DIMSAH: i32 = 173;
|
||||
|
||||
/// Variables newer than the DXF version written (R2000): DIMFXL, DIMJOGANG, DIMARCSYM,
|
||||
/// DIMFXLON, DIMLTYPE, DIMLTEX1, DIMLTEX2. The writer puts them in a style's `CADCRAFT`
|
||||
/// "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of DIMSTYLE groups that
|
||||
/// an R2000 reader doesn't expect; the reader takes both.
|
||||
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1,
|
||||
/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them
|
||||
/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of
|
||||
/// groups an R2000 reader doesn't expect. The reader takes both.
|
||||
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347];
|
||||
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose
|
||||
/// files are the 2004 format (AC1018).
|
||||
pub(crate) const NEWER_SINCE: Version = Version::R2004;
|
||||
/// Xdata list name of [`NEWER_CODES`] values.
|
||||
pub(crate) const DIMVARS_LIST: &str = "DIMVARS";
|
||||
|
||||
|
||||
@@ -23,8 +23,17 @@ use cadcraft_dxf::{Section, Tag};
|
||||
/// starts with `CADCRAFT`): they are read into the model, not kept raw. A change that starts
|
||||
/// modelling another entry adds it here. (An entry the writer writes is also never written
|
||||
/// twice: the kept one is left out.)
|
||||
const MODELLED_KEYS: &[&str] =
|
||||
&["ACAD_GROUP", "ACAD_LAYOUT", "ACAD_MLEADERSTYLE", "ACAD_PLOTSTYLENAME", "ACAD_TABLESTYLE", "ACAD_IMAGE_DICT", "ACAD_IMAGE_VARS"];
|
||||
const MODELLED_KEYS: &[&str] = &[
|
||||
"ACAD_GROUP",
|
||||
"ACAD_LAYOUT",
|
||||
"ACAD_MLEADERSTYLE",
|
||||
"ACAD_MLINESTYLE",
|
||||
"ACAD_PLOTSTYLENAME",
|
||||
"ACAD_SCALELIST",
|
||||
"ACAD_TABLESTYLE",
|
||||
"ACAD_IMAGE_DICT",
|
||||
"ACAD_IMAGE_VARS",
|
||||
];
|
||||
|
||||
/// Extension dictionary entries not kept raw: dimension associativity (CADCraft reads and
|
||||
/// rewrites it), draw order and annotation scale contexts (written from their own models by the
|
||||
@@ -589,6 +598,19 @@ impl Map<'_> {
|
||||
while let Some(r) = o.tags.get(i) {
|
||||
i += 1;
|
||||
if r.code == 102 && r.value.trim().starts_with('{') {
|
||||
// A `{NAME` group is a handle list (`{ACAD_REACTORS 330 … }`) only when every
|
||||
// group inside it is a reference. Application data groups also use 102 brackets
|
||||
// with mixed values and their own closing form — the AcDs thumbnail schema's
|
||||
// `{ATTRRECORD 341 h 2 name 280 1 291 1 ATTRRECORD}` — and collapsing those into
|
||||
// a handle list rewrote the handle under the LAST code read (291, a bool) and
|
||||
// dropped the rest, which strict DXF readers reject. Those are written as read,
|
||||
// references remapped, by the plain path below.
|
||||
let end = (i..o.tags.len()).find(|&k| o.tags[k].code == 102).unwrap_or(o.tags.len());
|
||||
let inner = &o.tags[i..end];
|
||||
if !inner.is_empty() && !inner.iter().all(|n| is_pointer(n.code)) {
|
||||
out.push(tag(r));
|
||||
continue;
|
||||
}
|
||||
let name = r.value.trim().trim_start_matches('{').to_string();
|
||||
let mut hs = Vec::new();
|
||||
let mut code = 330;
|
||||
|
||||
@@ -742,9 +742,10 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
|
||||
}
|
||||
let solid = solid || pattern.eq_ignore_ascii_case("SOLID");
|
||||
let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians());
|
||||
// Definition lines matter only for a pattern the library doesn't define.
|
||||
let pattern_lines =
|
||||
if solid || cadcraft_doc::library::pattern(&pattern).is_some() { Vec::new() } else { pattern_definition(tags, scale, angle, origin) };
|
||||
// Keep the file's definition lines even for a name the library knows: the library's versions
|
||||
// of some patterns (AR-CONC) only approximate AutoCAD's, and drawing them at the file's scale
|
||||
// could be hundreds of times denser than the hatch the author saw.
|
||||
let pattern_lines = if solid { Vec::new() } else { pattern_definition(tags, scale, angle, origin) };
|
||||
Some(EntityKind::Hatch(Hatch {
|
||||
pattern: pattern.to_ascii_uppercase(),
|
||||
solid,
|
||||
@@ -1843,6 +1844,9 @@ impl Objects {
|
||||
}
|
||||
|
||||
fn apply(self, d: &mut Drawing, rx: &Rx) {
|
||||
if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) {
|
||||
d.header.set_str("CMLEADERSTYLE", &style);
|
||||
}
|
||||
// Table styles (named by their ACAD_TABLESTYLE dictionary entries).
|
||||
for (h, tags) in &self.table_styles {
|
||||
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
|
||||
|
||||
+105
-12
@@ -1,6 +1,7 @@
|
||||
//! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000). Later
|
||||
//! versions keep that structure (the groups they add are optional, DXF Reference) and differ in
|
||||
//! `$ACADVER` and, from 2007, in UTF-8 text; the caller picks the text encoding.
|
||||
//! `$ACADVER`, from 2004 in the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller
|
||||
//! picks the text encoding) and MULTILEADER entities (older files get LEADER + MTEXT).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -121,9 +122,17 @@ struct Ctx {
|
||||
block_records: HashMap<String, String>,
|
||||
/// Raster images' IMAGEDEF, IMAGEDEF_REACTOR and dictionary handles.
|
||||
images: crate::dxf_image::Plan,
|
||||
/// The version the file ends up in (the DWG version for a DWG save): it decides which
|
||||
/// entity types exist (MULTILEADER from 2007).
|
||||
target: Version,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
/// Whether multileaders are written as MULTILEADER (AutoCAD 2007 / AC1021 and later);
|
||||
/// older files get LEADER + MTEXT/INSERT.
|
||||
fn native_mleader(&self) -> bool {
|
||||
self.target >= Version::R2007
|
||||
}
|
||||
fn style(&self, name: &str) -> Option<String> {
|
||||
self.styles.get(&name.to_ascii_uppercase()).cloned()
|
||||
}
|
||||
@@ -833,6 +842,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
|
||||
w.s(1000, c);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) if !cx.native_mleader() => mleader_fallback(w, d, e, m, owner, paper, cx),
|
||||
EntityKind::MLeader(m) => {
|
||||
w.s(0, "MULTILEADER");
|
||||
common(w, e, owner, paper, "AcDbMLeader");
|
||||
@@ -914,9 +924,12 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
|
||||
w.f(52, h.angle.to_degrees());
|
||||
w.f(41, h.scale);
|
||||
w.i(77, 0);
|
||||
let pat = cadcraft_doc::library::pattern(&h.pattern);
|
||||
// A pattern the library doesn't define keeps the lines read from its file.
|
||||
let lines = pat.map(|p| p.lines).unwrap_or_else(|| h.pattern_lines.clone());
|
||||
// The lines read from the file, else the library's pattern of that name.
|
||||
let lines = if h.pattern_lines.is_empty() {
|
||||
cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default()
|
||||
} else {
|
||||
h.pattern_lines.clone()
|
||||
};
|
||||
w.i(78, lines.len() as i64);
|
||||
for pl in lines {
|
||||
let ang = pl.angle.to_radians() + h.angle;
|
||||
@@ -1016,6 +1029,36 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
|
||||
}
|
||||
}
|
||||
|
||||
/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text
|
||||
/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one);
|
||||
/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`.
|
||||
fn code_page(d: &Drawing, version: Version) -> String {
|
||||
let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default();
|
||||
let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some();
|
||||
if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() }
|
||||
}
|
||||
|
||||
/// A multileader for a file older than 2007 (no MULTILEADER): a LEADER per leader line, ending
|
||||
/// at the landing, plus its MTEXT or its block's INSERT, with the multileader's properties.
|
||||
fn mleader_fallback(w: &mut W, d: &Drawing, e: &Entity, m: &MLeader, owner: &str, paper: bool, cx: &Ctx) {
|
||||
let part = |w: &mut W, kind: EntityKind| Entity { handle: Handle::parse_hex(&w.h()).unwrap_or(Handle(0)), common: e.common.clone(), kind };
|
||||
let style = d.header.str("DIMSTYLE", "Standard");
|
||||
for arm in m.leaders.iter().take(64) {
|
||||
let mut vertices = arm.clone();
|
||||
vertices.push(m.landing);
|
||||
let le = part(w, EntityKind::Leader(Leader { vertices, arrow: true, spline: m.spline, style: style.clone() }));
|
||||
entity(w, d, &le, owner, paper, cx);
|
||||
}
|
||||
if let Some(t) = &m.text {
|
||||
let te = part(w, EntityKind::MText(t.clone()));
|
||||
entity(w, d, &te, owner, paper, cx);
|
||||
}
|
||||
if let Some(ins) = &m.block {
|
||||
let ie = part(w, EntityKind::Insert(ins.clone()));
|
||||
entity(w, d, &ie, owner, paper, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A hatch's gradient fill (DXF Reference, HATCH group codes 450–470): two-colour gradient
|
||||
/// with its rotation (radians), shift (0 = centered, 1 = shifted) and per-colour 463 records
|
||||
/// carrying ACI (63) and, for true colours, the RGB value (421). Those groups belong to R2004+
|
||||
@@ -1406,6 +1449,18 @@ pub fn write(d: &Drawing) -> String {
|
||||
|
||||
/// The drawing's DXF tags, declaring `version` in `$ACADVER`.
|
||||
pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
tags_for(d, version, version)
|
||||
}
|
||||
|
||||
/// The R2000 DXF the DWG bridge converts to a `dwg` version DWG: its entity types are the DWG
|
||||
/// version's (MULTILEADER from 2007).
|
||||
pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {
|
||||
cadcraft_dxf::write_ascii(&tags_for(d, Version::R2000, dwg))
|
||||
}
|
||||
|
||||
/// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the
|
||||
/// file ends up in: `version`, or the DWG version the bridge writes) has.
|
||||
fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
// Structural handles start above every entity handle.
|
||||
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new() };
|
||||
// Pre-allocate handles for block records.
|
||||
@@ -1430,7 +1485,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
.collect();
|
||||
user_blocks.iter_mut().for_each(|b| b.1 = format!("{:X}", 0));
|
||||
let user_blocks: Vec<(String, String, &Block)> = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect();
|
||||
let mut cx = Ctx { paper_views: paper_views(d), ..Ctx::default() };
|
||||
let mut cx = Ctx { paper_views: paper_views(d), target, ..Ctx::default() };
|
||||
cx.block_records = user_blocks.iter().map(|(n, h, _)| (n.to_ascii_uppercase(), h.clone())).collect();
|
||||
// Dimension blocks.
|
||||
let mut dim_defs: Vec<(String, String, Vec<Entity>)> = Vec::new(); // filled below
|
||||
@@ -1555,6 +1610,9 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
// The drawing's own next handle: table records and objects are numbered above it, so
|
||||
// reading it back (instead of $HANDSEED) keeps their handles across saves (#495).
|
||||
xrecords.push((dxf_ext::HANDSEED_KEY, w.h(), vec![format!("{:X}", d.handseed)]));
|
||||
if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) {
|
||||
xrecords.push((dxf_ext::MLEADER_STYLE_KEY, w.h(), vec![name.to_string()]));
|
||||
}
|
||||
if let Some(chunks) = dxf_ext::constraint_chunks(&d.constraints, &d.parametric) {
|
||||
xrecords.push((dxf_ext::CONSTRAINTS_KEY, w.h(), chunks));
|
||||
}
|
||||
@@ -1592,7 +1650,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
w.s(9, "$ACADVER");
|
||||
w.s(1, version.code());
|
||||
w.s(9, "$DWGCODEPAGE");
|
||||
w.s(3, "ANSI_1252");
|
||||
w.s(3, code_page(d, version));
|
||||
let ext = d.extents(&Space::Model);
|
||||
if !ext.is_empty() {
|
||||
w.s(9, "$EXTMIN");
|
||||
@@ -1601,11 +1659,6 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
w.p(10, ext.max.to3(0.0));
|
||||
}
|
||||
header_vars(&mut w, d);
|
||||
// The current multileader style (an R2007 variable; older readers skip it).
|
||||
if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) {
|
||||
w.s(9, "$CMLEADERSTYLE");
|
||||
w.s(2, name);
|
||||
}
|
||||
let seed_pos = w.t.len();
|
||||
w.s(9, "$HANDSEED");
|
||||
w.s(5, "0");
|
||||
@@ -1629,7 +1682,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
if !cx.assoc.is_empty() {
|
||||
classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false));
|
||||
}
|
||||
if every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) {
|
||||
if cx.native_mleader() && every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) {
|
||||
classes.push(("MULTILEADER", "AcDbMLeader", 1025, true));
|
||||
}
|
||||
if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) {
|
||||
@@ -1867,6 +1920,11 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue };
|
||||
if !dxf_ext::NEWER_CODES.contains(code) {
|
||||
w.dimval(*code, enc, false);
|
||||
continue;
|
||||
}
|
||||
// Table groups where the file version has them, else xdata.
|
||||
if version >= dxf_ext::NEWER_SINCE {
|
||||
w.dimval(*code, enc, false);
|
||||
} else if v.get(*field) != defaults.get(*field) {
|
||||
newer.i(1070, i64::from(*code));
|
||||
newer.dimval(*code, enc, true);
|
||||
@@ -2255,10 +2313,45 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
*t = Tag::s(5, seed);
|
||||
}
|
||||
crate::dxf_raw::finish(&mut w.t, d);
|
||||
add_class_instance_counts(&mut w.t);
|
||||
mark_xrefs(&mut w.t, d);
|
||||
w.t
|
||||
}
|
||||
|
||||
/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish`
|
||||
/// restores foreign classes and objects so every class gets its actual instance count.
|
||||
fn add_class_instance_counts(tags: &mut Vec<Tag>) {
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
for t in tags.iter().filter(|t| t.code == 0) {
|
||||
let kind = t.str();
|
||||
if kind != "CLASS" {
|
||||
*counts.entry(kind.to_ascii_uppercase()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut start = 0;
|
||||
while start < tags.len() {
|
||||
if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") {
|
||||
start += 1;
|
||||
continue;
|
||||
}
|
||||
let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i);
|
||||
let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
|
||||
let count = counts.get(&name).copied().unwrap_or(0);
|
||||
let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) {
|
||||
tags[start + 1 + i] = Tag::i(91, count);
|
||||
false
|
||||
} else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) {
|
||||
tags.insert(start + 2 + i, Tag::i(91, count));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Inserting the group shifts the next record's start by one.
|
||||
start = end + usize::from(inserted);
|
||||
}
|
||||
}
|
||||
|
||||
/// Xref blocks (DXF Reference, BLOCK): bit 4 of group 70 and the external drawing's path in
|
||||
/// group 1, set on the written BLOCK records of blocks that have an `xref_path`.
|
||||
fn mark_xrefs(tags: &mut [Tag], d: &Drawing) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! CADCraft file formats: DXF read/write, SVG, PNG and PDF export (plotting).
|
||||
//! CADCraft file formats: DXF/DWG read, SVG and SVGZ import, SVG/PNG/PDF export (plotting).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -15,6 +15,7 @@ mod dxf_xref;
|
||||
pub mod pdf;
|
||||
pub mod plotter;
|
||||
pub mod svg;
|
||||
mod svg_read;
|
||||
|
||||
/// Multilines and their styles (MLINE, MLINESTYLE).
|
||||
mod dxf_mline;
|
||||
@@ -54,13 +55,15 @@ fn ext(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read a drawing; the format comes from the content (DXF ASCII/binary) or the name.
|
||||
/// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name
|
||||
/// (SVG and gzip-compressed SVGZ).
|
||||
pub fn read(bytes: &[u8], name: &str) -> Result<Drawing> {
|
||||
if cadcraft_dwg::is_dwg(bytes) {
|
||||
let dxf = cadcraft_dwg::dwg_to_dxf(bytes).map_err(IoError::Format)?;
|
||||
return dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")));
|
||||
}
|
||||
match ext(name).as_str() {
|
||||
"svg" | "svgz" => svg_read::read(bytes),
|
||||
"dxf" | "" => dxf_read::read(bytes),
|
||||
e if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())),
|
||||
e => Err(IoError::Unsupported(e.to_string())),
|
||||
@@ -92,7 +95,7 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<Vec<u8>
|
||||
// gradients, so a drawing from an R2000 DXF doesn't lose them by default.
|
||||
"dwg" if !opts.binary => {
|
||||
let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));
|
||||
cadcraft_dwg::dxf_to_dwg_as(dxf_write::write(d).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
|
||||
}
|
||||
e if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{e}`"))),
|
||||
_ => write_framed(d, name, None),
|
||||
|
||||
+68
-5
@@ -70,6 +70,12 @@ pub struct PdfOptions {
|
||||
/// embedded font subset (like AutoCAD's PDFSHX for its own fonts). Stroke-font text is
|
||||
/// always geometry.
|
||||
pub text_as_geometry: bool,
|
||||
/// Margins (left, bottom, right, top; mm) of the plot device's paper, bounding the printable
|
||||
/// area of a model-space plot. `None` keeps the page setup's (none for model space).
|
||||
pub margins: Option<[f64; 4]>,
|
||||
/// Entity hyperlinks read from a drawing file (`PE_URL` extended data) become link
|
||||
/// annotations too, after [`PdfOptions::links`]. Default on.
|
||||
pub hyperlinks: bool,
|
||||
}
|
||||
|
||||
impl Default for PdfOptions {
|
||||
@@ -89,13 +95,15 @@ impl Default for PdfOptions {
|
||||
include_off_layers: false,
|
||||
links: Vec::new(),
|
||||
text_as_geometry: false,
|
||||
margins: None,
|
||||
hyperlinks: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfOptions {
|
||||
/// Parse `{paper?, width?, height?, landscape?, fit?, scale?, lineweights?, compress?, title?,
|
||||
/// window?: [[x1, y1], [x2, y2]]}`.
|
||||
/// window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm), hyperlinks?: bool}`.
|
||||
pub fn from_json(v: &Value) -> PdfOptions {
|
||||
let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let paper_mm = match (num("width"), num("height")) {
|
||||
@@ -119,10 +127,21 @@ impl PdfOptions {
|
||||
compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true),
|
||||
title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
window: window(v.get("window")),
|
||||
margins: margins(v.get("margins")),
|
||||
hyperlinks: v.get("hyperlinks").and_then(Value::as_bool).unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`.
|
||||
pub(crate) fn margins(v: Option<&Value>) -> Option<[f64; 4]> {
|
||||
let a: Vec<f64> = v?.as_array()?.iter().take(5).filter_map(Value::as_f64).collect();
|
||||
match a[..] {
|
||||
[l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A plot window from `[[x1, y1], [x2, y2]]`: finite corners and a positive size, else `None`.
|
||||
fn window(v: Option<&Value>) -> Option<Bounds2> {
|
||||
let corner = |i: usize| {
|
||||
@@ -214,6 +233,10 @@ pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<PageSetup>
|
||||
if let Some(t) = &o.plot_style_table {
|
||||
page.plot_style_table = t.clone();
|
||||
}
|
||||
// The plot device's margins bound a model-space plot (a layout's come with its page setup).
|
||||
if let (Space::Model, Some(m)) = (space, o.margins) {
|
||||
page.margins_mm = m;
|
||||
}
|
||||
page.width_mm = page.width_mm.clamp(1.0, MAX_SHEET_MM);
|
||||
page.height_mm = page.height_mm.clamp(1.0, MAX_SHEET_MM);
|
||||
Ok(page)
|
||||
@@ -269,7 +292,8 @@ pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u
|
||||
} else {
|
||||
d
|
||||
};
|
||||
let pages = spaces.iter().map(|s| sheet(drawn, s, o)).collect::<Result<Vec<_>>>()?;
|
||||
let links = all_links(d, o);
|
||||
let pages = spaces.iter().map(|s| sheet(drawn, s, o, &links)).collect::<Result<Vec<_>>>()?;
|
||||
let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() };
|
||||
let fonts = if o.text_as_geometry { fonts::Fonts::default() } else { fonts::Fonts::collect(pages.iter().map(|p| &p.list)) };
|
||||
Ok(assemble(d, &pages, &fonts, o, &title))
|
||||
@@ -292,7 +316,7 @@ struct SheetPage {
|
||||
}
|
||||
|
||||
/// Plot one space.
|
||||
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<SheetPage> {
|
||||
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String)]) -> Result<SheetPage> {
|
||||
let page = page_for(d, space, o)?;
|
||||
let unit_mm = paper::paper_unit_mm(d);
|
||||
let sheet = Sheet::from_page(&page, unit_mm);
|
||||
@@ -301,7 +325,16 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<SheetPage> {
|
||||
Space::Model => true,
|
||||
});
|
||||
let plot_style_table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(std::sync::Arc::new);
|
||||
let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights, view_height: 0.0, plot_style_table };
|
||||
let ropts = cadcraft_render::Options {
|
||||
tolerance: 0.001,
|
||||
min_dash: 0.0,
|
||||
text: true,
|
||||
fill: true,
|
||||
lineweights,
|
||||
view_height: 0.0,
|
||||
plot_style_table,
|
||||
view: None,
|
||||
};
|
||||
let k = unit_mm * PT_PER_MM;
|
||||
let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty());
|
||||
let fit = o.fit.unwrap_or(window.is_some() || matches!(space, Space::Model));
|
||||
@@ -339,10 +372,40 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<SheetPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let links = links(&list, &map, &clip_pt, &o.links);
|
||||
let links = links(&list, &map, &clip_pt, links_to);
|
||||
Ok(SheetPage { media, list, map, clip_pt, layers, links, label: sheet_name(space) })
|
||||
}
|
||||
|
||||
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
|
||||
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
|
||||
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
|
||||
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
|
||||
if !o.hyperlinks {
|
||||
return out;
|
||||
}
|
||||
let given: std::collections::HashSet<Handle> = out.iter().map(|(h, _)| *h).collect();
|
||||
for (h, extra) in &d.foreign.entities {
|
||||
if out.len() >= MAX_LINKS {
|
||||
break;
|
||||
}
|
||||
if given.contains(h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(url) = pe_url(&extra.xdata) {
|
||||
out.push((*h, url));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The link target of `PE_URL` extended data: the first string (group 1000) after
|
||||
/// `1001 PE_URL`, before the next application's data.
|
||||
fn pe_url(xdata: &[cadcraft_doc::RawTag]) -> Option<String> {
|
||||
let start = xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("PE_URL"))?;
|
||||
let url = xdata.get(start + 1..)?.iter().take_while(|t| t.code != 1001).find(|t| t.code == 1000)?.value.trim();
|
||||
(!url.is_empty() && url.len() <= MAX_URL).then(|| url.to_string())
|
||||
}
|
||||
|
||||
/// Link annotation rectangles: the area (in points) each linked entity covers on the sheet,
|
||||
/// clipped to the plot area.
|
||||
fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, links: &[(Handle, String)]) -> Vec<(Bounds2, String)> {
|
||||
|
||||
+33
-28
@@ -19,9 +19,10 @@ use crate::{IoError, PdfOptions, Result, pdf};
|
||||
pub const MAX_PNG_PIXELS: f64 = 40.0e6;
|
||||
|
||||
/// Plot `space` with JSON options (the engine's `plot` hook): PDF unless `output` is `png` or
|
||||
/// `svg`. `margins: [l, b, r, t]` (mm) replaces a layout's printable area for this plot.
|
||||
/// `svg`. `margins: [l, b, r, t]` (mm) bound the printable area of this plot (model space or a
|
||||
/// layout).
|
||||
pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
let margins = margins(opts.get("margins"));
|
||||
let margins = pdf::margins(opts.get("margins"));
|
||||
let with_margins;
|
||||
let d = match (space, margins) {
|
||||
(Space::Paper(n), Some(m)) => {
|
||||
@@ -36,28 +37,15 @@ pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
};
|
||||
match opts.get("output").and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("pdf") => pdf::plot(d, space, opts),
|
||||
Some("png") => png(d, space, opts, margins),
|
||||
Some("png") => png(d, space, opts),
|
||||
Some("svg") => svg(d, space, opts),
|
||||
Some(other) => Err(IoError::Unsupported(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`.
|
||||
fn margins(v: Option<&Value>) -> Option<[f64; 4]> {
|
||||
let a: Vec<f64> = v?.as_array()?.iter().filter_map(Value::as_f64).collect();
|
||||
match a[..] {
|
||||
[l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet a plot draws on, and whether the drawing is fitted to its printable area (model
|
||||
/// space, or a layout asked to `fit`).
|
||||
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, margins: Option<[f64; 4]>) -> Result<(Sheet, cadcraft_doc::PageSetup)> {
|
||||
let mut page = pdf::page_for(d, space, o)?;
|
||||
if let (Space::Model, Some(m)) = (space, margins) {
|
||||
page.margins_mm = m;
|
||||
}
|
||||
/// The sheet a plot draws on (a model-space plot's printable area inside the device margins).
|
||||
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<(Sheet, cadcraft_doc::PageSetup)> {
|
||||
let page = pdf::page_for(d, space, o)?;
|
||||
Ok((Sheet::from_page(&page, paper::paper_unit_mm(d)), page))
|
||||
}
|
||||
|
||||
@@ -75,9 +63,9 @@ fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 {
|
||||
if s.is_finite() && s > 0.0 { s } else { 1.0 }
|
||||
}
|
||||
|
||||
fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> Result<Vec<u8>> {
|
||||
fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
let o = PdfOptions::from_json(opts);
|
||||
let (sheet, page) = sheet(d, space, &o, margins)?;
|
||||
let (sheet, page) = sheet(d, space, &o)?;
|
||||
let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0);
|
||||
// Pixels per paper unit, lowered so the image stays within the caps.
|
||||
let mut k = sheet.unit_mm / 25.4 * dpi;
|
||||
@@ -101,11 +89,14 @@ fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> R
|
||||
// Chord tolerance: a fifth of a pixel.
|
||||
let tolerance = (0.2 / (k * est)).max(1e-9);
|
||||
let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() };
|
||||
let list = cadcraft_render::build_plot(d, space, &ropts);
|
||||
let mut list = cadcraft_render::build_plot(d, space, &ropts);
|
||||
let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k);
|
||||
let b = window.unwrap_or(list.bounds);
|
||||
let s = if fit { fit_scale(&b, &sheet, &o) } else { 1.0 };
|
||||
// Plot style ends, joins and fill patterns at the plot scale, as PDF draws them (a layout's
|
||||
// are already drawn).
|
||||
cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm);
|
||||
let view = if fit {
|
||||
let b = window.unwrap_or(list.bounds);
|
||||
let s = fit_scale(&b, &sheet, &o);
|
||||
let from = if b.is_empty() { Vec2::ZERO } else { b.center() };
|
||||
// The world point at the image centre, with `from` on the printable area's centre.
|
||||
let center = from + (half - sheet.printable.center()) / s;
|
||||
@@ -120,11 +111,25 @@ fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> R
|
||||
|
||||
fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
let o = PdfOptions::from_json(opts);
|
||||
let window = match space {
|
||||
Space::Paper(_) => Some(sheet(d, space, &o, None)?.0.bounds()),
|
||||
Space::Model => o.window.filter(|w| !w.is_empty()),
|
||||
let Space::Model = space else {
|
||||
let window = sheet(d, space, &o)?.0.bounds();
|
||||
return Ok(crate::svg::export_window(d, space, Some(window)).into_bytes());
|
||||
};
|
||||
Ok(crate::svg::export_window(d, space, window).into_bytes())
|
||||
// Model space as plotted: the plot style table, lineweights, and the table's ends, joins and
|
||||
// fills at the scale that fits the window (or the extents) on the device's paper.
|
||||
let (sheet, page) = sheet(d, space, &o)?;
|
||||
let window = o.window.filter(|w| !w.is_empty());
|
||||
let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new);
|
||||
let lineweights = o.lineweights.unwrap_or(true);
|
||||
let est = fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o);
|
||||
// Chord tolerance: about 0.05 mm on paper.
|
||||
let tolerance = (0.05 / (sheet.unit_mm * est)).max(1e-9);
|
||||
let tolerance = if tolerance.is_finite() { tolerance } else { cadcraft_render::Options::default().tolerance };
|
||||
let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() };
|
||||
let mut list = cadcraft_render::build_plot(d, space, &ropts);
|
||||
let s = fit_scale(&window.unwrap_or(list.bounds), &sheet, &o);
|
||||
cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm);
|
||||
Ok(crate::svg::export_list(&list, window).into_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -16,6 +16,11 @@ pub fn export(d: &Drawing, space: &Space) -> String {
|
||||
/// or the extents with a 2% margin when `window` is `None`.
|
||||
pub fn export_window(d: &Drawing, space: &Space, window: Option<Bounds2>) -> String {
|
||||
let list = cadcraft_render::build(d, space, &cadcraft_render::Options::default());
|
||||
export_list(&list, window)
|
||||
}
|
||||
|
||||
/// Write a display list (as [`export_window`] does for a space's own).
|
||||
pub fn export_list(list: &cadcraft_render::DisplayList, window: Option<Bounds2>) -> String {
|
||||
let window = window.filter(|w| !w.is_empty());
|
||||
let b = window.unwrap_or(list.bounds);
|
||||
let (w, h) = if b.is_empty() { (1.0, 1.0) } else { (b.width().max(1e-9), b.height().max(1e-9)) };
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
//! SVG → Drawing: the static W3C SVG subset that drawing tools emit.
|
||||
//!
|
||||
//! Reads shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`), `path`
|
||||
//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping with `transform`
|
||||
//! attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers
|
||||
//! (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres with
|
||||
//! the page's top-left corner at the origin and Y pointing up — where a laser or pen job
|
||||
//! wants it.
|
||||
//!
|
||||
//! Not read (each is a follow-up): CSS class rules (`<style>`), `use` references, fills,
|
||||
//! gradients and hatch patterns, text, embedded raster images, rounded rectangle corners
|
||||
//! (`rx`/`ry`), and elliptical arcs under non-uniform transforms (those are flattened into
|
||||
//! line segments instead of losing the shape).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::{Mat3, PolyVertex, Spline, Vec2, Vec3};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
/// A decompressed SVGZ payload larger than this is refused (decompression bomb guard).
|
||||
const MAX_DECOMPRESSED: usize = 64 << 20;
|
||||
/// Shape elements per file (rect, circle, path, …; each counted once).
|
||||
const MAX_ELEMENTS: usize = 500_000;
|
||||
/// Path data segments per `d` attribute.
|
||||
const MAX_PATH_SEGMENTS: usize = 100_000;
|
||||
/// Vertices in one polyline, and control points in one spline.
|
||||
const MAX_CURVE_POINTS: usize = 100_001;
|
||||
/// Layers created from Inkscape layer groups.
|
||||
const MAX_LAYERS: usize = 256;
|
||||
/// Nested `<g>`/`<svg>` transforms.
|
||||
const MAX_TRANSFORM_DEPTH: usize = 64;
|
||||
/// Flattened chords per elliptical arc under a non-uniform transform.
|
||||
const MAX_ARC_CHORDS: usize = 10_000;
|
||||
|
||||
/// Elements whose whole subtree is skipped (paint servers, text, raster images, metadata).
|
||||
const SKIP: &[&[u8]] = &[
|
||||
b"defs",
|
||||
b"symbol",
|
||||
b"clipPath",
|
||||
b"mask",
|
||||
b"pattern",
|
||||
b"marker",
|
||||
b"linearGradient",
|
||||
b"radialGradient",
|
||||
b"filter",
|
||||
b"style",
|
||||
b"text",
|
||||
b"image",
|
||||
b"title",
|
||||
b"desc",
|
||||
b"metadata",
|
||||
];
|
||||
|
||||
/// Elements that hold children and push a transform stack frame.
|
||||
const CONTAINERS: &[&[u8]] = &[b"g", b"svg", b"a", b"switch"];
|
||||
|
||||
/// Read an SVG document, or a gzip-compressed one (`.svgz`, Inkscape's "Compressed SVG").
|
||||
pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
let unpacked;
|
||||
let plain: &[u8] = if bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
unpacked = gunzip(bytes)?;
|
||||
&unpacked
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
let text = std::str::from_utf8(plain).map_err(|_| IoError::Format("SVG files must be UTF-8".into()))?;
|
||||
let mut parser = Parser::new();
|
||||
parser.parse(text)?;
|
||||
Ok(parser.drawing)
|
||||
}
|
||||
|
||||
/// Decompress a gzip member (RFC 1952) with a hard output cap.
|
||||
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let damaged = || IoError::Format("this SVGZ file's gzip stream is damaged".into());
|
||||
if bytes.get(2) != Some(&8) {
|
||||
return Err(damaged());
|
||||
}
|
||||
let flg = bytes.get(3).copied().unwrap_or(0);
|
||||
let mut pos = 10usize;
|
||||
if flg & 0x04 != 0 {
|
||||
// FEXTRA
|
||||
let extra = bytes.get(pos..pos + 2).ok_or_else(damaged)?;
|
||||
pos += 2 + usize::from(u16::from_le_bytes([extra[0], extra[1]]));
|
||||
}
|
||||
if flg & 0x08 != 0 {
|
||||
pos = skip_cstring(bytes, pos).ok_or_else(damaged)?; // FNAME
|
||||
}
|
||||
if flg & 0x10 != 0 {
|
||||
pos = skip_cstring(bytes, pos).ok_or_else(damaged)?; // FCOMMENT
|
||||
}
|
||||
if flg & 0x02 != 0 {
|
||||
pos = pos.saturating_add(2); // FHCRC
|
||||
}
|
||||
let end = bytes.len().checked_sub(8).filter(|e| *e >= pos).ok_or_else(damaged)?;
|
||||
miniz_oxide::inflate::decompress_to_vec_with_limit(&bytes[pos..end], MAX_DECOMPRESSED).map_err(|_| damaged())
|
||||
}
|
||||
|
||||
/// Skip a NUL-terminated field; `None` when the terminator is missing.
|
||||
fn skip_cstring(bytes: &[u8], from: usize) -> Option<usize> {
|
||||
let rest = bytes.get(from..)?;
|
||||
let nul = rest.iter().position(|b| *b == 0)?;
|
||||
Some(from + nul + 1)
|
||||
}
|
||||
|
||||
fn parse_err<E: std::fmt::Display>(e: E) -> IoError {
|
||||
IoError::Format(format!("SVG: {e}"))
|
||||
}
|
||||
|
||||
fn v3(p: Vec2) -> Vec3 {
|
||||
Vec3::new(p.x, p.y, 0.0)
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
drawing: Drawing,
|
||||
/// Root viewBox/unit mapping composed with every open container transform.
|
||||
stack: Vec<Mat3>,
|
||||
/// Current Inkscape layer name.
|
||||
layer: String,
|
||||
layers: HashSet<String>,
|
||||
layer_color: u8,
|
||||
elements: usize,
|
||||
/// > 0 while inside a skipped subtree; counts the starts seen there.
|
||||
skip: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn new() -> Self {
|
||||
Parser {
|
||||
drawing: Drawing::new_metric(),
|
||||
stack: vec![Mat3::IDENTITY],
|
||||
layer: "0".into(),
|
||||
layers: HashSet::from(["0".to_string()]),
|
||||
layer_color: 1,
|
||||
elements: 0,
|
||||
skip: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(&mut self, text: &str) -> Result<()> {
|
||||
let mut reader = Reader::from_str(text);
|
||||
reader.config_mut().trim_text(true);
|
||||
loop {
|
||||
match reader.read_event().map_err(parse_err)? {
|
||||
Event::Start(e) => {
|
||||
let name = e.name();
|
||||
self.on_start(name.as_ref(), &e)?;
|
||||
}
|
||||
Event::Empty(e) => {
|
||||
let name = e.name();
|
||||
self.on_element(name.as_ref(), &e)?;
|
||||
}
|
||||
Event::End(e) => {
|
||||
let name = e.name();
|
||||
self.on_end(name.as_ref());
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A start tag: open a transform frame, descend into a skipped subtree, or treat a
|
||||
/// shape written as start+end the same as a self-closed one.
|
||||
fn on_start(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
|
||||
if self.skip > 0 {
|
||||
self.skip += 1;
|
||||
return Ok(());
|
||||
}
|
||||
if SKIP.contains(&name) {
|
||||
self.skip = 1;
|
||||
return Ok(());
|
||||
}
|
||||
if hidden(e) {
|
||||
self.skip = 1;
|
||||
return Ok(());
|
||||
}
|
||||
if CONTAINERS.contains(&name) { self.enter_group(name, e) } else { self.on_element(name, e) }
|
||||
}
|
||||
|
||||
fn on_element(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
|
||||
if self.skip > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if SKIP.contains(&name) || hidden(e) {
|
||||
return Ok(());
|
||||
}
|
||||
match name {
|
||||
b"rect" => self.rect(e),
|
||||
b"circle" => self.circle(e),
|
||||
b"ellipse" => self.ellipse(e),
|
||||
b"line" => self.line(e),
|
||||
b"polyline" => self.poly_points(e, false),
|
||||
b"polygon" => self.poly_points(e, true),
|
||||
b"path" => self.path(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_end(&mut self, name: &[u8]) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
} else if CONTAINERS.contains(&name) && self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_group(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
|
||||
if name == b"svg" && self.stack.len() == 1 {
|
||||
self.stack[0] = viewport(e)?;
|
||||
}
|
||||
if name == b"g" && attr(e, b"inkscape:groupmode").as_deref() == Some("layer") {
|
||||
let raw = attr(e, b"inkscape:label").or_else(|| attr(e, b"id"));
|
||||
if let Some(raw) = raw {
|
||||
self.set_layer(&raw);
|
||||
}
|
||||
}
|
||||
if self.stack.len() >= MAX_TRANSFORM_DEPTH {
|
||||
return Err(IoError::Format(format!("SVG: more than {MAX_TRANSFORM_DEPTH} nested groups")));
|
||||
}
|
||||
let current = *self.stack.last().unwrap_or(&Mat3::IDENTITY);
|
||||
let next = match attr(e, b"transform").as_deref().and_then(parse_transform) {
|
||||
Some(local) => current.then_before(local),
|
||||
None => current,
|
||||
};
|
||||
self.stack.push(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_layer(&mut self, raw: &str) {
|
||||
let name = sanitize_layer(raw);
|
||||
if !self.layers.contains(&name) {
|
||||
if self.layers.len() >= MAX_LAYERS {
|
||||
return;
|
||||
}
|
||||
let color = Color::Index(self.layer_color);
|
||||
self.layer_color = if self.layer_color >= 7 { 1 } else { self.layer_color + 1 };
|
||||
self.drawing.layers.push(Layer { name: name.clone(), color, ..Layer::default() });
|
||||
self.layers.insert(name.clone());
|
||||
}
|
||||
self.layer = name;
|
||||
}
|
||||
|
||||
/// The composed mapping for the current nesting: SVG user units → drawing units (mm).
|
||||
fn cur(&self) -> Mat3 {
|
||||
*self.stack.last().unwrap_or(&Mat3::IDENTITY)
|
||||
}
|
||||
|
||||
fn map(&self, p: Vec2) -> Vec2 {
|
||||
self.cur().apply(p)
|
||||
}
|
||||
|
||||
fn map_vec(&self, v: Vec2) -> Vec2 {
|
||||
self.cur().apply_vec(v)
|
||||
}
|
||||
|
||||
fn add(&mut self, kind: EntityKind) -> Result<()> {
|
||||
self.elements += 1;
|
||||
if self.elements > MAX_ELEMENTS {
|
||||
return Err(IoError::Format(format!("SVG: more than {MAX_ELEMENTS} elements")));
|
||||
}
|
||||
let common = Common { layer: self.layer.clone(), ..Common::default() };
|
||||
self.drawing.add(&Space::Model, common, kind).map(|_| ()).map_err(parse_err)
|
||||
}
|
||||
|
||||
// --- shapes -------------------------------------------------------------
|
||||
|
||||
fn rect(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let x = num(e, b"x", 0.0);
|
||||
let y = num(e, b"y", 0.0);
|
||||
let w = num(e, b"width", 0.0);
|
||||
let h = num(e, b"height", 0.0);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let pts = [Vec2::new(x, y), Vec2::new(x + w, y), Vec2::new(x + w, y + h), Vec2::new(x, y + h)];
|
||||
self.add_polyline(&pts.map(|p| self.map(p)), true)
|
||||
}
|
||||
|
||||
fn circle(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let r = num(e, b"r", 0.0);
|
||||
if r <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.add_rounded(Vec2::new(num(e, b"cx", 0.0), num(e, b"cy", 0.0)), r, r)
|
||||
}
|
||||
|
||||
fn ellipse(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let rx = num(e, b"rx", 0.0);
|
||||
let ry = num(e, b"ry", 0.0);
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.add_rounded(Vec2::new(num(e, b"cx", 0.0), num(e, b"cy", 0.0)), rx, ry)
|
||||
}
|
||||
|
||||
/// A circle or ellipse through a transform. A circle stays a `CIRCLE` under rotations,
|
||||
/// uniform scales and reflections; anything else becomes an `ELLIPSE` holding the axes
|
||||
/// of the transformed shape — a linear map sends the unit circle to an ellipse whose
|
||||
/// semi-axes are the square roots of the eigenvalues of `A·Aᵀ`.
|
||||
fn add_rounded(&mut self, center_user: Vec2, rx: f64, ry: f64) -> Result<()> {
|
||||
let center = self.map(center_user);
|
||||
let bx = self.map_vec(Vec2::new(rx, 0.0));
|
||||
let by = self.map_vec(Vec2::new(0.0, ry));
|
||||
let len = |v: Vec2| v.x.hypot(v.y);
|
||||
let (lbx, lby, dot) = (len(bx), len(by), bx.x * by.x + bx.y * by.y);
|
||||
if lbx.is_finite() && lby.is_finite() && (lbx - lby).abs() <= 1e-9 * (lbx + lby) && dot.abs() <= 1e-9 * lbx * lby {
|
||||
return self.add(EntityKind::Circle(Circle { center: v3(center), radius: lbx }));
|
||||
}
|
||||
let xx = bx.x * bx.x + bx.y * bx.y;
|
||||
let yy = by.x * by.x + by.y * by.y;
|
||||
let xy = bx.x * by.x + bx.y * by.y;
|
||||
let trace = xx + yy;
|
||||
let disc = (trace * trace - 4.0 * (xx * yy - xy * xy)).max(0.0).sqrt();
|
||||
let l_max = 0.5 * (trace + disc);
|
||||
let l_min = 0.5 * (trace - disc);
|
||||
let s_max = l_max.max(0.0).sqrt();
|
||||
if s_max <= f64::EPSILON {
|
||||
return Ok(());
|
||||
}
|
||||
let axis = if xy.abs() <= 1e-12 * trace.max(1.0) {
|
||||
if yy > xx { Vec2::new(0.0, 1.0) } else { Vec2::new(1.0, 0.0) }
|
||||
} else {
|
||||
let v = Vec2::new(xy, l_max - xx);
|
||||
let l = len(v);
|
||||
if l <= f64::EPSILON {
|
||||
return Ok(());
|
||||
}
|
||||
v / l
|
||||
};
|
||||
let ratio = (l_min.max(0.0).sqrt() / s_max).clamp(1e-9, 1.0);
|
||||
let major = center + axis * s_max;
|
||||
self.add(EntityKind::Ellipse(Ellipse { center: v3(center), major: v3(major), ratio, start: 0.0, end: std::f64::consts::TAU }))
|
||||
}
|
||||
|
||||
fn line(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let a = Vec2::new(num(e, b"x1", 0.0), num(e, b"y1", 0.0));
|
||||
let b = Vec2::new(num(e, b"x2", 0.0), num(e, b"y2", 0.0));
|
||||
self.add(EntityKind::Line(Line { a: v3(self.map(a)), b: v3(self.map(b)) }))
|
||||
}
|
||||
|
||||
fn poly_points(&mut self, e: &BytesStart, closed: bool) -> Result<()> {
|
||||
let Some(raw) = attr(e, b"points") else {
|
||||
return Ok(());
|
||||
};
|
||||
let nums: Vec<f64> = Numbers::new(&raw).collect();
|
||||
let mut pts: Vec<Vec2> = nums.as_chunks::<2>().0.iter().map(|[x, y]| self.map(Vec2::new(*x, *y))).collect();
|
||||
if pts.len() > MAX_CURVE_POINTS {
|
||||
pts.truncate(MAX_CURVE_POINTS);
|
||||
}
|
||||
self.add_polyline(&pts, closed)
|
||||
}
|
||||
|
||||
fn add_polyline(&mut self, pts: &[Vec2], closed: bool) -> Result<()> {
|
||||
if pts.len() < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
let vertices = pts.iter().map(|p| PolyVertex::new(*p)).collect();
|
||||
self.add(EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }))
|
||||
}
|
||||
|
||||
// --- path data ----------------------------------------------------------
|
||||
|
||||
fn path(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let Some(d) = attr(e, b"d") else {
|
||||
return Ok(());
|
||||
};
|
||||
for sp in parse_path(&d)? {
|
||||
self.emit_subpath(&sp)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit one subpath: runs of lines and arcs become an `LWPOLYLINE` (an arc becomes a
|
||||
/// bulge vertex, exactly like DXF's polyline bulge), runs of Bézier curves become a
|
||||
/// `SPLINE`. Under a non-uniform transform a circular arc is no longer circular, so it
|
||||
/// is flattened into line vertices instead.
|
||||
fn emit_subpath(&mut self, sp: &Subpath) -> Result<()> {
|
||||
let m = self.cur();
|
||||
let det = m.det();
|
||||
let ex = m.apply_vec(Vec2::new(1.0, 0.0));
|
||||
let ey = m.apply_vec(Vec2::new(0.0, 1.0));
|
||||
let mag = (ex.x * ex.x + ex.y * ex.y + ey.x * ey.x + ey.y * ey.y).max(1.0);
|
||||
let uniform =
|
||||
(ex.x * ex.x + ex.y * ex.y - (ey.x * ey.x + ey.y * ey.y)).abs() <= 1e-9 * mag && (ex.x * ey.x + ex.y * ey.y).abs() <= 1e-9 * mag;
|
||||
let scale = det.abs().sqrt();
|
||||
|
||||
let mut run: Vec<PolyVertex> = Vec::new();
|
||||
let mut ctrl: Vec<Vec2> = Vec::new();
|
||||
let mut cur_user = sp.start;
|
||||
let mut cur = m.apply(sp.start);
|
||||
|
||||
for seg in &sp.segs {
|
||||
match seg {
|
||||
Seg::Line(p) => {
|
||||
if run.is_empty() {
|
||||
run.push(PolyVertex::new(cur));
|
||||
}
|
||||
cur = m.apply(*p);
|
||||
run.push(PolyVertex::new(cur));
|
||||
cur_user = *p;
|
||||
}
|
||||
Seg::Cubic(c1, c2, p) => {
|
||||
self.flush_run(&mut run, false)?;
|
||||
if ctrl.is_empty() {
|
||||
ctrl.push(cur);
|
||||
}
|
||||
ctrl.push(m.apply(*c1));
|
||||
ctrl.push(m.apply(*c2));
|
||||
cur = m.apply(*p);
|
||||
ctrl.push(cur);
|
||||
cur_user = *p;
|
||||
if ctrl.len() > MAX_CURVE_POINTS {
|
||||
return Err(IoError::Format("SVG: one path has too many curve points".into()));
|
||||
}
|
||||
}
|
||||
Seg::Arc { rx, ry, rot, large, sweep, end } => {
|
||||
if uniform && scale > 0.0 {
|
||||
let r = rx * scale;
|
||||
let flipped = det < 0.0;
|
||||
if r > 0.0
|
||||
&& let Some((_, delta)) = arc_center_circular(cur, m.apply(*end), r, *large, *sweep != flipped)
|
||||
{
|
||||
if run.is_empty() {
|
||||
run.push(PolyVertex::new(cur));
|
||||
}
|
||||
if let Some(last) = run.last_mut() {
|
||||
last.bulge = (delta / 4.0).tan();
|
||||
}
|
||||
run.push(PolyVertex::new(m.apply(*end)));
|
||||
cur = m.apply(*end);
|
||||
cur_user = *end;
|
||||
}
|
||||
} else if *rx > 0.0 && *ry > 0.0 {
|
||||
self.flush_run(&mut run, false)?;
|
||||
self.flush_ctrl(&mut ctrl)?;
|
||||
for p in flatten_arc(&m, cur_user, *end, *rx, *ry, *rot, *large, *sweep)? {
|
||||
if run.is_empty() {
|
||||
run.push(PolyVertex::new(cur));
|
||||
}
|
||||
run.push(PolyVertex::new(p));
|
||||
}
|
||||
cur = m.apply(*end);
|
||||
cur_user = *end;
|
||||
}
|
||||
}
|
||||
}
|
||||
if run.len() > MAX_CURVE_POINTS {
|
||||
return Err(IoError::Format("SVG: one path has too many vertices".into()));
|
||||
}
|
||||
}
|
||||
self.flush_run(&mut run, sp.closed)?;
|
||||
self.flush_ctrl(&mut ctrl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_run(&mut self, run: &mut Vec<PolyVertex>, closed: bool) -> Result<()> {
|
||||
if run.len() >= 2 {
|
||||
let vertices = std::mem::take(run);
|
||||
self.add(EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }))?;
|
||||
} else {
|
||||
run.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_ctrl(&mut self, ctrl: &mut Vec<Vec2>) -> Result<()> {
|
||||
if ctrl.len() >= 4 {
|
||||
let spline = Spline::from_control(std::mem::take(ctrl), 3);
|
||||
self.add(EntityKind::Spline(spline))?;
|
||||
} else {
|
||||
ctrl.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The element is not rendered (`display="none"`, or a style that says so).
|
||||
fn hidden(e: &BytesStart) -> bool {
|
||||
if attr(e, b"display").as_deref() == Some("none") {
|
||||
return true;
|
||||
}
|
||||
attr(e, b"style").is_some_and(|s| s.replace(' ', "").contains("display:none"))
|
||||
}
|
||||
|
||||
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
|
||||
for a in e.attributes().flatten() {
|
||||
if a.key.as_ref() == name {
|
||||
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn num(e: &BytesStart, name: &[u8], fallback: f64) -> f64 {
|
||||
attr(e, name).as_deref().and_then(parse_length_mm).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// A bare number, or a number with an absolute CSS/SVG unit: millimetres here, everything
|
||||
/// else (px, the default; pt, pc, in, cm) through the CSS reference pixel (96 per inch).
|
||||
fn parse_length_mm(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let cut = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')).unwrap_or(s.len());
|
||||
let (n, unit) = s.split_at(cut);
|
||||
let value: f64 = n.trim().parse().ok()?;
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
match unit.trim() {
|
||||
"" | "px" => Some(value),
|
||||
"mm" => Some(value),
|
||||
"cm" => Some(value * 10.0),
|
||||
"in" => Some(value * 25.4),
|
||||
"pt" => Some(value * 25.4 / 72.0),
|
||||
"pc" => Some(value * 25.4 / 6.0),
|
||||
_ => Some(value * 25.4 / 96.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The root mapping: `viewBox` and unit-bearing `width`/`height` become millimetres per user
|
||||
/// unit, and the page's Y axis is flipped so the drawing keeps its appearance with Y up.
|
||||
fn viewport(e: &BytesStart) -> Result<Mat3> {
|
||||
let vb = attr(e, b"viewBox").and_then(|raw| {
|
||||
let nums: Vec<f64> = Numbers::new(&raw).collect();
|
||||
match nums.as_slice() {
|
||||
[x, y, w, h] if w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0 => Some((*x, *y, *w, *h)),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let w_mm = attr(e, b"width").as_deref().and_then(parse_length_mm);
|
||||
let h_mm = attr(e, b"height").as_deref().and_then(parse_length_mm);
|
||||
let unit = match (vb, w_mm, h_mm) {
|
||||
(Some((_, _, vw, _)), Some(w), _) if w > 0.0 => w / vw,
|
||||
(Some((_, _, _, vh)), _, Some(h)) if h > 0.0 => h / vh,
|
||||
(Some(_), _, _) => 25.4 / 96.0,
|
||||
(None, _, _) => 25.4 / 96.0,
|
||||
};
|
||||
let (vx, vy, vh) = match vb {
|
||||
Some((x, y, _, h)) => (x, y, h),
|
||||
None => (0.0, 0.0, 0.0),
|
||||
};
|
||||
Ok(Mat3 { a: unit, b: 0.0, c: 0.0, d: -unit, e: -vx * unit, f: (vy + vh) * unit })
|
||||
}
|
||||
|
||||
/// `transform="…"`: a list of functions, applied left to right.
|
||||
fn parse_transform(s: &str) -> Option<Mat3> {
|
||||
let mut out: Option<Mat3> = None;
|
||||
let mut rest = s.trim();
|
||||
while !rest.is_empty() {
|
||||
let open = rest.find('(')?;
|
||||
let name = rest[..open].trim();
|
||||
let close = rest[open..].find(')')? + open;
|
||||
let args: Vec<f64> = Numbers::new(&rest[open + 1..close]).collect();
|
||||
let m = match (name, args.as_slice()) {
|
||||
("matrix", [a, b, c, d, e, f]) => Mat3 { a: *a, b: *b, c: *c, d: *d, e: *e, f: *f },
|
||||
("translate", [x]) => Mat3::translate(Vec2::new(*x, 0.0)),
|
||||
("translate", [x, y]) => Mat3::translate(Vec2::new(*x, *y)),
|
||||
("scale", [s]) => Mat3::scale(*s, *s),
|
||||
("scale", [sx, sy]) => Mat3::scale(*sx, *sy),
|
||||
("rotate", [a]) => Mat3::rotate(a.to_radians()),
|
||||
("rotate", [a, cx, cy]) => Mat3::rotate_about(Vec2::new(*cx, *cy), a.to_radians()),
|
||||
("skewX", [a]) => Mat3 { a: 1.0, b: 0.0, c: a.to_radians().tan(), d: 1.0, e: 0.0, f: 0.0 },
|
||||
("skewY", [a]) => Mat3 { a: 1.0, b: a.to_radians().tan(), c: 0.0, d: 1.0, e: 0.0, f: 0.0 },
|
||||
_ => return None,
|
||||
};
|
||||
out = Some(match out {
|
||||
Some(prev) => prev.then_before(m),
|
||||
None => m,
|
||||
});
|
||||
rest = rest[close + 1..].trim_start_matches(|c: char| c == ',' || c.is_whitespace());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sanitize_layer(raw: &str) -> String {
|
||||
let cleaned: String =
|
||||
raw.trim().chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '$') { c } else { '_' }).take(128).collect();
|
||||
let cleaned = cleaned.trim_matches('_').to_string();
|
||||
if cleaned.is_empty() { "SVG".into() } else { cleaned }
|
||||
}
|
||||
|
||||
/// A scanner over an SVG number list: separators are whitespace and commas, and a sign
|
||||
/// starts a new number, so `10-20` is two numbers and `1e-5 2` is two numbers.
|
||||
struct Numbers<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Numbers<'a> {
|
||||
fn new(s: &'a str) -> Self {
|
||||
Numbers { s: s.as_bytes(), i: 0 }
|
||||
}
|
||||
|
||||
fn skip_seps(&mut self) {
|
||||
while matches!(self.s.get(self.i), Some(b' ' | b'\t' | b'\r' | b'\n' | b',')) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn next_num(&mut self) -> Option<f64> {
|
||||
self.skip_seps();
|
||||
let start = self.i;
|
||||
if matches!(self.s.get(self.i), Some(b'+' | b'-')) {
|
||||
self.i += 1;
|
||||
}
|
||||
let mut digits = 0;
|
||||
let mut dot = false;
|
||||
let mut exp = false;
|
||||
while let Some(c) = self.s.get(self.i) {
|
||||
match c {
|
||||
b'0'..=b'9' => {
|
||||
digits += 1;
|
||||
self.i += 1;
|
||||
}
|
||||
b'.' if !dot && !exp => {
|
||||
dot = true;
|
||||
self.i += 1;
|
||||
}
|
||||
b'e' | b'E' if !exp && digits > 0 => {
|
||||
exp = true;
|
||||
self.i += 1;
|
||||
if matches!(self.s.get(self.i), Some(b'+' | b'-')) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if digits == 0 {
|
||||
self.i = start;
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok()?.parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Numbers<'_> {
|
||||
type Item = f64;
|
||||
fn next(&mut self) -> Option<f64> {
|
||||
self.next_num()
|
||||
}
|
||||
}
|
||||
|
||||
/// One `M …` run of a `d` attribute.
|
||||
struct Subpath {
|
||||
start: Vec2,
|
||||
segs: Vec<Seg>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
enum Seg {
|
||||
Line(Vec2),
|
||||
Cubic(Vec2, Vec2, Vec2),
|
||||
Arc { rx: f64, ry: f64, rot: f64, large: bool, sweep: bool, end: Vec2 },
|
||||
}
|
||||
|
||||
/// Tokens of a `d` attribute: command letters and numbers, in order.
|
||||
enum Tok {
|
||||
Cmd(u8),
|
||||
Num(f64),
|
||||
}
|
||||
|
||||
struct Tokenizer<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Tokenizer<'a> {
|
||||
fn new(s: &'a str) -> Self {
|
||||
Tokenizer { s: s.as_bytes(), i: 0 }
|
||||
}
|
||||
|
||||
fn next_tok(&mut self) -> Option<Tok> {
|
||||
let mut nums = Numbers { s: self.s, i: self.i };
|
||||
nums.skip_seps();
|
||||
self.i = nums.i;
|
||||
let c = self.s.get(self.i).copied()?;
|
||||
if c.is_ascii_alphabetic() {
|
||||
self.i += 1;
|
||||
return Some(Tok::Cmd(c));
|
||||
}
|
||||
let tok = nums.next_num().map(Tok::Num);
|
||||
self.i = nums.i;
|
||||
tok
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `d` into subpaths, resolving relative commands and the S/T control reflections.
|
||||
fn parse_path(d: &str) -> Result<Vec<Subpath>> {
|
||||
let mut tk = Tokenizer::new(d);
|
||||
let mut lookahead: Option<Tok> = None;
|
||||
let mut cmd = 0u8;
|
||||
let mut cur = Vec2::ZERO;
|
||||
let mut start = Vec2::ZERO;
|
||||
let mut segs: Vec<Seg> = Vec::new();
|
||||
let mut out: Vec<Subpath> = Vec::new();
|
||||
let closed = false;
|
||||
let mut prev_cubic: Option<Vec2> = None;
|
||||
let mut prev_quad: Option<Vec2> = None;
|
||||
let mut segments = 0usize;
|
||||
|
||||
let next_num = |tk: &mut Tokenizer, look: &mut Option<Tok>| -> Result<f64> {
|
||||
match look.take() {
|
||||
Some(Tok::Num(v)) => Ok(v),
|
||||
Some(other @ Tok::Cmd(_)) => {
|
||||
*look = Some(other);
|
||||
Err(IoError::Format("SVG: path command is missing coordinates".into()))
|
||||
}
|
||||
None => match tk.next_tok() {
|
||||
Some(Tok::Num(v)) => Ok(v),
|
||||
Some(other @ Tok::Cmd(_)) => {
|
||||
*look = Some(other);
|
||||
Err(IoError::Format("SVG: path command is missing coordinates".into()))
|
||||
}
|
||||
None => Err(IoError::Format("SVG: path command is missing coordinates".into())),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(tok) = lookahead.take().or_else(|| tk.next_tok()) {
|
||||
let rel = match tok {
|
||||
Tok::Cmd(c) => {
|
||||
if c.eq_ignore_ascii_case(&b'z') {
|
||||
if !segs.is_empty() {
|
||||
out.push(Subpath { start, segs, closed: true });
|
||||
segs = Vec::new();
|
||||
}
|
||||
cur = start;
|
||||
cmd = 0;
|
||||
continue;
|
||||
}
|
||||
cmd = c;
|
||||
c.is_ascii_lowercase()
|
||||
}
|
||||
tok @ Tok::Num(_) => {
|
||||
if cmd == 0 {
|
||||
return Err(IoError::Format("SVG: path data does not start with a command".into()));
|
||||
}
|
||||
lookahead = Some(tok);
|
||||
cmd.is_ascii_lowercase()
|
||||
}
|
||||
};
|
||||
let abs = cmd.to_ascii_uppercase();
|
||||
let count = match abs {
|
||||
b'M' | b'L' => 2,
|
||||
b'H' | b'V' | b'T' => 1,
|
||||
b'C' => 6,
|
||||
b'S' | b'Q' => 4,
|
||||
b'A' => 7,
|
||||
_ => return Err(IoError::Format(format!("SVG: unknown path command `{}`", cmd as char))),
|
||||
};
|
||||
let mut args = [0.0f64; 7];
|
||||
for slot in args.iter_mut().take(count) {
|
||||
*slot = next_num(&mut tk, &mut lookahead)?;
|
||||
}
|
||||
let point = |x: f64, y: f64| if rel { cur + Vec2::new(x, y) } else { Vec2::new(x, y) };
|
||||
match abs {
|
||||
b'M' => {
|
||||
if !segs.is_empty() {
|
||||
out.push(Subpath { start, segs, closed: false });
|
||||
segs = Vec::new();
|
||||
}
|
||||
cur = point(args[0], args[1]);
|
||||
start = cur;
|
||||
cmd = if rel { b'l' } else { b'L' };
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'L' => {
|
||||
cur = point(args[0], args[1]);
|
||||
segs.push(Seg::Line(cur));
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'H' => {
|
||||
cur = Vec2::new(if rel { cur.x + args[0] } else { args[0] }, cur.y);
|
||||
segs.push(Seg::Line(cur));
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'V' => {
|
||||
cur = Vec2::new(cur.x, if rel { cur.y + args[0] } else { args[0] });
|
||||
segs.push(Seg::Line(cur));
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'C' => {
|
||||
let (c1, c2, p) = (point(args[0], args[1]), point(args[2], args[3]), point(args[4], args[5]));
|
||||
segs.push(Seg::Cubic(c1, c2, p));
|
||||
prev_cubic = Some(c2);
|
||||
prev_quad = None;
|
||||
cur = p;
|
||||
}
|
||||
b'S' => {
|
||||
let c1 = prev_cubic.map_or(cur, |c2| cur * 2.0 - c2);
|
||||
let (c2, p) = (point(args[0], args[1]), point(args[2], args[3]));
|
||||
segs.push(Seg::Cubic(c1, c2, p));
|
||||
prev_cubic = Some(c2);
|
||||
prev_quad = None;
|
||||
cur = p;
|
||||
}
|
||||
b'Q' => {
|
||||
let (q, p) = (point(args[0], args[1]), point(args[2], args[3]));
|
||||
segs.push(Seg::Cubic(cur + (q - cur) * (2.0 / 3.0), p + (q - p) * (2.0 / 3.0), p));
|
||||
prev_quad = Some(q);
|
||||
prev_cubic = None;
|
||||
cur = p;
|
||||
}
|
||||
b'T' => {
|
||||
let reflect = prev_quad.map_or(cur, |q| cur * 2.0 - q);
|
||||
let p = point(args[0], args[1]);
|
||||
segs.push(Seg::Cubic(cur + (reflect - cur) * (2.0 / 3.0), p + (reflect - p) * (2.0 / 3.0), p));
|
||||
prev_quad = Some(reflect);
|
||||
prev_cubic = None;
|
||||
cur = p;
|
||||
}
|
||||
b'A' => {
|
||||
let end = point(args[5], args[6]);
|
||||
segs.push(Seg::Arc { rx: args[0].abs(), ry: args[1].abs(), rot: args[2], large: args[3] != 0.0, sweep: args[4] != 0.0, end });
|
||||
cur = end;
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
_ => return Err(IoError::Format("SVG: unsupported path command".into())),
|
||||
}
|
||||
segments += 1;
|
||||
if segments > MAX_PATH_SEGMENTS {
|
||||
return Err(IoError::Format("SVG: path has too many segments".into()));
|
||||
}
|
||||
if segs.len() > MAX_CURVE_POINTS {
|
||||
return Err(IoError::Format("SVG: path has too many segments".into()));
|
||||
}
|
||||
}
|
||||
if !segs.is_empty() {
|
||||
out.push(Subpath { start, segs, closed });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The centre and signed sweep of a circular arc from `p0` to `p1` in a Y-up frame, per the
|
||||
/// SVG endpoint parameterisation (F.6.5). Radii that cannot span the chord are scaled up,
|
||||
/// as the specification requires.
|
||||
fn arc_center_circular(p0: Vec2, p1: Vec2, r: f64, large: bool, sweep: bool) -> Option<(Vec2, f64)> {
|
||||
if r <= 0.0 || !r.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let d = p1 - p0;
|
||||
let dist = d.x.hypot(d.y);
|
||||
let r = if dist > 2.0 * r { dist / 2.0 } else { r };
|
||||
if dist <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let mid = (p0 + p1) * 0.5;
|
||||
let h = (r * r - (dist * dist) / 4.0).max(0.0).sqrt();
|
||||
let n = Vec2::new(-d.y / dist, d.x / dist);
|
||||
let sign = if large != sweep { 1.0 } else { -1.0 };
|
||||
let center = mid + n * (h * sign);
|
||||
let a0 = (p0.y - center.y).atan2(p0.x - center.x);
|
||||
let a1 = (p1.y - center.y).atan2(p1.x - center.x);
|
||||
let ccw = (a1 - a0).rem_euclid(std::f64::consts::TAU);
|
||||
Some((center, pick_sweep(ccw, large, sweep)))
|
||||
}
|
||||
|
||||
/// The signed sweep (CCW positive) for a chord sweep `ccw` in `[0, 2π)`: the centre is
|
||||
/// already chosen so that the magnitude matches `large`, so only the direction is left.
|
||||
fn pick_sweep(ccw: f64, large: bool, sweep: bool) -> f64 {
|
||||
let tau = std::f64::consts::TAU;
|
||||
let eps = f64::EPSILON;
|
||||
if sweep {
|
||||
if ccw <= eps { if large { tau } else { eps } } else { ccw }
|
||||
} else if ccw >= tau - eps {
|
||||
if large { -tau } else { -eps }
|
||||
} else {
|
||||
ccw - tau
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten a general elliptical arc (any `rx`/`ry`/rotation) from `p0` to `p1` in user
|
||||
/// space, then map every sample through `m`.
|
||||
fn flatten_arc(m: &Mat3, p0: Vec2, p1: Vec2, rx: f64, ry: f64, rot_deg: f64, large: bool, sweep: bool) -> Result<Vec<Vec2>> {
|
||||
let (c, theta, delta) = elliptical_params(p0, p1, rx, ry, rot_deg.to_radians(), large, sweep)
|
||||
.ok_or_else(|| IoError::Format("SVG: elliptical arc cannot be converted".into()))?;
|
||||
let axes = Mat3::rotate(rot_deg.to_radians());
|
||||
let rmax = rx.max(ry);
|
||||
let tol = 0.05_f64;
|
||||
let steps = if rmax > tol {
|
||||
let step = 2.0 * (1.0 - (tol / rmax).min(1.0)).acos().max(1e-6);
|
||||
((delta.abs() / step).ceil() as usize).clamp(8, MAX_ARC_CHORDS)
|
||||
} else {
|
||||
8
|
||||
};
|
||||
let mut out = Vec::with_capacity(steps + 1);
|
||||
for k in 0..=steps {
|
||||
let t = theta + delta * (k as f64) / (steps as f64);
|
||||
let local = Vec2::new(rx * t.cos(), ry * t.sin());
|
||||
out.push(m.apply(c + axes.apply_vec(local)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Endpoint → centre parameterisation for a rotated elliptical arc (SVG F.6.5), in the
|
||||
/// frame the points are given in. Returns the centre, the start parameter and the signed
|
||||
/// sweep.
|
||||
fn elliptical_params(p0: Vec2, p1: Vec2, rx: f64, ry: f64, phi: f64, large: bool, sweep: bool) -> Option<(Vec2, f64, f64)> {
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (sphi, cphi) = phi.sin_cos();
|
||||
let dx = (p0.x - p1.x) / 2.0;
|
||||
let dy = (p0.y - p1.y) / 2.0;
|
||||
let x1 = cphi * dx + sphi * dy;
|
||||
let y1 = -sphi * dx + cphi * dy;
|
||||
let (rx, ry) = {
|
||||
let lambda = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry);
|
||||
if lambda > 1.0 {
|
||||
let s = lambda.sqrt();
|
||||
(rx * s, ry * s)
|
||||
} else {
|
||||
(rx, ry)
|
||||
}
|
||||
};
|
||||
let num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
|
||||
let den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
|
||||
let coef = if sweep != large { 1.0 } else { -1.0 } * (num.max(0.0) / den.max(f64::MIN_POSITIVE)).sqrt();
|
||||
let cxp = coef * rx * y1 / ry;
|
||||
let cyp = -coef * ry * x1 / rx;
|
||||
let center = Vec2::new(cphi * cxp - sphi * cyp + (p0.x + p1.x) / 2.0, sphi * cxp + cphi * cyp + (p0.y + p1.y) / 2.0);
|
||||
let angle = |ux: f64, uy: f64, vx: f64, vy: f64| {
|
||||
let dot = ux * vx + uy * vy;
|
||||
let det = ux * vy - uy * vx;
|
||||
let denom = (ux.hypot(uy) * vx.hypot(vy)).max(f64::MIN_POSITIVE);
|
||||
(dot / denom).clamp(-1.0, 1.0).acos().copysign(det)
|
||||
};
|
||||
let (ux, uy) = ((x1 - cxp) / rx, (y1 - cyp) / ry);
|
||||
let (vx, vy) = ((-x1 - cxp) / rx, (-y1 - cyp) / ry);
|
||||
let theta = angle(1.0, 0.0, ux, uy);
|
||||
let ccw = angle(ux, uy, vx, vy).rem_euclid(std::f64::consts::TAU);
|
||||
Some((center, theta, pick_sweep(ccw, large, sweep)))
|
||||
}
|
||||
+192
-1
@@ -1379,7 +1379,10 @@ fn mleader_is_written_as_multileader() {
|
||||
block: None,
|
||||
};
|
||||
d.add(&Space::Model, Default::default(), EntityKind::MLeader(m.clone())).unwrap();
|
||||
let back = roundtrip(&d);
|
||||
// MULTILEADER is a 2007 entity (R2000/2004 files get LEADER + MTEXT).
|
||||
let back =
|
||||
crate::read(&crate::write_with(&d, "x.dxf", &crate::SaveOptions { version: Some(crate::Version::R2007), binary: false }).unwrap(), "x.dxf")
|
||||
.unwrap();
|
||||
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MLeader(x) if *x == m)));
|
||||
assert!(!back.model.iter().any(|e| matches!(e.kind, EntityKind::Leader(_) | EntityKind::MText(_))));
|
||||
}
|
||||
@@ -1821,3 +1824,191 @@ fn mirrored_mtext_extrusion_keeps_its_footprint() {
|
||||
let b = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
|
||||
assert!(b.min.x > -0.2 && b.max.x > 0.5 && b.min.y > -0.2 && b.max.y > 0.5, "box right of and above (0,0): {b:?}");
|
||||
}
|
||||
|
||||
// --- SVG import ---
|
||||
|
||||
/// One 20mm×10mm page whose user units are millimetres; the reader flips Y, so a point at
|
||||
/// SVG y lands at `10 - y` in the drawing.
|
||||
const SVG_PAGE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10">"#;
|
||||
|
||||
fn kinds(d: &Drawing) -> Vec<&'static str> {
|
||||
d.model.iter().map(|e| e.kind.type_name()).collect()
|
||||
}
|
||||
|
||||
fn gzip_of(data: &[u8]) -> Vec<u8> {
|
||||
let mut crc: u32 = 0xffff_ffff;
|
||||
for b in data {
|
||||
crc ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
crc = (crc >> 1) ^ (0xedb8_8320u32 & 0u32.wrapping_sub(crc & 1));
|
||||
}
|
||||
}
|
||||
let mut out = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3];
|
||||
out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(data, 6));
|
||||
out.extend_from_slice(&(!crc).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_shapes_become_entities_in_millimetres() {
|
||||
let svg = format!(
|
||||
"{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/><circle cx=\"10\" cy=\"5\" r=\"2\"/>\
|
||||
<ellipse cx=\"10\" cy=\"5\" rx=\"4\" ry=\"2\"/><line x1=\"0\" y1=\"0\" x2=\"20\" y2=\"10\"/>\
|
||||
<polyline points=\"0,0 5,0 5,5\"/><polygon points=\"0,0 2,0 1,1\"/></svg>"
|
||||
);
|
||||
let d = read(svg.as_bytes(), "shapes.svg").unwrap();
|
||||
assert_eq!(kinds(&d), ["Polyline", "Circle", "Ellipse", "Line", "Polyline", "Polyline"]);
|
||||
let r = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
|
||||
assert!(r.closed);
|
||||
let got: Vec<Vec2> = r.vertices.iter().map(|v| v.p).collect();
|
||||
let want = [Vec2::new(1.0, 9.0), Vec2::new(5.0, 9.0), Vec2::new(5.0, 6.0), Vec2::new(1.0, 6.0)];
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
assert!((*g - *w).len() < 1e-9, "{g:?} vs {w:?}");
|
||||
}
|
||||
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
|
||||
assert!((c.radius - 2.0).abs() < 1e-9 && (c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
|
||||
let e = first(&d, |k| if let EntityKind::Ellipse(e) = k { Some(e.clone()) } else { None });
|
||||
let major = Vec2::new(e.major.x - e.center.x, e.major.y - e.center.y);
|
||||
assert!((major.len() - 4.0).abs() < 1e-9 && (e.ratio - 0.5).abs() < 1e-9, "{e:?}");
|
||||
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
|
||||
assert!((l.a.y - 10.0).abs() < 1e-9 && (l.b.y).abs() < 1e-9, "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_path_lines_curves_and_arcs() {
|
||||
let svg = format!("{SVG_PAGE}<path d=\"M1 1 L5 1 C6 1 7 2 8 3\"/><path d=\"M0 0 A5 5 0 0 1 10 0 Z\"/></svg>");
|
||||
let d = read(svg.as_bytes(), "paths.svg").unwrap();
|
||||
assert_eq!(kinds(&d), ["Polyline", "Spline", "Polyline"]);
|
||||
let line_run = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
|
||||
assert!(!line_run.closed && line_run.vertices.len() == 2, "{line_run:?}");
|
||||
let spline = first(&d, |k| if let EntityKind::Spline(s) = k { Some(s.clone()) } else { None });
|
||||
assert!(spline.is_valid(), "{spline:?}");
|
||||
let arc = d
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::LwPolyline(p) if p.vertices.first().is_some_and(|v| v.bulge != 0.0) => Some(p.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("an arc becomes a bulge vertex");
|
||||
assert!(arc.closed && arc.vertices.len() == 2, "{arc:?}");
|
||||
let bulge = arc.vertices[0].bulge;
|
||||
assert!((bulge.abs() - 1.0).abs() < 1e-6, "semicircle bulge is ±1, got {bulge}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_group_transforms_apply() {
|
||||
let svg = format!("{SVG_PAGE}<g transform=\"translate(5,2)\"><line x1=\"0\" y1=\"0\" x2=\"10\" y2=\"0\"/></g></svg>");
|
||||
let d = read(svg.as_bytes(), "t.svg").unwrap();
|
||||
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
|
||||
assert!((l.a - Vec3::new(5.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
|
||||
assert!((l.b - Vec3::new(15.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_viewbox_scaling_and_mm_units() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 200 100"><circle cx="100" cy="50" r="50"/></svg>"#;
|
||||
let d = read(svg.as_bytes(), "vb.svg").unwrap();
|
||||
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
|
||||
assert!((c.radius - 5.0).abs() < 1e-9, "{c:?}");
|
||||
assert!((c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_inkscape_layers_are_kept() {
|
||||
let svg = format!(
|
||||
"{SVG_PAGE}<g inkscape:groupmode=\"layer\" inkscape:label=\"Cut\"><rect x=\"0\" y=\"0\" width=\"2\" height=\"2\"/></g>\
|
||||
<g inkscape:groupmode=\"layer\" inkscape:label=\"Engrave\"><circle cx=\"5\" cy=\"5\" r=\"1\"/></g></svg>",
|
||||
SVG_PAGE = SVG_PAGE.replace("<svg ", "<svg xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\" ")
|
||||
);
|
||||
let d = read(svg.as_bytes(), "layers.svg").unwrap();
|
||||
let layers: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert!(layers.contains(&"Cut") && layers.contains(&"Engrave"), "{layers:?}");
|
||||
let per_layer = |name: &str| d.model.iter().filter(|e| e.common.layer == name).count();
|
||||
assert_eq!(per_layer("Cut"), 1);
|
||||
assert_eq!(per_layer("Engrave"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_hidden_and_defs_subtrees_are_skipped() {
|
||||
let svg = format!(
|
||||
"{SVG_PAGE}<defs><rect x=\"0\" y=\"0\" width=\"9\" height=\"9\"/></defs><text x=\"0\" y=\"0\">hi</text>\
|
||||
<rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" display=\"none\"/><rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" style=\"fill:red;display:none\"/>\
|
||||
<rect x=\"1\" y=\"1\" width=\"2\" height=\"2\"/></svg>"
|
||||
);
|
||||
let d = read(svg.as_bytes(), "skip.svg").unwrap();
|
||||
assert_eq!(kinds(&d), ["Polyline"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svgz_gunzips_to_the_same_drawing() {
|
||||
let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/></svg>");
|
||||
let plain = read(svg.as_bytes(), "a.svg").unwrap();
|
||||
let packed = read(&gzip_of(svg.as_bytes()), "a.svgz").unwrap();
|
||||
assert_eq!(kinds(&plain), kinds(&packed));
|
||||
assert_eq!(plain.model.iter().map(|e| &e.kind).collect::<Vec<_>>(), packed.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_read_dispatches_by_extension() {
|
||||
let svg = format!("{SVG_PAGE}<circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>");
|
||||
assert!(read(svg.as_bytes(), "a.svg").is_ok());
|
||||
assert!(read(&gzip_of(svg.as_bytes()), "a.svgz").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_hostile_input_errors_without_panicking() {
|
||||
let deep = format!("{SVG_PAGE}{}<line x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"/>{}</svg>", "<g>".repeat(200), "</g>".repeat(200));
|
||||
for (bytes, name) in [
|
||||
(b"not xml at all".to_vec(), "a.svg"),
|
||||
(Vec::new(), "a.svg"),
|
||||
(b"<svg><rect x=\"1e309\" width=\"NaN\" height=\"inf\"/></svg>".to_vec(), "a.svg"),
|
||||
(deep.clone().into_bytes(), "a.svg"),
|
||||
(b"<svg><path d=\"M0 0 X5 5\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><path d=\"M0 0 L\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><path d=\"M0 0 A1 1 0 5 1 2 2\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><polyline points=\"1 2 3\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><g transform=\"bogus(1)\"><rect width=\"1\" height=\"1\"/></g></svg>".to_vec(), "a.svg"),
|
||||
(vec![0x1f, 0x8b, 8, 0, b'j', b'u', b'n', b'k', 0, 0, 0, 0, 0, 0, 0, 0], "a.svgz"),
|
||||
(vec![0xff, 0xfe, 0x00, 0x01, 0x02, 0x03], "a.svg"),
|
||||
] {
|
||||
let _ = read(&bytes, name);
|
||||
}
|
||||
let deep = read(deep.as_bytes(), "a.svg");
|
||||
assert!(deep.is_err(), "200 nested groups are refused, not walked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_no_geometry_yields_an_empty_drawing() {
|
||||
let svg = format!("{SVG_PAGE}</svg>");
|
||||
let d = read(svg.as_bytes(), "empty.svg").unwrap();
|
||||
assert!(kinds(&d).is_empty());
|
||||
}
|
||||
|
||||
/// An application data group inside a kept XRECORD — the AcDs thumbnail schema's
|
||||
/// `{ATTRRECORD 341 h 2 name 280 1 291 1 ATTRRECORD}` — is written back as read. It used to be
|
||||
/// collapsed into a handle list: the handle came out under group 291 (a bool) and the name and
|
||||
/// flags were dropped, which strict DXF readers (ezdxf) reject outright.
|
||||
#[test]
|
||||
fn kept_xrecord_application_group_roundtrips_as_read() {
|
||||
let dxf = "0\nSECTION\n2\nOBJECTS\n\
|
||||
0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n3\nTD_SCHEMA\n350\n20\n\
|
||||
0\nXRECORD\n5\n20\n330\nC\n100\nAcDbXrecord\n280\n1\n1\nAcDb_Thumbnail_Schema\n\
|
||||
102\n{ATTRRECORD\n341\n21\n2\nAcDbDs::TreatedAsObjectData\n280\n1\n291\n1\n102\nATTRRECORD}\n\
|
||||
0\nXRECORD\n5\n21\n330\n20\n100\nAcDbXrecord\n280\n1\n1\nTarget\n\
|
||||
0\nENDSEC\n0\nEOF\n";
|
||||
let d = read_dxf(dxf.as_bytes()).expect("read");
|
||||
let out = write_dxf(&d);
|
||||
let lines: Vec<&str> = out.lines().map(str::trim).collect();
|
||||
let at = lines.iter().position(|l| *l == "{ATTRRECORD").expect("group kept");
|
||||
// The group keeps its own shape: a reference under 341, then the name, flag and bool.
|
||||
assert_eq!(lines[at + 1], "341", "reference group code: {:?}", &lines[at..at + 12]);
|
||||
assert!(lines[at + 2] != "0" && !lines[at + 2].is_empty());
|
||||
assert_eq!(&lines[at + 3..at + 5], &["2", "AcDbDs::TreatedAsObjectData"]);
|
||||
assert_eq!(&lines[at + 5..at + 7], &["280", "1"]);
|
||||
assert_eq!(&lines[at + 7..at + 9], &["291", "1"]);
|
||||
assert_eq!(&lines[at + 9..at + 11], &["102", "ATTRRECORD}"]);
|
||||
// And no handle is written under a bool code anywhere.
|
||||
assert!(!out.contains("291\n2"), "no handle may be written under a bool code");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! File-format follow-ups (#626): MULTILEADER only from 2007 (older files get LEADER + MTEXT /
|
||||
//! INSERT, DXF and DWG), post-R2000 DIMSTYLE variables as table groups from 2004, `$DWGCODEPAGE`
|
||||
//! kept as read in pre-2007 files, and named-dictionary entries the writer writes itself not
|
||||
//! kept a second time.
|
||||
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use cadcraft_io::{SaveOptions, Version};
|
||||
|
||||
fn save(d: &Drawing, name: &str, v: Version) -> Vec<u8> {
|
||||
cadcraft_io::write_with(d, name, &SaveOptions { version: Some(v), binary: false }).unwrap()
|
||||
}
|
||||
|
||||
fn dxf(d: &Drawing, v: Version) -> String {
|
||||
String::from_utf8(save(d, "x.dxf", v)).unwrap()
|
||||
}
|
||||
|
||||
fn lines(text: &str) -> Vec<&str> {
|
||||
text.lines().map(str::trim).collect()
|
||||
}
|
||||
|
||||
/// Number of records of `kind` (`0` / `kind` pairs) in DXF text.
|
||||
fn records(text: &str, kind: &str) -> usize {
|
||||
lines(text).chunks(2).filter(|p| *p == ["0", kind]).count()
|
||||
}
|
||||
|
||||
fn count(d: &Drawing, f: impl Fn(&EntityKind) -> bool) -> usize {
|
||||
d.model.iter().filter(|e| f(&e.kind)).count()
|
||||
}
|
||||
|
||||
fn mleader_drawing() -> (Drawing, MLeader) {
|
||||
let mut d = Drawing::new_metric();
|
||||
let m = MLeader {
|
||||
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 3.0, 0.0)], vec![Vec3::new(1.0, -2.0, 0.0)]],
|
||||
landing: Vec3::new(5.0, 4.0, 0.0),
|
||||
dogleg: 0.75,
|
||||
text: Some(MText {
|
||||
insert: Vec3::new(5.9, 4.0, 0.0),
|
||||
height: 0.4,
|
||||
width: 6.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: "NOTE".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}),
|
||||
style: "Standard".into(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
};
|
||||
d.add(&Space::Model, Common { layer: "0".into(), ..Common::default() }, EntityKind::MLeader(m.clone())).unwrap();
|
||||
(d, m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multileader_before_2007_is_leader_and_mtext() {
|
||||
let (d, _) = mleader_drawing();
|
||||
for v in [Version::R2000, Version::R2004] {
|
||||
let text = dxf(&d, v);
|
||||
assert_eq!(records(&text, "MULTILEADER"), 0, "{v:?}: no MULTILEADER entity");
|
||||
assert!(!text.contains("AcDbMLeader\n"), "{v:?}: no MULTILEADER class");
|
||||
assert_eq!(records(&text, "LEADER"), 2, "{v:?}: one LEADER per leader line");
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
assert_eq!(count(&back, |k| matches!(k, EntityKind::MLeader(_))), 0);
|
||||
assert_eq!(count(&back, |k| matches!(k, EntityKind::Leader(_))), 2);
|
||||
assert_eq!(count(&back, |k| matches!(k, EntityKind::MText(t) if t.contents == "NOTE")), 1);
|
||||
// The leader lines end at the landing.
|
||||
assert!(back.model.iter().all(|e| match &e.kind {
|
||||
EntityKind::Leader(l) => l.vertices.last() == Some(&Vec3::new(5.0, 4.0, 0.0)),
|
||||
_ => true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multileader_from_2007_is_native() {
|
||||
let (d, m) = mleader_drawing();
|
||||
for v in [Version::R2007, Version::R2018] {
|
||||
let text = dxf(&d, v);
|
||||
assert_eq!(records(&text, "MULTILEADER"), 1, "{v:?}");
|
||||
assert_eq!(records(&text, "LEADER"), 0, "{v:?}");
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
let got: Vec<&MLeader> = back.model.iter().filter_map(|e| if let EntityKind::MLeader(m) = &e.kind { Some(m) } else { None }).collect();
|
||||
assert_eq!(got, vec![&m], "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multileader_in_dwg_follows_the_dwg_version() {
|
||||
let (d, _) = mleader_drawing();
|
||||
let old = cadcraft_io::read(&save(&d, "x.dwg", Version::R2004), "x.dwg").unwrap();
|
||||
assert_eq!(count(&old, |k| matches!(k, EntityKind::MLeader(_))), 0, "2004 DWG has no MULTILEADER");
|
||||
assert_eq!(count(&old, |k| matches!(k, EntityKind::Leader(_))), 2);
|
||||
assert_eq!(count(&old, |k| matches!(k, EntityKind::MText(_))), 1);
|
||||
let new = cadcraft_io::read(&save(&d, "x.dwg", Version::R2018), "x.dwg").unwrap();
|
||||
assert_eq!(count(&new, |k| matches!(k, EntityKind::Leader(_))), 0, "2018 DWG keeps the multileader whole");
|
||||
}
|
||||
|
||||
fn dim_drawing() -> (Drawing, DimStyle) {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.linetypes.push(Linetype::simple("DASHED", "Dashed", &[0.5, -0.25]));
|
||||
d.linetypes.push(Linetype::simple("CENTER", "Center", &[1.25, -0.25, 0.25, -0.25]));
|
||||
let st = DimStyle {
|
||||
name: "Detail".into(),
|
||||
arc_symbol: 1,
|
||||
dim_linetype: "DASHED".into(),
|
||||
ext_linetype1: "CENTER".into(),
|
||||
ext_linetype2: "DASHED".into(),
|
||||
fixed_ext: true,
|
||||
fixed_ext_length: 0.75,
|
||||
jog_angle: 30.0,
|
||||
..DimStyle::default()
|
||||
};
|
||||
d.dim_styles.push(st.clone());
|
||||
(d, st)
|
||||
}
|
||||
|
||||
/// The DIMSTYLE record named `name`.
|
||||
fn dimstyle_record<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
|
||||
let l = lines(text);
|
||||
let at = l.windows(2).position(|w| w == ["2", name]).unwrap();
|
||||
let end = l[at..].windows(2).position(|w| w == ["0", "DIMSTYLE"] || w == ["0", "ENDTAB"]).map_or(l.len(), |e| at + e);
|
||||
l[at..end].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_dimstyle_variables_are_table_groups_from_2004() {
|
||||
let (d, st) = dim_drawing();
|
||||
let r2000 = dxf(&d, Version::R2000);
|
||||
let rec = dimstyle_record(&r2000, "Detail");
|
||||
assert!(rec.windows(4).any(|w| w == ["1001", "CADCRAFT", "1000", "DIMVARS"]), "R2000: xdata");
|
||||
assert!(!rec.chunks(2).any(|p| p[0] == "345"), "R2000: no DIMLTYPE group");
|
||||
for v in [Version::R2004, Version::R2018] {
|
||||
let text = dxf(&d, v);
|
||||
let rec = dimstyle_record(&text, "Detail");
|
||||
assert!(!rec.contains(&"DIMVARS"), "{v:?}: no DIMVARS xdata");
|
||||
for code in ["49", "50", "90", "290", "345", "346", "347"] {
|
||||
assert!(rec.chunks(2).any(|p| p[0] == code), "{v:?}: group {code}");
|
||||
}
|
||||
let jog = rec.chunks(2).find(|p| p[0] == "50").unwrap()[1].parse::<f64>().unwrap();
|
||||
assert!((jog - 30f64.to_radians()).abs() < 1e-9, "DIMJOGANG in radians: {jog}");
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.dim_style("Detail").unwrap(), &st, "{v:?}");
|
||||
}
|
||||
assert_eq!(cadcraft_io::read_dxf(r2000.as_bytes()).unwrap().dim_style("Detail").unwrap(), &st);
|
||||
}
|
||||
|
||||
fn code_page(text: &str) -> &str {
|
||||
let l = lines(text);
|
||||
let at = l.iter().position(|s| *s == "$DWGCODEPAGE").unwrap();
|
||||
l[at + 2]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_page_is_kept_before_2007() {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.header.set_str("DWGCODEPAGE", "ANSI_1251");
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::Text(Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 2.5,
|
||||
value: "Привет".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
for v in [Version::R2000, Version::R2004] {
|
||||
let bytes = save(&d, "x.dxf", v);
|
||||
assert!(bytes.is_ascii(), "{v:?}: ASCII with escapes");
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert_eq!(code_page(&text), "ANSI_1251", "{v:?}");
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.header.str("DWGCODEPAGE", ""), "ANSI_1251");
|
||||
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.value == "Привет")));
|
||||
}
|
||||
assert_eq!(code_page(&dxf(&d, Version::R2018)), "ANSI_1252", "2007+: UTF-8 text");
|
||||
// Without one, or with one CADCraft doesn't know: ANSI_1252.
|
||||
d.header.set_str("DWGCODEPAGE", "NO_SUCH\n0\nEOF");
|
||||
assert_eq!(code_page(&dxf(&d, Version::R2000)), "ANSI_1252");
|
||||
assert_eq!(code_page(&dxf(&Drawing::new_metric(), Version::R2000)), "ANSI_1252");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modelled_dictionaries_are_not_kept_twice() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let wall = MLineStyle { name: "Wall".into(), ..MLineStyle::default() };
|
||||
d.mline_styles.push(wall.clone());
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::MLine(MLine::new(&wall, &[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)], false, MLineJust::Zero, 1.0)),
|
||||
)
|
||||
.unwrap();
|
||||
let first = dxf(&d, Version::R2018);
|
||||
let read = cadcraft_io::read_dxf(first.as_bytes()).unwrap();
|
||||
let kept: Vec<&str> = read.foreign.objects.iter().map(|o| o.kind.as_str()).collect();
|
||||
assert!(kept.is_empty(), "nothing CADCraft writes itself is kept raw: {kept:?}");
|
||||
let again = dxf(&read, Version::R2018);
|
||||
let third = dxf(&cadcraft_io::read_dxf(again.as_bytes()).unwrap(), Version::R2018);
|
||||
for kind in ["MLINESTYLE", "SCALE", "MLEADERSTYLE", "TABLESTYLE", "DICTIONARY", "XRECORD", "GROUP", "LAYOUT"] {
|
||||
assert_eq!(records(&again, kind), records(&first, kind), "{kind} after one round trip");
|
||||
assert_eq!(records(&third, kind), records(&first, kind), "{kind} after two");
|
||||
}
|
||||
let l = lines(&again);
|
||||
for key in ["ACAD_MLINESTYLE", "ACAD_SCALELIST", "ACAD_GROUP", "ACAD_MLEADERSTYLE", "ACAD_PLOTSTYLENAME", "CADCRAFT_HANDSEED"] {
|
||||
assert_eq!(l.windows(2).filter(|w| *w == ["3", key]).count(), 1, "{key} once in the named object dictionary");
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,27 @@ fn custom_pattern_lines_roundtrip_scaled_and_rotated() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_patterns_keep_no_definition() {
|
||||
fn library_named_patterns_keep_the_files_definition() {
|
||||
// The library's AR-CONC only approximates AutoCAD's: the file's own lines are kept and saved.
|
||||
let h = first_hatch(&save(hatch("AR-CONC", 2.0, 0.0, Vec2::ZERO, custom_lines())));
|
||||
assert!(near(&h.pattern_lines, &custom_lines()), "{:?}", h.pattern_lines);
|
||||
// A hatch without lines of its own saves the library's definition, which reads back as lines.
|
||||
let h = first_hatch(&save(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, vec![])));
|
||||
assert!(h.pattern_lines.is_empty());
|
||||
assert_eq!(h.pattern_lines.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_definition_is_drawn_instead_of_the_librarys() {
|
||||
let prims = |lines: Vec<PatternLine>| {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, lines))).expect("add");
|
||||
cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).prims.len()
|
||||
};
|
||||
// One horizontal family 5 units apart from y = 2.5 across the 10 × 10 square: two lines.
|
||||
let sparse = vec![PatternLine { angle: 0.0, origin: (0.0, 2.5), delta: (0.0, 5.0), dashes: vec![] }];
|
||||
assert_eq!(prims(sparse), 2);
|
||||
// Without file lines, the library's ANSI31 (0.125 apart) is drawn.
|
||||
assert!(prims(vec![]) > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
/// Saved as 2007 DXF: older versions have no MULTILEADER (they get LEADER + MTEXT / INSERT).
|
||||
fn roundtrip(d: &Drawing) -> (String, Drawing) {
|
||||
let text = cadcraft_io::write_dxf(d);
|
||||
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2007), binary: false };
|
||||
let text = String::from_utf8(cadcraft_io::write_with(d, "x.dxf", &opts).unwrap()).unwrap();
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
(text, back)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Plot follow-ups (#626): model-space plots use the plot device's paper margins, PNG and SVG
|
||||
//! plots draw plot style ends, joins and fills as PDF does, and hyperlinks a drawing file gives
|
||||
//! its entities (`PE_URL` extended data) become PDF link annotations.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EndStyle, EntityKind, Line, PlotStyle, PlotStyleTable, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{plotter, read_dxf, write_dxf};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) {
|
||||
let k = EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) });
|
||||
d.add(&Space::Model, Common::default(), k).unwrap();
|
||||
}
|
||||
|
||||
fn pdf_text(d: &Drawing, opts: Value) -> String {
|
||||
let mut o = json!({"compress": false});
|
||||
if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) {
|
||||
m.extend(e.clone());
|
||||
}
|
||||
String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// The plot area's clip rectangle (`x y w h re W n`) of the first page.
|
||||
fn clip(pdf: &str) -> Vec<f64> {
|
||||
let line = pdf.lines().find(|l| l.ends_with(" re W n")).unwrap_or("");
|
||||
line.split_whitespace().take(4).filter_map(|t| t.parse().ok()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_space_pdf_uses_the_device_margins() {
|
||||
let mut d = Drawing::new_metric();
|
||||
line(&mut d, (0.0, 0.0), (100.0, 50.0));
|
||||
let mm = 72.0 / 25.4;
|
||||
// No device: the page setup's printable area, as before.
|
||||
let plain = clip(&pdf_text(&d, json!({})));
|
||||
assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}");
|
||||
// A device paper with 20 mm margins: the printable area starts 20 mm in.
|
||||
let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]})));
|
||||
assert_eq!(r.len(), 4, "{r:?}");
|
||||
assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}");
|
||||
let (w, h) = (r[2].min(r[3]), r[2].max(r[3]));
|
||||
assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}");
|
||||
// Hostile margins are ignored.
|
||||
for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] {
|
||||
assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing with two lines in a 2 mm wide style with round ends, and a plot style table holding it.
|
||||
fn styled() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
line(&mut d, (0.0, 0.0), (100.0, 0.0));
|
||||
line(&mut d, (0.0, 40.0), (100.0, 60.0));
|
||||
let round = PlotStyle { name: "Round".into(), lineweight: Some(200), end: EndStyle::Round, ..PlotStyle::default() };
|
||||
d.plot_style_tables.push(PlotStyleTable::named("round.stb", "", vec![round]));
|
||||
let hs: Vec<_> = d.model.iter().map(|e| e.handle).collect();
|
||||
for h in hs {
|
||||
d.modify_entity(h, |e| e.common.plot_style = "Round".into()).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_and_svg_plots_draw_plot_style_ends() {
|
||||
let d = styled();
|
||||
let png = |table: &str| plotter::plot(&d, &Space::Model, &json!({"output": "png", "resolution": 72, "plotStyleTable": table})).unwrap();
|
||||
assert_ne!(png("round.stb"), png("None"), "round ends change the image");
|
||||
|
||||
let svg = |table: &str| String::from_utf8(plotter::plot(&d, &Space::Model, &json!({"output": "svg", "plotStyleTable": table})).unwrap()).unwrap();
|
||||
let plain = svg("None");
|
||||
assert!(plain.contains("<polyline") && !plain.contains("<path"), "{plain}");
|
||||
let round = svg("round.stb");
|
||||
assert!(round.contains("<path") && !round.contains("<polyline"), "wide lines with round ends are outlines: {round}");
|
||||
}
|
||||
|
||||
/// DXF text of `d` with `PE_URL` extended data on each LINE, one URL per line in order.
|
||||
fn with_pe_urls(d: &Drawing, urls: &[&str]) -> String {
|
||||
let text = write_dxf(d);
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut urls = urls.iter();
|
||||
let mut in_line = false;
|
||||
// Extended data goes last in an entity, before the next `0` group.
|
||||
for pair in lines.chunks(2) {
|
||||
let [code, value] = pair else { continue };
|
||||
if *code == "0" {
|
||||
if in_line && let Some(url) = urls.next() {
|
||||
for (c, v) in [("1001", "PE_URL"), ("1000", url), ("1002", "{"), ("1000", "A link"), ("1002", "}")] {
|
||||
out.push(c.to_string());
|
||||
out.push(v.to_string());
|
||||
}
|
||||
}
|
||||
in_line = *value == "LINE";
|
||||
}
|
||||
out.push(code.to_string());
|
||||
out.push(value.to_string());
|
||||
}
|
||||
out.join("\n") + "\n"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pe_url_hyperlinks_become_link_annotations() {
|
||||
let mut d = Drawing::new_metric();
|
||||
line(&mut d, (0.0, 0.0), (100.0, 0.0));
|
||||
line(&mut d, (0.0, 20.0), (100.0, 20.0));
|
||||
line(&mut d, (0.0, 40.0), (100.0, 40.0));
|
||||
let text = with_pe_urls(&d, &["https://example.com/plan", "javascript:alert(1)", "data:text/html,x"]);
|
||||
assert_eq!(text.matches("PE_URL").count(), 3, "the test drawing carries the links");
|
||||
let read = read_dxf(text.as_bytes()).unwrap();
|
||||
let pdf = pdf_text(&read, json!({}));
|
||||
assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "only the web link: {pdf}");
|
||||
assert!(pdf.contains("https://example.com/plan"));
|
||||
assert!(!pdf.contains("javascript") && !pdf.contains("data:text"));
|
||||
// Turned off, and given explicitly for the same object (the explicit one wins).
|
||||
assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0);
|
||||
let first = read.model.iter().next().unwrap().handle.hex();
|
||||
let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]}));
|
||||
assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}");
|
||||
}
|
||||
@@ -878,6 +878,21 @@ fn saving_twice_writes_the_same_records() {
|
||||
assert_eq!(a.counts(), b.counts());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_records_have_instance_counts_for_generated_and_preserved_records() {
|
||||
let (_, a, b) = saves(&fixture());
|
||||
for out in [&a, &b] {
|
||||
let counts = out.counts();
|
||||
for class in out.in_section("CLASSES", "CLASS") {
|
||||
let name = s(class, 1);
|
||||
let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64);
|
||||
assert_eq!(instance_count, counts.get(&name).map(|n| *n as i64), "CLASS {name}");
|
||||
}
|
||||
assert!(out.in_section("CLASSES", "CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT"));
|
||||
assert_eq!(counts.get("ACME_OBJECT"), Some(&1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erased_entities_take_their_extension_data_with_them() {
|
||||
let mut d = read_dxf(fixture().as_bytes()).expect("read");
|
||||
|
||||
@@ -73,7 +73,11 @@ fn multileader_styles_and_current_style_round_trip() {
|
||||
};
|
||||
d.mleader_styles.push(callout);
|
||||
d.header.set_str("CMLEADERSTYLE", "Callout");
|
||||
let r = round_trip(&d);
|
||||
let text = write_dxf(&d);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let header = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
|
||||
assert!(header.tags.iter().all(|t| t.code != 9 || t.str() != "$CMLEADERSTYLE"));
|
||||
let r = read_dxf(text.as_bytes()).expect("reopen");
|
||||
assert_eq!(r.mleader_styles, d.mleader_styles);
|
||||
assert_eq!(r.header.str("CMLEADERSTYLE", ""), "Callout");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
lyon_tessellation = { workspace = true }
|
||||
|
||||
# Display lists of large drawings are built on all cores (not on the web, which has no threads).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
+303
-70
@@ -1,11 +1,17 @@
|
||||
//! Hatch drawing: island detection picks the boundary loops that take part, then a solid,
|
||||
//! gradient or background fill (even-odd triangles) and the pattern lines (each pattern line
|
||||
//! family swept across the boundary, even-odd clipped, then dashed).
|
||||
//!
|
||||
//! On-screen builds get a level of detail (`Options::min_dash` > 0 is two pixels, `Options::view`
|
||||
//! the area being looked at): a pattern whose lines run together on screen is drawn as a solid
|
||||
//! fill of its boundary ([`reads_solid`]), a dash or dot repeat under two pixels as a continuous
|
||||
//! line (as linetypes already are), and nothing is generated outside the view. Plots, exports and
|
||||
//! picks pass `min_dash = 0` and `view = None` and get every line.
|
||||
|
||||
use super::{Builder, Ctx, Ink, fill, ink};
|
||||
use super::{Builder, Ctx, Ink, fill, ink, max_scale};
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::library::pattern;
|
||||
use cadcraft_doc::{Entity, Gradient, Hatch};
|
||||
use cadcraft_doc::library::{PatternLine, pattern_for};
|
||||
use cadcraft_doc::{Drawing, Entity, Gradient, Hatch};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
|
||||
/// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance.
|
||||
@@ -14,7 +20,12 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink,
|
||||
// Gradient and background colours given ByLayer follow the hatch's own layer.
|
||||
let layer = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
|
||||
let layer_color = ctx.layer_color(layer).unwrap_or(Color::Index(7));
|
||||
if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() {
|
||||
// Level of detail: a pixel in the hatch's own units, along its most stretched axis (so lines
|
||||
// that show apart in any direction are never merged); 0 = full detail.
|
||||
let stretch = max_scale(&ctx.xf);
|
||||
let px = if stretch > 1e-12 { b.opts.min_dash / 2.0 / stretch } else { 0.0 };
|
||||
let dense = b.opts.fill && reads_solid(ctx.d, h, px);
|
||||
if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() || dense {
|
||||
if b.opts.fill {
|
||||
let tris = fill::triangulate_evenodd(&loops);
|
||||
match &h.gradient {
|
||||
@@ -29,7 +40,12 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink,
|
||||
{
|
||||
b.tris(ctx, ink(bg, layer_color, ctx.block_color), &fill::triangulate_evenodd(&loops));
|
||||
}
|
||||
for seg in pattern_lines(h, &loops) {
|
||||
// The area being looked at, in the hatch's units.
|
||||
let view = b.opts.view.and_then(|v| {
|
||||
let inv = ctx.xf.inverse()?;
|
||||
Some(Bounds2::from_points(v.corners().iter().map(|p| inv.apply(*p))))
|
||||
});
|
||||
for seg in pattern_lines(ctx.d, h, &loops, px, view.as_ref()) {
|
||||
if seg.len() == 1 {
|
||||
if let Some(p) = seg.first() {
|
||||
b.point(ctx, rgb, *p);
|
||||
@@ -44,12 +60,39 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink,
|
||||
/// every loop with every other); past it all loops are kept (odd parity).
|
||||
const MAX_ISLAND_WORK: usize = 20_000_000;
|
||||
|
||||
/// True when two polygons have the same vertices (ignoring cyclic shift and winding order).
|
||||
fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let n = a.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
let near = |p1: Vec2, p2: Vec2| p1.near(p2, 1e-4);
|
||||
(0..n).any(|k| (0..n).all(|i| near(a[i], b[(i + k) % n]))) || (0..n).any(|k| (0..n).all(|i| near(a[i], b[(k + n - (i % n)) % n])))
|
||||
}
|
||||
|
||||
fn dedup_polygons(loops: &mut Vec<Vec<Vec2>>) {
|
||||
let mut unique: Vec<Vec<Vec2>> = Vec::with_capacity(loops.len());
|
||||
for l in loops.drain(..) {
|
||||
if l.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
if !unique.iter().any(|u| same_polygon(u, &l)) {
|
||||
unique.push(l);
|
||||
}
|
||||
}
|
||||
*loops = unique;
|
||||
}
|
||||
|
||||
/// The tessellated boundary loops that take part in the fill under the hatch's island
|
||||
/// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands
|
||||
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
|
||||
/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop.
|
||||
pub(crate) fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
|
||||
let mut loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
|
||||
dedup_polygons(&mut loops);
|
||||
let max_depth = match h.style {
|
||||
1 => 1,
|
||||
2 => 0,
|
||||
@@ -208,16 +251,86 @@ impl Field {
|
||||
|
||||
/// Upper bound on the line families drawn from a pattern definition read from a file.
|
||||
const MAX_PATTERN_LINES: usize = 1024;
|
||||
/// Upper bound on the runs of one hatch (AutoCAD's HPMAXLINES default), checked as runs are made
|
||||
/// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the
|
||||
/// runs of a full build, so it never leaves out runs that a full build draws.
|
||||
const MAX_RUNS: usize = 1_000_000;
|
||||
/// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left
|
||||
/// out (a full-detail build of a pattern far too fine for its area).
|
||||
const MAX_FAMILY_LINES: i64 = 20_000;
|
||||
/// The same bound when the sweep is limited to a view: the count then follows the view's size
|
||||
/// on screen, and lines closer than a pixel already read as solid, so only very large displays
|
||||
/// come near it.
|
||||
const MAX_VIEW_FAMILY_LINES: i64 = 100_000;
|
||||
/// Line families closer than this on screen (pixels) run together: the hatch reads as solid.
|
||||
pub const SOLID_SPACING_PX: f64 = 1.0;
|
||||
/// Average marks per pixel from which a pattern reads as solid (scattered marks leave about
|
||||
/// e^-2 ≈ 14% of the pixels empty there).
|
||||
pub const SOLID_COVERAGE: f64 = 2.0;
|
||||
|
||||
/// The line families a hatch draws with: the lines its file defined (as its author's CAD drew
|
||||
/// them), else the library's pattern of that name as drawn in `d`, else ANSI31.
|
||||
fn lines_of(d: &Drawing, h: &Hatch) -> Vec<PatternLine> {
|
||||
if h.pattern_lines.is_empty() {
|
||||
pattern_for(d, &h.pattern).or_else(|| pattern_for(d, "ANSI31")).map(|p| p.lines).unwrap_or_default()
|
||||
} else {
|
||||
h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The hatch's pattern scale (1 when unusable).
|
||||
fn scale_of(h: &Hatch) -> f64 {
|
||||
if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 }
|
||||
}
|
||||
|
||||
/// True when, at `px` hatch units per pixel, the pattern covers its area on screen, so drawing
|
||||
/// it line by line would fill (nearly) every pixel anyway. Each line, dash or dot is drawn at
|
||||
/// least a pixel wide, so that is the case when
|
||||
/// - some line family has its lines closer than [`SOLID_SPACING_PX`] and no gap along them a
|
||||
/// pixel or longer (continuous lines, or a repeat that reads as a continuous line), or
|
||||
/// - the families together put on average [`SOLID_COVERAGE`] or more marks on every pixel
|
||||
/// (dense dot or dash patterns made of several families).
|
||||
///
|
||||
/// A sparse dot or dash pattern keeps its look. `px <= 0` (full detail) is never solid.
|
||||
pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
|
||||
if !(px.is_finite() && px > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let scale = scale_of(h);
|
||||
let mut coverage = 0.0;
|
||||
for f in lines_of(d, h) {
|
||||
let spacing = (f.delta.1 * scale).abs();
|
||||
if !(spacing.is_finite() && spacing > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let pat_len: f64 = f.dashes.iter().map(|d| (d * scale).abs()).sum();
|
||||
// The share of a line inked on screen (a dash or dot is at least a pixel long there).
|
||||
let ink: f64 = f.dashes.iter().filter(|d| **d >= 0.0).map(|d| (d * scale).max(px)).sum();
|
||||
let inked = if f.dashes.is_empty() || (ink > 0.0 && pat_len < 2.0 * px) {
|
||||
1.0
|
||||
} else if pat_len > 0.0 {
|
||||
(ink / pat_len).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let gap = f.dashes.iter().filter(|d| **d < 0.0).map(|d| (d * scale).abs()).fold(0.0, f64::max);
|
||||
let draws = f.dashes.is_empty() || ink > 0.0;
|
||||
if spacing < SOLID_SPACING_PX * px && draws && (inked >= 1.0 || gap < px) {
|
||||
return true;
|
||||
}
|
||||
coverage += inked * px / spacing;
|
||||
}
|
||||
coverage.is_finite() && coverage >= SOLID_COVERAGE
|
||||
}
|
||||
|
||||
/// Hatch line runs in world coordinates (single-point runs are dots) for the fill `loops`.
|
||||
pub fn pattern_lines(h: &Hatch, loops: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
|
||||
// A pattern the library doesn't define draws with the lines its file defined, else as ANSI31.
|
||||
let lines = match pattern(&h.pattern) {
|
||||
Some(p) => p.lines,
|
||||
None if !h.pattern_lines.is_empty() => h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect(),
|
||||
None => pattern("ANSI31").map(|p| p.lines).unwrap_or_default(),
|
||||
};
|
||||
let scale = if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 };
|
||||
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
|
||||
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
|
||||
/// the part of the hatch inside it is generated; `None` = all of it.
|
||||
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
|
||||
let lines = lines_of(d, h);
|
||||
let scale = scale_of(h);
|
||||
let px = if px.is_finite() && px > 0.0 { px } else { 0.0 };
|
||||
let mut out = Vec::new();
|
||||
for fam in &lines {
|
||||
let ang = fam.angle.to_radians() + h.angle;
|
||||
@@ -228,76 +341,196 @@ pub fn pattern_lines(h: &Hatch, loops: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
|
||||
continue;
|
||||
}
|
||||
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
|
||||
family(loops, origin, ang, delta, spacing, &dashes, &mut out);
|
||||
if out.len() > 500_000 {
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, &mut out);
|
||||
if out.len() >= MAX_RUNS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn family(loops: &[Vec<Vec2>], origin: Vec2, ang: f64, delta: Vec2, spacing: f64, dashes: &[f64], out: &mut Vec<Vec<Vec2>>) {
|
||||
// Work in a frame where the family's lines are horizontal.
|
||||
let to_local = |p: Vec2| (p - origin).rotate(-ang);
|
||||
let to_world = |p: Vec2| origin + p.rotate(ang);
|
||||
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| to_local(*p)).collect()).collect();
|
||||
let bb = Bounds2::from_points(local.iter().flatten().copied());
|
||||
if bb.is_empty() {
|
||||
return;
|
||||
/// Repeats of a pattern across a swatch ([`pattern_swatch`]).
|
||||
const SWATCH_REPEATS: f64 = 6.0;
|
||||
/// Most runs a swatch holds.
|
||||
const MAX_SWATCH_RUNS: usize = 4000;
|
||||
|
||||
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
|
||||
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
|
||||
/// pattern (each family's line spacing or dash repeat, whichever is longer). Empty for a pattern
|
||||
/// with no lines (SOLID).
|
||||
pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
|
||||
let repeat =
|
||||
lines.iter().map(|l| l.delta.1.abs().max(l.dashes.iter().map(|d| d.abs()).sum::<f64>())).filter(|r| r.is_finite()).fold(0.0, f64::max);
|
||||
if !side.is_finite() || side <= 0.0 || repeat < 1e-9 {
|
||||
return Vec::new();
|
||||
}
|
||||
let shift_per_line = delta.rotate(-ang).x;
|
||||
let k0 = (bb.min.y / spacing).floor() as i64;
|
||||
let k1 = (bb.max.y / spacing).ceil() as i64;
|
||||
if k1.saturating_sub(k0) > 20_000 {
|
||||
return;
|
||||
let h = Hatch {
|
||||
pattern: String::new(),
|
||||
solid: false,
|
||||
loops: Vec::new(),
|
||||
scale: side / (SWATCH_REPEATS * repeat),
|
||||
angle: 0.0,
|
||||
associative: false,
|
||||
style: 0,
|
||||
elevation: 0.0,
|
||||
gradient: None,
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: lines.to_vec(),
|
||||
};
|
||||
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
|
||||
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
|
||||
runs.truncate(MAX_SWATCH_RUNS);
|
||||
runs
|
||||
}
|
||||
|
||||
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
|
||||
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
|
||||
struct Family<'a> {
|
||||
origin: Vec2,
|
||||
ang: f64,
|
||||
delta: Vec2,
|
||||
spacing: f64,
|
||||
dashes: &'a [f64],
|
||||
}
|
||||
|
||||
impl Family<'_> {
|
||||
/// Into the frame where the family's lines are horizontal.
|
||||
fn to_local(&self, p: Vec2) -> Vec2 {
|
||||
(p - self.origin).rotate(-self.ang)
|
||||
}
|
||||
let pat_len: f64 = dashes.iter().map(|d| d.abs()).sum();
|
||||
for k in k0..=k1 {
|
||||
let y = k as f64 * spacing;
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for l in &local {
|
||||
let n = l.len();
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
|
||||
if (a.y > y) != (b.y > y) {
|
||||
xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));
|
||||
fn to_world(&self, p: Vec2) -> Vec2 {
|
||||
self.origin + p.rotate(self.ang)
|
||||
}
|
||||
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, out: &mut Vec<Vec<Vec2>>) {
|
||||
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
|
||||
let bb = Bounds2::from_points(local.iter().flatten().copied());
|
||||
if bb.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The lines and spans swept: the boundary's extent, cut to the view's (its corners in
|
||||
// this frame) when there is one.
|
||||
let (mut lo, mut hi) = (bb.min, bb.max);
|
||||
if let Some(v) = view {
|
||||
let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p)));
|
||||
if vb.is_empty() || !vb.intersects(&bb) {
|
||||
return;
|
||||
}
|
||||
lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y));
|
||||
hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y));
|
||||
}
|
||||
let shift_per_line = self.delta.rotate(-self.ang).x;
|
||||
let k0 = (lo.y / self.spacing).floor() as i64;
|
||||
let k1 = (hi.y / self.spacing).ceil() as i64;
|
||||
let max_lines = if view.is_some() { MAX_VIEW_FAMILY_LINES } else { MAX_FAMILY_LINES };
|
||||
if k1.saturating_sub(k0) > max_lines {
|
||||
return;
|
||||
}
|
||||
let pat_len: f64 = self.dashes.iter().map(|d| d.abs()).sum();
|
||||
// Level of detail: a whole repeat under two pixels reads as a continuous line (when the
|
||||
// family draws anything at all).
|
||||
let draws = self.dashes.iter().any(|d| *d >= 0.0);
|
||||
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
|
||||
for k in k0..=k1 {
|
||||
if out.len() >= MAX_RUNS {
|
||||
return;
|
||||
}
|
||||
let y = k as f64 * self.spacing;
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for l in &local {
|
||||
let n = l.len();
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
|
||||
if (a.y > y) != (b.y > y) {
|
||||
xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let offset = shift_per_line * k as f64;
|
||||
for span in xs.chunks(2) {
|
||||
let [x0, x1] = span else { continue };
|
||||
let (x0, x1) = if view.is_some() { (x0.max(lo.x), x1.min(hi.x)) } else { (*x0, *x1) };
|
||||
if view.is_some() && x1 <= x0 {
|
||||
continue;
|
||||
}
|
||||
if solid_line {
|
||||
out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]);
|
||||
} else {
|
||||
self.dash(x0, x1, y, offset, pat_len, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let offset = shift_per_line * k as f64;
|
||||
for span in xs.chunks(2) {
|
||||
let [x0, x1] = span else { continue };
|
||||
if dashes.is_empty() || pat_len < 1e-12 {
|
||||
out.push(vec![to_world(Vec2::new(*x0, y)), to_world(Vec2::new(*x1, y))]);
|
||||
continue;
|
||||
}
|
||||
// Dash phase anchored at x = offset.
|
||||
let start_rep = ((x0 - offset) / pat_len).floor();
|
||||
let mut x = offset + start_rep * pat_len;
|
||||
let mut guard = 0;
|
||||
'outer: while x < *x1 {
|
||||
for d in dashes {
|
||||
guard += 1;
|
||||
if guard > 100_000 {
|
||||
break 'outer;
|
||||
}
|
||||
let len = d.abs();
|
||||
if *d > 0.0 {
|
||||
let a = x.max(*x0);
|
||||
let b = (x + len).min(*x1);
|
||||
if b > a {
|
||||
out.push(vec![to_world(Vec2::new(a, y)), to_world(Vec2::new(b, y))]);
|
||||
}
|
||||
} else if *d == 0.0 && x >= *x0 && x <= *x1 {
|
||||
out.push(vec![to_world(Vec2::new(x, y))]);
|
||||
}
|
||||
x += len;
|
||||
if x >= *x1 {
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
/// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`.
|
||||
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>) {
|
||||
let start_rep = ((x0 - offset) / pat_len).floor();
|
||||
let mut x = offset + start_rep * pat_len;
|
||||
let mut guard = 0;
|
||||
'outer: while x < x1 {
|
||||
for d in self.dashes {
|
||||
guard += 1;
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS {
|
||||
break 'outer;
|
||||
}
|
||||
let len = d.abs();
|
||||
if *d > 0.0 {
|
||||
let a = x.max(x0);
|
||||
let b = (x + len).min(x1);
|
||||
if b > a {
|
||||
out.push(vec![self.to_world(Vec2::new(a, y)), self.to_world(Vec2::new(b, y))]);
|
||||
}
|
||||
} else if *d == 0.0 && x >= x0 && x <= x1 {
|
||||
out.push(vec![self.to_world(Vec2::new(x, y))]);
|
||||
}
|
||||
x += len;
|
||||
if x >= x1 {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::{Hatch, HatchLoop};
|
||||
use cadcraft_geom::PolyVertex;
|
||||
|
||||
#[test]
|
||||
fn fill_loops_deduplicates_coincident_loops() {
|
||||
let loop1 = HatchLoop {
|
||||
vertices: vec![
|
||||
PolyVertex::new(Vec2::new(0.0, 0.0)),
|
||||
PolyVertex::new(Vec2::new(10.0, 0.0)),
|
||||
PolyVertex::new(Vec2::new(10.0, 10.0)),
|
||||
PolyVertex::new(Vec2::new(0.0, 10.0)),
|
||||
],
|
||||
outer: true,
|
||||
sources: vec![],
|
||||
};
|
||||
let loop2 = loop1.clone();
|
||||
let hatch = Hatch {
|
||||
origin: Vec2::ZERO,
|
||||
pattern: "SOLID".into(),
|
||||
solid: true,
|
||||
loops: vec![loop1, loop2],
|
||||
scale: 1.0,
|
||||
angle: 0.0,
|
||||
associative: false,
|
||||
style: 0,
|
||||
elevation: 0.0,
|
||||
gradient: None,
|
||||
background: None,
|
||||
pattern_lines: vec![],
|
||||
};
|
||||
let loops = fill_loops(&hatch, 1e-4);
|
||||
assert_eq!(loops.len(), 1, "fill_loops must deduplicate duplicate loops");
|
||||
let tris = fill::triangulate_evenodd(&loops);
|
||||
let area: f64 = tris.chunks(3).map(|t| ((t[1] - t[0]).cross(t[2] - t[0]) / 2.0).abs()).sum();
|
||||
assert!((area - 100.0).abs() < 1e-6, "triangulated area must be 100.0, not zero");
|
||||
}
|
||||
}
|
||||
|
||||
+113
-13
@@ -19,6 +19,7 @@ pub mod pen;
|
||||
mod plotstyle;
|
||||
mod point;
|
||||
pub mod raster;
|
||||
mod text;
|
||||
mod tolerance;
|
||||
pub mod units;
|
||||
mod wide;
|
||||
@@ -30,6 +31,7 @@ use pen::Pen;
|
||||
|
||||
pub use dim::{ArrowGeom, Arrowhead, DimGeometry, DimText, LineRole, arrowhead, dimension_geometry, dimension_geometry_with, format_linear_value};
|
||||
pub use fill::triangulate_evenodd;
|
||||
pub use hatch::pattern_swatch;
|
||||
pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet};
|
||||
pub use tolerance::{gdt_points, tolerance_in};
|
||||
|
||||
@@ -172,7 +174,8 @@ impl DisplayList {
|
||||
pub struct Options {
|
||||
/// Chord tolerance in world units (about half a pixel at the current zoom).
|
||||
pub tolerance: f64,
|
||||
/// Dashes shorter than this (world units) are drawn as continuous lines.
|
||||
/// Two pixels in world units: shorter dashes are continuous and filled font text uses
|
||||
/// screen-size detail. Zero keeps full detail (plots and image/vector exports).
|
||||
pub min_dash: f64,
|
||||
/// Draw text (off for very coarse previews).
|
||||
pub text: bool,
|
||||
@@ -185,14 +188,28 @@ pub struct Options {
|
||||
/// Plot style table to draw with. When `None`, [`build`] of a layout whose page setup has
|
||||
/// "Display plot styles" on uses the layout's table; [`build_plot`] uses none.
|
||||
pub plot_style_table: Option<std::sync::Arc<cadcraft_doc::PlotStyleTable>>,
|
||||
/// The area being looked at (world units of the space being built), for on-screen builds:
|
||||
/// hatch patterns are only generated inside it. `None` = everywhere (plots, exports, picks).
|
||||
pub view: Option<Bounds2>,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None }
|
||||
Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None, view: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest axis scale of a transform's linear part (its largest singular value): a length
|
||||
/// `l` in the transform's own units measures at most `l * max_scale` after it, in any direction.
|
||||
/// A uniform scale gives its factor, where [`Mat3::scale_factor`] gives the mean of the axes.
|
||||
fn max_scale(m: &Mat3) -> f64 {
|
||||
let (a, b, c, d) = (m.a, m.b, m.c, m.d);
|
||||
let e = (a * a + b * b + c * c + d * d) / 2.0;
|
||||
let f = (((a * a + b * b - c * c - d * d) / 2.0).powi(2) + (a * c + b * d).powi(2)).sqrt();
|
||||
let s = (e + f).max(0.0).sqrt();
|
||||
if s.is_finite() { s } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Graphics state inherited through block references.
|
||||
#[derive(Clone)]
|
||||
struct Ctx<'a> {
|
||||
@@ -234,6 +251,8 @@ struct Builder<'a> {
|
||||
styles: Option<&'a plotstyle::Styler>,
|
||||
/// Named plot style (table index) of the entity being drawn.
|
||||
named: Option<usize>,
|
||||
/// The block-expansion budget shared by the parts of a list built in parallel.
|
||||
shared_budget: Option<&'a std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy
|
||||
@@ -275,6 +294,9 @@ impl Builder<'_> {
|
||||
self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw, kind: Kind::Polyline, start, len: pts.len() as u32 });
|
||||
}
|
||||
fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) {
|
||||
self.tris_at(ctx, color, tris, ctx.xf);
|
||||
}
|
||||
fn tris_at(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2], xf: Mat3) {
|
||||
if tris.len() < 3 {
|
||||
return;
|
||||
}
|
||||
@@ -282,7 +304,7 @@ impl Builder<'_> {
|
||||
let (color, _) = self.styled(color, 0.0);
|
||||
let start = self.list.tris.len() as u32;
|
||||
for p in tris {
|
||||
let q = ctx.xf.apply(*p);
|
||||
let q = xf.apply(*p);
|
||||
self.list.bounds.add(q);
|
||||
self.list.tris.push(q);
|
||||
}
|
||||
@@ -298,16 +320,26 @@ impl Builder<'_> {
|
||||
}
|
||||
let fill = self.opts.fill && ctx.d.header.i64("TEXTFILL", 1) != 0;
|
||||
// Plots note which filled outlines are font glyphs (see `DisplayList::texts`).
|
||||
let mut text = if self.plotting && fill { sh.text.iter().peekable() } else { [].iter().peekable() };
|
||||
let mut text = sh.text.iter().peekable();
|
||||
let pixel = if self.plotting { 0.0 } else { self.opts.min_dash * 0.5 };
|
||||
for (i, g) in sh.glyphs.iter().enumerate() {
|
||||
if fill {
|
||||
let tris = fill::triangulate_evenodd(g);
|
||||
let at = self.list.prims.len();
|
||||
self.tris(ctx, color, &tris);
|
||||
while text.next_if(|t| t.outline < i).is_some() {}
|
||||
if let Some(t) = text.next_if(|t| t.outline == i)
|
||||
let glyph = text.next_if(|t| t.outline == i);
|
||||
let at = self.list.prims.len();
|
||||
let len = if let Some(t) = glyph {
|
||||
let xf = crate::text::placement(t, ctx.xf);
|
||||
let mesh = crate::text::mesh(t, xf, pixel);
|
||||
self.tris_at(ctx, color, &mesh.triangles, xf);
|
||||
mesh.triangles.len()
|
||||
} else {
|
||||
let tris = fill::triangulate_evenodd(g);
|
||||
self.tris(ctx, color, &tris);
|
||||
tris.len()
|
||||
};
|
||||
if let Some(t) = glyph.filter(|_| self.plotting)
|
||||
&& self.list.prims.len() > at
|
||||
&& let (Ok(prim), Ok(verts)) = (u32::try_from(at), u32::try_from(tris.len()))
|
||||
&& let (Ok(prim), Ok(verts)) = (u32::try_from(at), u32::try_from(len))
|
||||
{
|
||||
let xf = &ctx.xf;
|
||||
let glyph = cadcraft_fonts::TextGlyph { origin: xf.apply(t.origin), x: xf.apply_vec(t.x), y: xf.apply_vec(t.y), ..t.clone() };
|
||||
@@ -339,7 +371,10 @@ impl Builder<'_> {
|
||||
/// Take one unit of the block-expansion budget; false once it is spent.
|
||||
fn expand(&mut self) -> bool {
|
||||
self.list.expanded = self.list.expanded.saturating_add(1);
|
||||
self.list.expanded <= MAX_BLOCK_EXPANSION
|
||||
match self.shared_budget {
|
||||
Some(spent) => spent.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < MAX_BLOCK_EXPANSION,
|
||||
None => self.list.expanded <= MAX_BLOCK_EXPANSION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,12 +417,17 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
|
||||
_ => None,
|
||||
});
|
||||
let styler = table.map(|t| plotstyle::Styler::new(d, t));
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None };
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None };
|
||||
if let Space::Paper(name) = space {
|
||||
b.list.sheet = paper::sheet(d, name);
|
||||
}
|
||||
if let Some(store) = d.space(space) {
|
||||
let paper = matches!(space, Space::Paper(_));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !paper && !plotting && b.styles.is_none() && store.len() >= 2 * PARALLEL_CHUNK {
|
||||
build_parallel(&mut b, d, store);
|
||||
return b.list;
|
||||
}
|
||||
let mut viewports = 0usize;
|
||||
for e in store.iter() {
|
||||
if paper && let EntityKind::Viewport(vp) = &e.kind {
|
||||
@@ -410,6 +450,66 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
|
||||
b.list
|
||||
}
|
||||
|
||||
/// Top-level entities per task when a display list is built on all cores.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PARALLEL_CHUNK: usize = 256;
|
||||
|
||||
/// Build model space for the screen on all cores: the entities are split into chunks built
|
||||
/// side by side, then joined in drawing order, so the list is the same as a one-thread build.
|
||||
/// The canvas rebuilds the whole list at every 2x zoom step, on the UI thread; one core made
|
||||
/// that a visible stall on real drawings. Plots, layouts and plot styles keep the one-thread
|
||||
/// path (they run once, and their pens and per-primitive layers are built along the way).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStore) {
|
||||
use rayon::prelude::*;
|
||||
let ents: Vec<&std::sync::Arc<Entity>> = store.iter().collect();
|
||||
let budget = std::sync::atomic::AtomicUsize::new(0);
|
||||
let (opts, plotting) = (b.opts, b.plotting);
|
||||
let parts: Vec<DisplayList> = ents
|
||||
.par_chunks(PARALLEL_CHUNK)
|
||||
.map(|chunk| {
|
||||
let mut part = Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget) };
|
||||
for e in chunk {
|
||||
entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
|
||||
}
|
||||
part.list
|
||||
})
|
||||
.collect();
|
||||
// One allocation per array for the joined list.
|
||||
b.list.prims.reserve(parts.iter().map(|p| p.prims.len()).sum());
|
||||
b.list.verts.reserve(parts.iter().map(|p| p.verts.len()).sum());
|
||||
b.list.tris.reserve(parts.iter().map(|p| p.tris.len()).sum());
|
||||
for part in parts {
|
||||
append(&mut b.list, part);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `src` to `dst`: its primitives index into `dst`'s vertex and triangle arrays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn append(dst: &mut DisplayList, src: DisplayList) {
|
||||
let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32);
|
||||
// Pens are parallel to the primitives, with missing entries meaning "use object".
|
||||
if !src.pens.is_empty() {
|
||||
dst.pens.resize(dst.prims.len(), Pen::default());
|
||||
}
|
||||
dst.prims.extend(src.prims.into_iter().map(|mut p| {
|
||||
p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts };
|
||||
p
|
||||
}));
|
||||
dst.verts.extend(src.verts);
|
||||
dst.tris.extend(src.tris);
|
||||
dst.prim_layers.extend(src.prim_layers);
|
||||
dst.texts.extend(src.texts.into_iter().map(|mut t| {
|
||||
if t.prim < PlotText::CLIPPED {
|
||||
t.prim += prims;
|
||||
}
|
||||
t
|
||||
}));
|
||||
dst.pens.extend(src.pens);
|
||||
dst.bounds = dst.bounds.union(&src.bounds);
|
||||
dst.expanded = dst.expanded.saturating_add(src.expanded);
|
||||
}
|
||||
|
||||
/// `opts` with a view height for relative point sizes: the extents height when none is given
|
||||
/// and points draw as figures sized relative to the view.
|
||||
fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options {
|
||||
@@ -464,7 +564,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
|
||||
let mcenter = vp.view_center + (rect.center() - center) / s;
|
||||
// Model window with slack: entity bounds are approximate (text, dimensions).
|
||||
let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
|
||||
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None };
|
||||
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, shared_budget: None };
|
||||
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
|
||||
// paper space, whatever the viewport scale.
|
||||
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
|
||||
@@ -602,7 +702,7 @@ pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents:
|
||||
} else {
|
||||
opts
|
||||
};
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None };
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None };
|
||||
for e in ents {
|
||||
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
|
||||
}
|
||||
|
||||
@@ -385,3 +385,201 @@ fn table_title_is_centred_and_merged() {
|
||||
let b = Bounds2::from_points(text);
|
||||
assert!((b.center().x - 3.0).abs() < 0.05, "{b:?}");
|
||||
}
|
||||
|
||||
// ---------- hatch level of detail (on-screen builds, issue #544) ----------
|
||||
|
||||
fn pattern_hatch(pattern: &str, scale: f64, (x0, y0, x1, y1): (f64, f64, f64, f64)) -> Hatch {
|
||||
let sq = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| PolyVertex::new(Vec2::new(x, y)));
|
||||
Hatch {
|
||||
pattern: pattern.into(),
|
||||
solid: false,
|
||||
loops: vec![HatchLoop { vertices: sq.to_vec(), outer: true, sources: Vec::new() }],
|
||||
scale,
|
||||
angle: 0.0,
|
||||
associative: false,
|
||||
style: 0,
|
||||
elevation: 0.0,
|
||||
gradient: None,
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn drawing_with(h: Hatch) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn loops_of(h: &Hatch) -> Vec<Vec<Vec2>> {
|
||||
crate::hatch::fill_loops(h, 0.001)
|
||||
}
|
||||
|
||||
/// On-screen options for a view where one pixel is `px` drawing units.
|
||||
fn screen(px: f64, view: Option<Bounds2>) -> Options {
|
||||
Options { tolerance: px / 2.0, min_dash: px * 2.0, view, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_hatch_on_screen_is_one_solid_fill() {
|
||||
// ANSI31 at scale 0.01: lines 0.00125 apart over a 4x4 square. On a 1000 px view of it a
|
||||
// pixel is 0.004, so the lines run together: one solid fill. Plots keep every line.
|
||||
let d = drawing_with(pattern_hatch("ANSI31", 0.01, (0.0, 0.0, 4.0, 4.0)));
|
||||
let plot = build(&d, &Space::Model, &Options::default());
|
||||
assert!(plot.prims.len() > 4000, "plots keep every line: {}", plot.prims.len());
|
||||
let l = build(&d, &Space::Model, &screen(0.004, None));
|
||||
assert_eq!(l.prims.len(), 1);
|
||||
assert_eq!(l.prims[0].kind, Kind::Tris);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_dots_keep_their_look_when_lines_are_close() {
|
||||
// Dot rows half a pixel apart but dots five pixels apart along them: not solid colour.
|
||||
let fam = |dashes: &[f64]| cadcraft_doc::library::PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 0.5), dashes: dashes.to_vec() };
|
||||
let sparse = Hatch { pattern_lines: vec![fam(&[0.0, -5.0])], ..pattern_hatch("MYDOTS", 1.0, (0.0, 0.0, 40.0, 40.0)) };
|
||||
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &sparse, 1.0));
|
||||
// Dots closer than a pixel along rows closer than a pixel: solid.
|
||||
let dense = Hatch { pattern_lines: vec![fam(&[0.0, -0.5])], ..sparse.clone() };
|
||||
assert!(crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &dense, 1.0));
|
||||
// A family with only gaps draws nothing, so it never reads as solid.
|
||||
let blank = Hatch { pattern_lines: vec![fam(&[-0.5])], ..sparse.clone() };
|
||||
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &blank, 1.0));
|
||||
assert!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &blank, &loops_of(&blank), 1.0, None).is_empty());
|
||||
// Full detail is never solid.
|
||||
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &dense, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scattered_dot_pattern_reads_solid_once_dots_fill_the_pixels() {
|
||||
// AR-SAND at scale 3/16 (the library pattern is drawn 16x coarser since #643, so this is the
|
||||
// geometry of the old scale 3): three dot families, no single one dense enough on its own at
|
||||
// 0.5 per pixel, but together about four dots on every pixel. Zoomed in 4x the dots stand apart.
|
||||
let h = pattern_hatch("AR-SAND", 3.0 / 16.0, (0.0, 0.0, 100.0, 100.0));
|
||||
assert!(crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, 0.5));
|
||||
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, 0.125));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_zoom_pattern_is_unchanged() {
|
||||
// Lines and dash repeats a few pixels long or more: the level of detail changes nothing.
|
||||
for (pattern, px) in [("ANSI31", 0.05), ("ANSI33", 0.05), ("AR-CONC", 0.02), ("AR-SAND", 0.01), ("DOTS", 0.01)] {
|
||||
let h = pattern_hatch(pattern, 1.0, (0.0, 0.0, 6.0, 4.0));
|
||||
let loops = loops_of(&h);
|
||||
let full = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.0, None);
|
||||
assert!(!full.is_empty(), "{pattern}");
|
||||
assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, px), "{pattern}");
|
||||
assert_eq!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, px, None), full, "{pattern}");
|
||||
// A view covering the whole hatch changes nothing either.
|
||||
let all = Bounds2::new(Vec2::new(-1.0, -1.0), Vec2::new(7.0, 5.0));
|
||||
assert_eq!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, px, Some(&all)), full, "{pattern}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dash_repeat_under_two_pixels_is_one_run() {
|
||||
// ANSI33's dashed family repeats every 0.1875: at 0.1 per pixel it reads as a continuous line,
|
||||
// drawn as one run per span covering the same length as the full-detail span.
|
||||
let h = pattern_hatch("ANSI33", 1.0, (0.0, 0.0, 6.0, 4.0));
|
||||
let loops = loops_of(&h);
|
||||
let full = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.0, None);
|
||||
let lod = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.1, None);
|
||||
assert!(lod.len() < full.len(), "{} vs {}", lod.len(), full.len());
|
||||
assert!(lod.iter().all(|r| r.len() == 2));
|
||||
// The continuous family: identical runs in both.
|
||||
let solid_runs = |v: &[Vec<Vec2>]| v.iter().filter(|r| r.len() == 2).count();
|
||||
assert!(solid_runs(&lod) > 0 && solid_runs(&full) > solid_runs(&lod));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_pattern_only_inside_the_view() {
|
||||
// A 40x40 ANSI31 hatch looked at through a 2x2 view in a corner, at full pixel detail.
|
||||
let d = drawing_with(pattern_hatch("ANSI31", 1.0, (0.0, 0.0, 40.0, 40.0)));
|
||||
let full = build(&d, &Space::Model, &Options::default());
|
||||
let far = Some(Bounds2::new(Vec2::new(100.0, 100.0), Vec2::new(110.0, 110.0)));
|
||||
assert_eq!(build(&d, &Space::Model, &Options { view: far, ..Default::default() }).prims.len(), 0);
|
||||
let corner = Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(2.0, 2.0));
|
||||
let part = build(&d, &Space::Model, &Options { view: Some(corner), ..Default::default() });
|
||||
assert!(!part.prims.is_empty() && part.prims.len() * 10 < full.prims.len(), "{} of {}", part.prims.len(), full.prims.len());
|
||||
// Runs are cut in the family's rotated frame: a 2x2 view at 45° reaches 1 unit out, plus one
|
||||
// line spacing (0.125 × √2 along an axis) from rounding the line range outwards.
|
||||
let near = corner.expand(1.0 + 0.125 * 2f64.sqrt() + 1e-9);
|
||||
assert!(part.verts.iter().all(|p| near.contains(*p)));
|
||||
// Nothing is lost inside the view: the drawn length within an inner box is the same.
|
||||
let inner = Bounds2::new(Vec2::new(0.2, 0.2), Vec2::new(1.8, 1.8));
|
||||
let (a, b) = (length_inside(&full, &inner), length_inside(&part, &inner));
|
||||
assert!(a > 1.0 && (a - b).abs() < 1e-9, "{a} vs {b}");
|
||||
}
|
||||
|
||||
/// Total length of a display list's polylines inside an axis-aligned box (Liang–Barsky).
|
||||
fn length_inside(l: &DisplayList, bx: &Bounds2) -> f64 {
|
||||
let mut total = 0.0;
|
||||
for p in l.prims.iter().filter(|p| p.kind == Kind::Polyline) {
|
||||
for w in l.points(p).windows(2) {
|
||||
let (p0, d) = (w[0], w[1] - w[0]);
|
||||
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
||||
let mut ok = true;
|
||||
for (pp, q) in [(-d.x, p0.x - bx.min.x), (d.x, bx.max.x - p0.x), (-d.y, p0.y - bx.min.y), (d.y, bx.max.y - p0.y)] {
|
||||
if pp == 0.0 {
|
||||
ok &= q >= 0.0;
|
||||
} else if pp < 0.0 {
|
||||
t0 = t0.max(q / pp);
|
||||
} else {
|
||||
t1 = t1.min(q / pp);
|
||||
}
|
||||
}
|
||||
if ok && t1 > t0 {
|
||||
total += d.len() * (t1 - t0);
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_hatch_seen_through_a_wide_view_is_drawn() {
|
||||
// ANSI31 (0.125 apart) over ±1e5 seen through a 3000-unit view: about 34,000 lines in the
|
||||
// family's rotated frame, past the 20,000-line guard of a whole-hatch sweep, but still drawn
|
||||
// when the sweep is limited to the view.
|
||||
let d = drawing_with(pattern_hatch("ANSI31", 1.0, (-1e5, -1e5, 1e5, 1e5)));
|
||||
let view = Some(Bounds2::new(Vec2::new(-1500.0, -1500.0), Vec2::new(1500.0, 1500.0)));
|
||||
let l = build(&d, &Space::Model, &Options { view, ..Default::default() });
|
||||
assert!(l.prims.len() > 20_000, "{}", l.prims.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_in_a_squashed_block_keeps_lines_that_show() {
|
||||
// A block hatch inserted 100x wider than tall: along x its lines are spread far apart on
|
||||
// screen, so it is drawn with lines, not merged into a fill.
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut blk = cadcraft_doc::Block::new("B");
|
||||
let mut e = cadcraft_doc::Entity::new(cadcraft_doc::Handle(1), EntityKind::Hatch(pattern_hatch("ANSI31", 1.0, (0.0, 0.0, 4.0, 4.0))));
|
||||
e.common = Common::default();
|
||||
blk.entities.push(e);
|
||||
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
|
||||
let ins = cadcraft_doc::Insert {
|
||||
block: "B".into(),
|
||||
insert: Vec3::new(0.0, 0.0, 0.0),
|
||||
scale: Vec3::new(100.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: vec![],
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
// One pixel = 1 unit: 0.125 spacing × 100 along x is far more than a pixel.
|
||||
let l = build(&d, &Space::Model, &screen(1.0, None));
|
||||
assert!(l.prims.iter().all(|p| p.kind != Kind::Tris), "merged into a fill");
|
||||
assert!(l.prims.len() > 10, "{}", l.prims.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_scale_is_the_most_stretched_axis() {
|
||||
let m = Mat3::scale(1.0, 0.1);
|
||||
assert!((max_scale(&m) - 1.0).abs() < 1e-12);
|
||||
let r = Mat3::rotate(0.7).then(Mat3::scale(2.0, 2.0));
|
||||
assert!((max_scale(&r) - 2.0).abs() < 1e-12);
|
||||
assert!((max_scale(&Mat3::IDENTITY) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Filled font glyphs: bounded font-unit meshes shared across placements and zoom rebuilds.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
|
||||
|
||||
use cadcraft_fonts::{TextGlyph, ttf};
|
||||
use cadcraft_geom::{Bounds2, Mat3, Vec2};
|
||||
use lyon_tessellation::geometry_builder::{BuffersBuilder, VertexBuffers};
|
||||
use lyon_tessellation::math::point;
|
||||
use lyon_tessellation::path::Path;
|
||||
use lyon_tessellation::{FillOptions, FillTessellator, FillVertex};
|
||||
|
||||
const MAX_POINTS: usize = 16_384;
|
||||
const MAX_ENTRIES: usize = 8192;
|
||||
const MAX_CACHE_BYTES: usize = 64 << 20;
|
||||
const MAX_SIMPLIFY_STEPS: usize = 1_000_000;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Detail {
|
||||
Full,
|
||||
/// A power-of-two tolerance in font units, rounded toward finer detail.
|
||||
Reduced(i16),
|
||||
/// An em occupies at most two pixels in every direction: show the ink's extent.
|
||||
Box,
|
||||
}
|
||||
|
||||
type Key = (usize, usize, u32, u16, Detail);
|
||||
|
||||
pub(super) struct Mesh {
|
||||
contours: Arc<[Vec<Vec2>]>,
|
||||
pub triangles: Vec<Vec2>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
// Keep the bytes alive: registering a replacement font must never reuse this key's address.
|
||||
_face: ttf::Face,
|
||||
mesh: Arc<Mesh>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
entries: HashMap<Key, Entry>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
/// A read-write lock: display lists are built on all cores and nearly every lookup is a hit.
|
||||
fn cache() -> &'static RwLock<Cache> {
|
||||
static CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| RwLock::new(Cache::default()))
|
||||
}
|
||||
|
||||
/// World-space placement of font units, including the block/viewport transform.
|
||||
pub(super) fn placement(g: &TextGlyph, xf: Mat3) -> Mat3 {
|
||||
xf.then_before(Mat3 { a: g.x.x / 1000.0, b: g.x.y / 1000.0, c: g.y.x / 1000.0, d: g.y.y / 1000.0, e: g.origin.x, f: g.origin.y })
|
||||
}
|
||||
|
||||
fn detail(xf: Mat3, pixel: f64) -> Detail {
|
||||
// The Frobenius norm bounds the largest stretch, including shear, mirroring and unequal
|
||||
// block scales. A font-unit error below pixel / stretch is below a pixel in every direction.
|
||||
let stretch = xf.a.hypot(xf.b).hypot(xf.c.hypot(xf.d));
|
||||
if !(pixel.is_finite() && pixel > 0.0 && stretch.is_finite() && stretch > 0.0) {
|
||||
return Detail::Full;
|
||||
}
|
||||
if stretch * 1000.0 <= pixel * 2.0 {
|
||||
return Detail::Box;
|
||||
}
|
||||
let tolerance = pixel * 0.25 / stretch;
|
||||
if !tolerance.is_finite() || tolerance < 0.0625 { Detail::Full } else { Detail::Reduced(tolerance.log2().floor().clamp(-4.0, 16.0) as i16) }
|
||||
}
|
||||
|
||||
pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc<Mesh> {
|
||||
let detail = detail(xf, pixel);
|
||||
let key = (g.face.bytes.as_ptr() as usize, g.face.bytes.len(), g.face.index, g.gid, detail);
|
||||
if let Some(entry) = cache().read().unwrap_or_else(PoisonError::into_inner).entries.get(&key) {
|
||||
return entry.mesh.clone();
|
||||
}
|
||||
let contours = ttf::glyph_contours(&g.face, g.gid);
|
||||
let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add);
|
||||
// Font data is untrusted; don't hand unbounded or invalid paths to the tessellator.
|
||||
if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(|p| !p.is_finite() || p.x.abs().max(p.y.abs()) > 1.0e6) {
|
||||
return Arc::new(Mesh { contours: Arc::default(), triangles: Vec::new() });
|
||||
}
|
||||
let contours = match detail {
|
||||
Detail::Full => contours,
|
||||
Detail::Reduced(band) => contours.iter().map(|c| simplify(c, 2f64.powi(i32::from(band)))).collect::<Vec<_>>().into(),
|
||||
Detail::Box => {
|
||||
let bb = Bounds2::from_points(contours.iter().flatten().copied());
|
||||
if bb.is_empty() {
|
||||
Arc::default()
|
||||
} else {
|
||||
vec![vec![bb.min, Vec2::new(bb.max.x, bb.min.y), bb.max, Vec2::new(bb.min.x, bb.max.y), bb.min]].into()
|
||||
}
|
||||
}
|
||||
};
|
||||
let mesh = Arc::new(Mesh { triangles: triangulate(&contours), contours });
|
||||
let points = mesh.contours.iter().map(Vec::capacity).sum::<usize>().saturating_add(mesh.triangles.capacity());
|
||||
let bytes = points
|
||||
.saturating_mul(std::mem::size_of::<Vec2>())
|
||||
.saturating_add(mesh.contours.len().saturating_mul(std::mem::size_of::<Vec<Vec2>>()))
|
||||
.saturating_add(std::mem::size_of::<Entry>() + std::mem::size_of::<Key>() + std::mem::size_of::<Mesh>());
|
||||
if bytes <= MAX_CACHE_BYTES {
|
||||
let mut c = cache().write().unwrap_or_else(PoisonError::into_inner);
|
||||
if c.entries.len() >= MAX_ENTRIES || c.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
|
||||
c.entries.clear();
|
||||
c.bytes = 0;
|
||||
}
|
||||
if !c.entries.contains_key(&key) {
|
||||
c.bytes = c.bytes.saturating_add(bytes);
|
||||
c.entries.insert(key, Entry { _face: g.face.clone(), mesh: mesh.clone() });
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
/// Iterative Douglas-Peucker on a closed contour. Never collapse a hole to a line or recurse
|
||||
/// on font data; preserve the original if the work budget is exhausted.
|
||||
fn simplify(contour: &[Vec2], tolerance: f64) -> Vec<Vec2> {
|
||||
if contour.len() < 5 {
|
||||
return contour.to_vec();
|
||||
}
|
||||
let mut points = contour.to_vec();
|
||||
if let (Some(&first), Some(&last)) = (points.first(), points.last())
|
||||
&& first != last
|
||||
{
|
||||
points.push(first);
|
||||
}
|
||||
let last = points.len().saturating_sub(1);
|
||||
let mut keep = vec![false; points.len()];
|
||||
if let Some(k) = keep.first_mut() {
|
||||
*k = true;
|
||||
}
|
||||
if let Some(k) = keep.last_mut() {
|
||||
*k = true;
|
||||
}
|
||||
let mut pending = vec![(0usize, last)];
|
||||
let mut steps = 0usize;
|
||||
while let Some((start, end)) = pending.pop() {
|
||||
let (Some(&a), Some(&b)) = (points.get(start), points.get(end)) else { continue };
|
||||
let ab = b - a;
|
||||
let len2 = ab.len2();
|
||||
let mut furthest = None;
|
||||
let mut distance = tolerance;
|
||||
for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) {
|
||||
steps += 1;
|
||||
if steps > MAX_SIMPLIFY_STEPS {
|
||||
return contour.to_vec();
|
||||
}
|
||||
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = p.dist(a + ab * t);
|
||||
if d > distance {
|
||||
distance = d;
|
||||
furthest = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = furthest {
|
||||
if let Some(k) = keep.get_mut(i) {
|
||||
*k = true;
|
||||
}
|
||||
pending.extend([(start, i), (i, end)]);
|
||||
}
|
||||
}
|
||||
let result: Vec<_> = points.into_iter().zip(keep).filter_map(|(p, keep)| keep.then_some(p)).collect();
|
||||
if result.len() >= 4 { result } else { contour.to_vec() }
|
||||
}
|
||||
|
||||
/// Monotone tessellation with even-odd holes and intersections. Work in font units so f32
|
||||
/// precision is independent of drawing coordinates; the placement stays f64. The existing
|
||||
/// slab filler remains the fallback when tessellation fails (including its u16 vertex limit).
|
||||
fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
|
||||
let mut path = Path::builder();
|
||||
for contour in contours {
|
||||
let Some(&first) = contour.first().filter(|_| contour.len() >= 3) else { continue };
|
||||
path.begin(point(first.x as f32, first.y as f32));
|
||||
for p in contour.iter().skip(1) {
|
||||
path.line_to(point(p.x as f32, p.y as f32));
|
||||
}
|
||||
path.end(true);
|
||||
}
|
||||
let mut buffers: VertexBuffers<Vec2, u16> = VertexBuffers::new();
|
||||
let result = FillTessellator::new().tessellate_path(
|
||||
&path.build(),
|
||||
&FillOptions::even_odd(),
|
||||
&mut BuffersBuilder::new(&mut buffers, |v: FillVertex<'_>| {
|
||||
let p = v.position();
|
||||
Vec2::new(f64::from(p.x), f64::from(p.y))
|
||||
}),
|
||||
);
|
||||
if result.is_err() {
|
||||
return crate::fill::triangulate_evenodd(contours);
|
||||
}
|
||||
buffers.indices.into_iter().filter_map(|i| buffers.vertices.get(usize::from(i)).copied()).collect()
|
||||
}
|
||||
@@ -164,3 +164,50 @@ fn pattern_lines_from_a_file_are_drawn_for_unknown_names() {
|
||||
let p = l.prims.iter().find(|p| p.kind == Kind::Polyline).map(|p| l.points(p)).expect("lines");
|
||||
assert!(((p[1] - p[0]).angle().to_degrees().rem_euclid(180.0) - 45.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_swatches_stay_in_their_square_and_show_a_few_repeats() {
|
||||
for p in cadcraft_doc::library::standard_patterns() {
|
||||
let runs = cadcraft_render::pattern_swatch(&p.lines, 1.0);
|
||||
if p.lines.is_empty() {
|
||||
assert!(runs.is_empty(), "{}", p.name);
|
||||
continue;
|
||||
}
|
||||
assert!(runs.len() >= 3, "{} has {} runs", p.name, runs.len());
|
||||
assert!(runs.len() <= 4000, "{} has {} runs", p.name, runs.len());
|
||||
for v in runs.iter().flatten() {
|
||||
assert!((-1e-9..=1.0 + 1e-9).contains(&v.x) && (-1e-9..=1.0 + 1e-9).contains(&v.y), "{} at {v:?}", p.name);
|
||||
}
|
||||
}
|
||||
assert!(cadcraft_render::pattern_swatch(&[], 1.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
|
||||
let l = draw(hatch("BRICK", vec![square(0.0, 2.0)]), "0");
|
||||
// Vertical joints: (x, course) for every vertical segment, rounded.
|
||||
let mut joints: Vec<(i64, i64)> = l
|
||||
.prims
|
||||
.iter()
|
||||
.filter(|p| p.kind == Kind::Polyline)
|
||||
.filter_map(|p| match l.points(p) {
|
||||
[a, b] if (a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() > 1e-9 => {
|
||||
Some(((a.x * 1000.0).round() as i64, ((a.y + b.y) / 2.0 / 0.25).floor() as i64))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let n = joints.len();
|
||||
joints.sort_unstable();
|
||||
joints.dedup();
|
||||
assert_eq!(joints.len(), n, "a joint drawn twice");
|
||||
// Within a course joints are a brick (0.5) apart; the next course is offset half a brick.
|
||||
let xs = |course: i64| joints.iter().filter(|j| j.1 == course).map(|j| j.0).collect::<Vec<_>>();
|
||||
for course in 0..4 {
|
||||
let x = xs(course);
|
||||
assert!(x.len() >= 3, "course {course}: {x:?}");
|
||||
assert!(x.windows(2).all(|w| w[1] - w[0] == 500), "course {course}: {x:?}");
|
||||
let next = xs(course + 1);
|
||||
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ pub fn load_prefs(json: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the GPU error notice says (see [`gpu_notice`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GpuNotice {
|
||||
pub fault: crate::gpu::GpuFault,
|
||||
/// Drawings autosaved because of it (0: nothing unsaved, or autosave is off).
|
||||
pub autosaved: usize,
|
||||
}
|
||||
|
||||
/// Drawing Recovery: autosave files left behind by an app that stopped, listed once at start.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Recovery {
|
||||
@@ -81,6 +89,19 @@ impl CadApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the
|
||||
/// session may not survive it) and show [`gpu_notice`].
|
||||
pub fn gpu_fault_tick(&mut self, ctx: &egui::Context) {
|
||||
let Some(fault) = self.gpu_faults.take() else { return };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let saved = self.autosave.as_mut().map_or(0, |a| a.save_now(&self.session));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let saved = 0;
|
||||
let autosaved = self.gpu_notice.as_ref().map_or(0, |n| n.autosaved) + saved;
|
||||
self.gpu_notice = Some(GpuNotice { fault, autosaved });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The app closes normally: this app's autosave files are not needed any more.
|
||||
pub fn shutdown_autosave(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -163,6 +184,48 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice after a GPU error or device loss: what happened, what was autosaved, and what to
|
||||
/// do. Not modal, so drawings can still be saved while the device works.
|
||||
pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let Some(n) = &app.gpu_notice else { return };
|
||||
let t = Tokens::get();
|
||||
let mut dismiss = false;
|
||||
egui::Window::new("Graphics error")
|
||||
.id(egui::Id::new("cc_gpu_notice"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(440.0);
|
||||
if n.fault.lost {
|
||||
ui.label("The graphics device stopped working (a driver reset or a GPU that stopped responding). CADCraft can't draw again until it restarts.");
|
||||
} else {
|
||||
let times = if n.fault.errors == 1 { String::new() } else { format!(" ({} times)", n.fault.errors) };
|
||||
ui.label(format!("The graphics driver reported an error{times}. CADCraft kept running, but the drawing may not display correctly."));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
match n.autosaved {
|
||||
0 => ui.label("Nothing needed autosaving."),
|
||||
1 => ui.label("A recovery copy of the open drawing with unsaved changes was autosaved."),
|
||||
k => ui.label(format!("Recovery copies of {k} drawings with unsaved changes were autosaved.")),
|
||||
};
|
||||
ui.label(RichText::new("Save your work and restart CADCraft. Recovery copies are offered at the next start if needed.").strong());
|
||||
ui.add_space(4.0);
|
||||
ui.collapsing("Details", |ui| {
|
||||
ui.label(RichText::new(&n.fault.message).color(t.text_dim).monospace());
|
||||
});
|
||||
// A row just as tall as the button (a plain right-to-left layout takes the rest).
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Dismiss").clicked() {
|
||||
dismiss = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if dismiss {
|
||||
app.gpu_notice = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
+327
-40
@@ -38,7 +38,10 @@ impl Xf {
|
||||
#[derive(Default)]
|
||||
pub struct CanvasState {
|
||||
pub list: Option<DisplayList>,
|
||||
key: (u64, u64, i32, bool, usize),
|
||||
key: (u64, u64, i32, i32, bool, usize),
|
||||
/// The area `list` was built for (hatch patterns exist only inside it); a view leaving it
|
||||
/// rebuilds the list.
|
||||
window: Option<Bounds2>,
|
||||
/// Bumped whenever `list` is rebuilt (identifies it for [`PrimIndex`]).
|
||||
list_gen: u64,
|
||||
prim_index: PrimIndex,
|
||||
@@ -48,6 +51,20 @@ pub struct CanvasState {
|
||||
mesh_key: Option<u64>,
|
||||
mesh_slot: crate::gpu::MeshSlot,
|
||||
mesh_origin: Vec2,
|
||||
/// The dragged objects of a MOVE/COPY/ROTATE/SCALE preview, uploaded once per drag (see
|
||||
/// [`draw_drag_gpu`]).
|
||||
drag_key: Option<u64>,
|
||||
drag_slot: crate::gpu::MeshSlot,
|
||||
drag_origin: Vec2,
|
||||
/// The selection highlight on the GPU (see [`draw_highlight_gpu`]).
|
||||
hl_key: Option<u64>,
|
||||
hl_slot: crate::gpu::MeshSlot,
|
||||
hl_origin: Vec2,
|
||||
/// Selected infinite lines (clipped and drawn on the CPU each frame).
|
||||
hl_infinite: Vec<usize>,
|
||||
/// The highlight geometry of the active viewport's model space, with the key it was built for
|
||||
/// (see [`viewport_highlight_tolerance`]).
|
||||
vp_hl: Option<(u64, DisplayList)>,
|
||||
/// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode).
|
||||
infinite: Vec<usize>,
|
||||
/// Milliseconds spent converting the display list to vertex data (last rebuild).
|
||||
@@ -142,32 +159,65 @@ fn color32(c: Rgb) -> Color32 {
|
||||
Color32::from_rgb(c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
fn ensure_list(app: &mut CadApp, px: f64) {
|
||||
/// Rebuild the display list when needed. `px`: drawing units per pixel in the current view;
|
||||
/// `views`: every area on screen that shows the list (the current view, other model tiles) with
|
||||
/// its drawing units per pixel.
|
||||
fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) {
|
||||
let Ok(st) = app.session.state() else { return };
|
||||
// Rebuild when the drawing changes or the zoom moves by more than 2x (tessellation band).
|
||||
let band = px.max(1e-300).log2().floor() as i32;
|
||||
let key = (st.uid, st.revision, band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize);
|
||||
if app.canvas.list.is_some() && app.canvas.key == key {
|
||||
// Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a
|
||||
// view leaves the area the list was built for.
|
||||
let band = |px: f64| px.max(1e-300).log2().floor() as i32;
|
||||
// Hatch level of detail follows the finest view showing the list.
|
||||
let lod_band = views.iter().map(|v| band(v.1)).fold(band(px), i32::min);
|
||||
let key = (st.uid, st.revision, band(px), lod_band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize);
|
||||
let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0));
|
||||
let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible));
|
||||
if app.canvas.list.is_some() && app.canvas.key == key && inside {
|
||||
return;
|
||||
}
|
||||
let t0 = crate::now_ms();
|
||||
let opts = cadcraft_render::Options {
|
||||
tolerance: px * 0.5 * 2f64.powi(band) / px.max(1e-300) * px.min(1.0e300),
|
||||
min_dash: px * 2.0,
|
||||
tolerance: 2f64.powi(band(px)) * 0.5,
|
||||
// Two pixels at the band's finest zoom: the list serves every zoom in the band, so its
|
||||
// level of detail must hold at the finest one.
|
||||
min_dash: 2f64.powi(lod_band) * 2.0,
|
||||
text: true,
|
||||
fill: true,
|
||||
lineweights: app.session.settings.lwdisplay,
|
||||
view_height: st.paper_view().height,
|
||||
plot_style_table: None,
|
||||
view: build_window(views),
|
||||
};
|
||||
let opts = cadcraft_render::Options { tolerance: 2f64.powi(band) * 0.5, ..opts };
|
||||
let space = st.space.clone();
|
||||
app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts));
|
||||
app.canvas.key = key;
|
||||
app.canvas.window = opts.view;
|
||||
app.canvas.list_gen = app.canvas.list_gen.wrapping_add(1);
|
||||
app.canvas.build_ms = crate::now_ms() - t0;
|
||||
}
|
||||
|
||||
/// Tessellation tolerance for the highlights inside the active viewport: `scale` (pixels per
|
||||
/// drawing unit) snapped to its 2x zoom band, as in [`ensure_list`], so zooming and panning
|
||||
/// inside the band doesn't rebuild the geometry.
|
||||
fn viewport_highlight_tolerance(scale: f64) -> f64 {
|
||||
2f64.powi((1.0 / scale.max(1e-300)).log2().floor() as i32) * 0.5
|
||||
}
|
||||
|
||||
/// The area a display list is built for: each view with half its larger side of margin all
|
||||
/// round (panning by up to that much doesn't rebuild), joined. `None` (everything) when a view
|
||||
/// isn't usable.
|
||||
fn build_window(views: &[(Bounds2, f64)]) -> Option<Bounds2> {
|
||||
let mut w = Bounds2::EMPTY;
|
||||
for (v, _) in views {
|
||||
let side = v.width().max(v.height());
|
||||
if v.is_empty() || !side.is_finite() {
|
||||
return None;
|
||||
}
|
||||
w = w.union(&v.expand(side * 0.5));
|
||||
}
|
||||
(!w.is_empty()).then_some(w)
|
||||
}
|
||||
|
||||
/// Grid spacing adapted to the zoom: multiply the unit until lines are at least `min_px` apart.
|
||||
fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 {
|
||||
let mut u = unit.max(1e-12);
|
||||
@@ -322,6 +372,117 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay:
|
||||
p.extend(shapes);
|
||||
}
|
||||
|
||||
/// Draw the selection highlight (dashed, `width` points) through the GPU: its vertex data is
|
||||
/// rebuilt only when the display list, the selection or the vertex origin changes. Dashing every
|
||||
/// selected line and tessellating it as egui shapes each frame made large selections sluggish.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_highlight_gpu(
|
||||
p: &egui::Painter,
|
||||
xf: &Xf,
|
||||
list: &DisplayList,
|
||||
list_gen: u64,
|
||||
prim_index: &mut PrimIndex,
|
||||
sel: &[Handle],
|
||||
color: Color32,
|
||||
width: f32,
|
||||
hl: (&mut Option<u64>, &crate::gpu::MeshSlot, &mut Vec2, &mut Vec<usize>),
|
||||
) {
|
||||
let (hl_key, hl_slot, hl_origin, hl_infinite) = hl;
|
||||
let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300));
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(list_gen, sel, origin.x.to_bits(), origin.y.to_bits(), color.to_array(), width.to_bits()).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
if *hl_key != Some(key) {
|
||||
let prims = prim_index.prims(list_gen, list, sel);
|
||||
let mesh = crate::gpu::build_highlight_mesh(list, &prims, origin, color.to_array(), width);
|
||||
*hl_infinite = prims.into_iter().filter(|&i| list.prims.get(i).is_some_and(|p| matches!(p.kind, Kind::Infinite { .. }))).collect();
|
||||
if let Ok(mut slot) = hl_slot.lock() {
|
||||
*slot = Some(mesh);
|
||||
}
|
||||
*hl_key = Some(key);
|
||||
*hl_origin = origin;
|
||||
}
|
||||
let center = xf.rect.center();
|
||||
p.add(egui_wgpu::Callback::new_paint_callback(
|
||||
xf.rect,
|
||||
crate::gpu::CanvasCallback {
|
||||
key,
|
||||
slot: hl_slot.clone(),
|
||||
offset: [(hl_origin.x - xf.center.x) as f32, (hl_origin.y - xf.center.y) as f32],
|
||||
scale: xf.scale as f32,
|
||||
center: [center.x, center.y],
|
||||
layer: crate::gpu::LAYER_HIGHLIGHT,
|
||||
xform: crate::gpu::IDENTITY_XFORM,
|
||||
dash: 10.0,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
/// Draw a MOVE/COPY/ROTATE/SCALE preview the way the drawing itself is drawn: the dragged
|
||||
/// objects are tessellated and uploaded once (again only when the drawing, the selection or the
|
||||
/// zoom band changes), and each frame only the transform in the view uniform moves. Rebuilding
|
||||
/// the transformed copies as egui shapes every frame was slow with heavy selections and could
|
||||
/// overflow the GPU buffer limit.
|
||||
/// `clipboard` is the session's clipboard and its serial (dragged when `drag.clipboard`, by PASTE).
|
||||
fn draw_drag_gpu(
|
||||
c: &mut CanvasState,
|
||||
p: &egui::Painter,
|
||||
xf: &Xf,
|
||||
doc: &cadcraft_doc::Drawing,
|
||||
drag: &cadcraft_engine::Drag,
|
||||
clipboard: (&[cadcraft_doc::Entity], u64),
|
||||
bg: Rgb,
|
||||
) {
|
||||
// Tessellate for a power-of-two zoom band so zooming mid-drag rebuilds only every 2x.
|
||||
let band = xf.scale.max(1e-300).log2().round() as i32;
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(c.key, band, bg.0, bg.1, bg.2, &drag.handles).hash(&mut h);
|
||||
if drag.clipboard {
|
||||
clipboard.1.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
};
|
||||
if c.drag_key != Some(key) {
|
||||
let band_scale = 2f64.powi(band);
|
||||
let opts = cadcraft_render::Options { tolerance: 0.5 / band_scale, min_dash: 2.0 / band_scale, ..Default::default() };
|
||||
let list = if drag.clipboard {
|
||||
cadcraft_render::build_entities(doc, clipboard.0.iter(), &opts)
|
||||
} else {
|
||||
cadcraft_render::build_entities(doc, drag.handles.iter().filter_map(|h| doc.entity(*h)).map(|e| e.as_ref()), &opts)
|
||||
};
|
||||
let origin = if list.bounds.is_empty() { Vec2::ZERO } else { list.bounds.center() };
|
||||
let mesh = crate::gpu::build_mesh(&list, origin, bg, false);
|
||||
if let Ok(mut slot) = c.drag_slot.lock() {
|
||||
*slot = Some(mesh);
|
||||
}
|
||||
c.drag_key = Some(key);
|
||||
c.drag_origin = origin;
|
||||
}
|
||||
// Positions are relative to the origin o: M(o + p) = L·p + (M(o) − o).
|
||||
let m = drag.matrix;
|
||||
let o = c.drag_origin;
|
||||
let t = m.apply(o) - o;
|
||||
let center = xf.rect.center();
|
||||
p.add(egui_wgpu::Callback::new_paint_callback(
|
||||
xf.rect,
|
||||
crate::gpu::CanvasCallback {
|
||||
key,
|
||||
slot: c.drag_slot.clone(),
|
||||
offset: [(o.x - xf.center.x) as f32, (o.y - xf.center.y) as f32],
|
||||
scale: xf.scale as f32,
|
||||
center: [center.x, center.y],
|
||||
layer: crate::gpu::LAYER_DRAG,
|
||||
xform: [m.a as f32, m.b as f32, m.c as f32, m.d as f32, t.x as f32, t.y as f32],
|
||||
dash: 0.0,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
/// Draw the display list through the GPU paint callback (uploading it when it changed), plus
|
||||
/// the infinite lines, which are clipped to the view on the CPU every frame.
|
||||
fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) {
|
||||
@@ -330,7 +491,7 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(c.key, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h);
|
||||
(c.key, c.list_gen, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
if c.mesh_key != Some(key) {
|
||||
@@ -353,6 +514,9 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis
|
||||
offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32],
|
||||
scale: xf.scale as f32,
|
||||
center: [center.x, center.y],
|
||||
layer: crate::gpu::LAYER_DRAWING,
|
||||
xform: crate::gpu::IDENTITY_XFORM,
|
||||
dash: 0.0,
|
||||
},
|
||||
));
|
||||
let mut shapes = Vec::new();
|
||||
@@ -556,6 +720,31 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf, ctx: &egui::Context) ->
|
||||
r.point
|
||||
}
|
||||
|
||||
/// The vertical wheel scroll this frame that zooms, in points, read from the raw wheel events so
|
||||
/// each notch zooms by its whole step at once, as in standard CAD (#660). egui's smoothed delta
|
||||
/// spreads a notch over several frames, so quick notches lag behind the wheel. Units convert to
|
||||
/// points as egui does (a line is `line_scroll_speed` points, a page the view's height), and
|
||||
/// events egui turns into a pinch zoom (⌘/Ctrl) or a sideways scroll (⇧) are left out, as it does.
|
||||
fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> f32 {
|
||||
let points: f32 = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::MouseWheel { unit, delta, modifiers, .. }
|
||||
if !modifiers.matches_any(opts.zoom_modifier) && !modifiers.matches_any(opts.horizontal_scroll_modifier) =>
|
||||
{
|
||||
let per = match unit {
|
||||
egui::MouseWheelUnit::Point => 1.0,
|
||||
egui::MouseWheelUnit::Line => opts.line_scroll_speed,
|
||||
egui::MouseWheelUnit::Page => page,
|
||||
};
|
||||
Some(delta.y * per)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
if points.is_finite() { points } else { 0.0 }
|
||||
}
|
||||
|
||||
pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let full = ui.available_rect_before_wrap();
|
||||
@@ -602,26 +791,29 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
if inside && scroll != egui::Vec2::ZERO {
|
||||
if app.canvas.scroll_pans {
|
||||
// Two-finger swipe pans: the drawing follows the fingers, like a middle-drag.
|
||||
// Pinch (and Cmd+scroll) still zoom via `zoom_delta` below.
|
||||
if let Ok(st) = app.session.state_mut() {
|
||||
let v = st.view();
|
||||
st.set_view_quiet(cadcraft_engine::View {
|
||||
center: v.center - Vec2::new(f64::from(scroll.x) / scale, -f64::from(scroll.y) / scale),
|
||||
height: v.height,
|
||||
});
|
||||
}
|
||||
} else if scroll.y.abs() > 0.0
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
// Zoom with the mouse wheel about the cursor.
|
||||
let f = app.session.wheel_zoom_factor(f64::from(scroll.y));
|
||||
let about = xf.to_world(hp);
|
||||
let _ = app.session.zoom_about(f, about);
|
||||
if inside && app.canvas.scroll_pans && scroll != egui::Vec2::ZERO {
|
||||
// Two-finger swipe pans: the drawing follows the fingers, like a middle-drag.
|
||||
// Pinch (and Cmd+scroll) still zoom via `zoom_delta` below.
|
||||
if let Ok(st) = app.session.state_mut() {
|
||||
let v = st.view();
|
||||
st.set_view_quiet(cadcraft_engine::View {
|
||||
center: v.center - Vec2::new(f64::from(scroll.x) / scale, -f64::from(scroll.y) / scale),
|
||||
height: v.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
let opts = ui.ctx().options(|o| o.input_options);
|
||||
let wheel = ui.input(|i| wheel_zoom_points(&i.raw.events, &opts, rect.height()));
|
||||
if inside
|
||||
&& !app.canvas.scroll_pans
|
||||
&& wheel != 0.0
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
// Zoom with the mouse wheel about the cursor, each notch at once.
|
||||
let f = app.session.wheel_zoom_factor(f64::from(wheel));
|
||||
let about = xf.to_world(hp);
|
||||
let _ = app.session.zoom_about(f, about);
|
||||
}
|
||||
let zoom_pinch = ui.input(|i| i.zoom_delta());
|
||||
if inside
|
||||
&& (zoom_pinch - 1.0).abs() > 1e-4
|
||||
@@ -852,7 +1044,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
// ---------- paint ----------
|
||||
painter.rect_filled(rect, 0.0, t.canvas);
|
||||
let t0 = crate::now_ms();
|
||||
ensure_list(app, 1.0 / xf_paper.scale);
|
||||
let mut views = vec![(xf_paper.world_bounds(), 1.0 / xf_paper.scale)];
|
||||
views.extend(tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0).map(|(r, tile)| {
|
||||
let xf = tile_xf(*r, tile);
|
||||
(xf.world_bounds(), 1.0 / xf.scale)
|
||||
}));
|
||||
ensure_list(app, 1.0 / xf_paper.scale, &views);
|
||||
let sheet = app.canvas.list.as_ref().and_then(|l| l.sheet);
|
||||
let bg = match sheet {
|
||||
Some(sh) => {
|
||||
@@ -904,23 +1101,40 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
hs.extend(app.canvas.hover.filter(|h| !sel.contains(h)));
|
||||
let ents: Vec<_> = hs.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref()).collect();
|
||||
if !ents.is_empty() {
|
||||
let list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance: 0.5 / scale, ..Default::default() });
|
||||
let clipped = painter.with_clip_rect(r.intersect(rect));
|
||||
if let Some(h) = app.canvas.hover
|
||||
&& !sel.contains(&h)
|
||||
{
|
||||
draw_highlight(&clipped, &xf, &list, &prims_of(&list, &[h]), t.hover, 2.0, false);
|
||||
let tolerance = viewport_highlight_tolerance(scale);
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), &hs).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
if app.canvas.vp_hl.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
let built = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() });
|
||||
app.canvas.vp_hl = Some((key, built));
|
||||
}
|
||||
if let Some((_, list)) = &app.canvas.vp_hl {
|
||||
let clipped = painter.with_clip_rect(r.intersect(rect));
|
||||
if let Some(h) = app.canvas.hover
|
||||
&& !sel.contains(&h)
|
||||
{
|
||||
draw_highlight(&clipped, &xf, list, &prims_of(list, &[h]), t.hover, 2.0, false);
|
||||
}
|
||||
draw_highlight(&clipped, &xf, list, &prims_of(list, &sel), t.selection, 1.5, true);
|
||||
}
|
||||
draw_highlight(&clipped, &xf, &list, &prims_of(&list, &sel), t.selection, 1.5, true);
|
||||
}
|
||||
}
|
||||
} else if let CanvasState { list: Some(list), list_gen, prim_index, hover, .. } = &mut app.canvas {
|
||||
} else if let CanvasState { list: Some(list), list_gen, prim_index, hover, gpu, hl_key, hl_slot, hl_origin, hl_infinite, .. } = &mut app.canvas {
|
||||
if let Some(h) = *hover
|
||||
&& !sel.contains(&h)
|
||||
{
|
||||
draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &[h]), t.hover, 2.0, false);
|
||||
}
|
||||
draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true);
|
||||
if gpu.is_some() && !sel.is_empty() {
|
||||
draw_highlight_gpu(&painter, &xf, list, *list_gen, prim_index, &sel, t.selection, 1.5, (hl_key, hl_slot, hl_origin, &mut *hl_infinite));
|
||||
draw_highlight(&painter, &xf, list, hl_infinite, t.selection, 1.5, true);
|
||||
} else {
|
||||
draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true);
|
||||
}
|
||||
}
|
||||
app.canvas.draw_ms = crate::now_ms() - t0;
|
||||
compare::paint(app, ui.ctx(), &painter, &xf_paper, rect);
|
||||
@@ -973,7 +1187,23 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
// Rubber band preview of the active command.
|
||||
if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) {
|
||||
// `xf` is the space being edited: paper space, or model space inside the active viewport.
|
||||
let drag = match (app.canvas.cursor, app.canvas.gpu.is_some()) {
|
||||
(Some(c), true) => app.session.drag(c),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((drag, extra)) = drag {
|
||||
if let Ok(st) = app.session.state() {
|
||||
let serial = app.session.clipboard_source.as_ref().map_or(0, |c| c.serial);
|
||||
draw_drag_gpu(&mut app.canvas, &painter, &xf, &st.doc, &drag, (&app.session.clipboard, serial), bg);
|
||||
let list = cadcraft_render::build_entities(
|
||||
&st.doc,
|
||||
extra.iter(),
|
||||
&cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() },
|
||||
);
|
||||
draw_list(&painter, &xf, &list, bg, false);
|
||||
}
|
||||
} else if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) {
|
||||
let ents = app.session.preview(c);
|
||||
if !ents.is_empty()
|
||||
&& let Ok(st) = app.session.state()
|
||||
@@ -1056,6 +1286,11 @@ fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine::ModelTile
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The world ↔ screen transform of a model tile drawn in `r`.
|
||||
fn tile_xf(r: Rect, tile: &cadcraft_engine::ModelTile) -> Xf {
|
||||
Xf { rect: r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) }
|
||||
}
|
||||
|
||||
/// The tiles other than the current one (each with its own view, clipped to it), then the
|
||||
/// tile borders: thin, and heavy around the current tile.
|
||||
fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::ModelTile)]) {
|
||||
@@ -1063,7 +1298,7 @@ fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::Mode
|
||||
let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b());
|
||||
for (r, tile) in tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0) {
|
||||
let p = ui.painter_at(*r);
|
||||
let xf = Xf { rect: *r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) };
|
||||
let xf = tile_xf(*r, tile);
|
||||
p.rect_filled(*r, 0.0, t.canvas);
|
||||
draw_grid(app, &p, &xf);
|
||||
if let Some(list) = &app.canvas.list {
|
||||
@@ -1520,6 +1755,15 @@ mod tests {
|
||||
v.iter().map(|&h| Handle(h)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_highlight_tolerance_is_constant_within_a_zoom_band() {
|
||||
assert_eq!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(3.0));
|
||||
assert_ne!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(1.5));
|
||||
for s in [0.3, 1.0, 3.0, 4.0, 250.0] {
|
||||
assert!(viewport_highlight_tolerance(s) <= 0.5 / s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prim_index_matches_a_scan_and_follows_list_rebuilds() {
|
||||
let a = list(&[3, 1, 3, 2, 7, 3]);
|
||||
@@ -1533,6 +1777,49 @@ mod tests {
|
||||
let b = list(&[3, 9]);
|
||||
assert_eq!(ix.prims(2, &b, &hs(&[9, 3])), vec![0, 1]);
|
||||
}
|
||||
|
||||
fn wheel(unit: egui::MouseWheelUnit, dy: f32, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }
|
||||
}
|
||||
|
||||
/// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways
|
||||
/// scroll) don't zoom here, and every notch of the frame counts (#660).
|
||||
#[test]
|
||||
fn wheel_zoom_reads_every_notch_of_the_frame() {
|
||||
use egui::{Modifiers, MouseWheelUnit as U};
|
||||
let o = egui::InputOptions::default();
|
||||
let none = Modifiers::NONE;
|
||||
assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, none)], &o, 500.0), o.line_scroll_speed);
|
||||
assert_eq!(wheel_zoom_points(&[wheel(U::Line, -2.0, none)], &o, 500.0), -2.0 * o.line_scroll_speed);
|
||||
assert_eq!(wheel_zoom_points(&[wheel(U::Point, 7.5, none)], &o, 500.0), 7.5);
|
||||
assert_eq!(wheel_zoom_points(&[wheel(U::Page, 1.0, none)], &o, 500.0), 500.0);
|
||||
// Three quick notches in one frame zoom three steps at once.
|
||||
let three = [wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none), wheel(U::Line, 1.0, none)];
|
||||
assert_eq!(wheel_zoom_points(&three, &o, 500.0), 3.0 * o.line_scroll_speed);
|
||||
assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::COMMAND)], &o, 500.0), 0.0);
|
||||
assert_eq!(wheel_zoom_points(&[wheel(U::Line, 1.0, Modifiers::SHIFT)], &o, 500.0), 0.0);
|
||||
assert_eq!(wheel_zoom_points(&[wheel(U::Line, f32::NAN, none)], &o, 500.0), 0.0);
|
||||
assert_eq!(wheel_zoom_points(&[egui::Event::PointerGone], &o, 500.0), 0.0);
|
||||
}
|
||||
|
||||
/// The frame a notch arrives, egui's smoothed scroll holds only part of it (the rest trickles
|
||||
/// in over later frames, which made quick notches lag), while the raw reading zooms the
|
||||
/// whole notch at once (#660).
|
||||
#[test]
|
||||
fn a_wheel_notch_zooms_whole_in_its_own_frame() {
|
||||
let ctx = egui::Context::default();
|
||||
let o = ctx.options(|o| o.input_options);
|
||||
let input = egui::RawInput { events: vec![wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE)], ..Default::default() };
|
||||
let mut seen = (0.0, 0.0);
|
||||
ctx.run_ui(input, |ui| {
|
||||
seen = ui.input(|i| (i.smooth_scroll_delta.y, wheel_zoom_points(&i.raw.events, &o, 500.0)));
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
let (smoothed, raw) = seen;
|
||||
assert_eq!(raw, o.line_scroll_speed, "the whole notch");
|
||||
assert!(smoothed < raw, "egui's smoothed delta lags behind: {smoothed} of {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The COMPARE overlay.
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
response.on_hover_text("More toolbar commands");
|
||||
response.on_hover_text(crate::tl!("More toolbar commands"));
|
||||
}
|
||||
if let Some(c) = clicked {
|
||||
app.start(c);
|
||||
@@ -192,55 +192,59 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
app.ui.start_tab = false;
|
||||
}
|
||||
x += 24.0;
|
||||
let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) {
|
||||
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
|
||||
let resp = ui.interact(tr, id, Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
tr,
|
||||
0.0,
|
||||
if active {
|
||||
t.tab_active
|
||||
} else if resp.hovered() {
|
||||
t.chrome
|
||||
} else {
|
||||
t.chrome_dark
|
||||
},
|
||||
);
|
||||
ui.painter().text(
|
||||
pos2(tr.left() + 8.0, tr.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
label,
|
||||
crate::theme::body(),
|
||||
if active { t.text } else { t.text_dim },
|
||||
);
|
||||
let mut close = false;
|
||||
if closable && (active || resp.hovered()) {
|
||||
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
|
||||
let cresp = ui.interact(cr, id.with("x"), Sense::click());
|
||||
icons::paint(ui.painter(), cr, Icon::Close, false);
|
||||
icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false);
|
||||
close = cresp.clicked();
|
||||
}
|
||||
icons::describe_control(ui, &resp, label, Some(active), true);
|
||||
ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border));
|
||||
x += w;
|
||||
(resp.clicked(), close, resp)
|
||||
};
|
||||
let (c, _, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
|
||||
// `label` is the drawing's name; `dirty` adds the unsaved-changes mark, kept visible when a
|
||||
// long name is cut short.
|
||||
let mut tab =
|
||||
|ui: &mut egui::Ui, label: &str, dirty: bool, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool, egui::Response) {
|
||||
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
|
||||
let resp = ui.interact(tr, id, Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
tr,
|
||||
0.0,
|
||||
if active {
|
||||
t.tab_active
|
||||
} else if resp.hovered() {
|
||||
t.chrome
|
||||
} else {
|
||||
t.chrome_dark
|
||||
},
|
||||
);
|
||||
// The name ends in "…" when it doesn't fit before the close button (#513).
|
||||
let color = if active { t.text } else { t.text_dim };
|
||||
let mark = if dirty { "*" } else { "" };
|
||||
let text_w = (w - 8.0 - if closable { 24.0 } else { 8.0 }).max(0.0);
|
||||
let mark_w = ui.painter().layout_no_wrap(mark.to_string(), crate::theme::body(), color).size().x;
|
||||
let (galley, cut) = elided(ui.painter(), label, crate::theme::body(), color, text_w - mark_w);
|
||||
let pos = pos2(tr.left() + 8.0, tr.center().y - galley.size().y / 2.0);
|
||||
let name_w = galley.size().x;
|
||||
ui.painter().galley(pos, galley, color);
|
||||
if dirty {
|
||||
ui.painter().text(pos2(pos.x + name_w, tr.center().y), egui::Align2::LEFT_CENTER, mark, crate::theme::body(), color);
|
||||
}
|
||||
let resp = if cut { resp.on_hover_text(format!("{label}{mark}")) } else { resp };
|
||||
let mut close = false;
|
||||
if closable && (active || resp.hovered()) {
|
||||
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
|
||||
let cresp = ui.interact(cr, id.with("x"), Sense::click());
|
||||
icons::paint(ui.painter(), cr, Icon::Close, false);
|
||||
icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false);
|
||||
close = cresp.clicked();
|
||||
}
|
||||
icons::describe_control(ui, &resp, label, Some(active), true);
|
||||
ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border));
|
||||
x += w;
|
||||
(resp.clicked(), close, resp)
|
||||
};
|
||||
let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
|
||||
if c {
|
||||
app.ui.start_tab = true;
|
||||
}
|
||||
let mut switch_to = None;
|
||||
let mut close = None;
|
||||
let titles: Vec<(usize, String, bool)> = app
|
||||
.session
|
||||
.docs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| (i, format!("{}{}", d.title, if d.is_dirty() { "*" } else { "" }), i == app.session.active))
|
||||
.collect();
|
||||
for (i, title, active) in titles {
|
||||
let (c, x, resp) = tab(ui, &title, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
|
||||
let titles: Vec<(usize, String, bool, bool)> =
|
||||
app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect();
|
||||
for (i, title, dirty, active) in titles {
|
||||
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
|
||||
crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i));
|
||||
if c {
|
||||
switch_to = Some(i);
|
||||
@@ -259,6 +263,16 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `text` laid out on one line no wider than `max_width`, ending in "…" when it had to be cut;
|
||||
/// and whether it was.
|
||||
fn elided(p: &egui::Painter, text: &str, font: egui::FontId, color: egui::Color32, max_width: f32) -> (std::sync::Arc<egui::Galley>, bool) {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: max_width.max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
let galley = p.layout_job(job);
|
||||
let cut = galley.elided;
|
||||
(galley, cut)
|
||||
}
|
||||
|
||||
/// Status bar: layout tabs on the left, coordinates and drafting toggles on the right.
|
||||
pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
|
||||
+117
-15
@@ -1,6 +1,8 @@
|
||||
//! The floating command line: history, the active prompt with clickable keywords, typed input
|
||||
//! (keyboard focus stays on the drawing — typing anywhere goes here) and AutoComplete.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cadcraft_engine::Input;
|
||||
use egui::{Key, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
|
||||
@@ -90,25 +92,76 @@ fn tab(app: &mut CadApp) {
|
||||
{
|
||||
// Both values typed: Tab locks the second one as well.
|
||||
app.cmd.locked_second = Some(app.cmd.buffer.clone());
|
||||
} else if let Some((id, _)) = suggestions(&app.cmd.buffer).first() {
|
||||
app.cmd.buffer = id.clone();
|
||||
} else if let Some(s) = suggestions(&app.cmd.buffer).first() {
|
||||
app.cmd.buffer = s.id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// An AutoComplete suggestion for the command line.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Suggestion {
|
||||
/// Command identifier (uppercase, e.g. "RECTANG"), used for buffer completion and execution.
|
||||
pub id: String,
|
||||
/// Display text shown in the AutoComplete list (e.g. "REC (RECTANG)" or "RECOVER").
|
||||
pub display: String,
|
||||
/// Human-readable command label (e.g. "Rectangle").
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
/// Matching command names/aliases for AutoComplete.
|
||||
fn suggestions(prefix: &str) -> Vec<(String, &'static str)> {
|
||||
fn suggestions(prefix: &str) -> Vec<Suggestion> {
|
||||
if prefix.is_empty() || prefix.contains(' ') {
|
||||
return Vec::new();
|
||||
}
|
||||
let p = prefix.to_ascii_lowercase();
|
||||
let mut v: Vec<(String, &'static str)> = cadcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| !c.id.contains('.') && (c.id.starts_with(&p) || c.aliases.iter().any(|a| *a == p)))
|
||||
.map(|c| (c.id.to_ascii_uppercase(), c.label))
|
||||
.collect();
|
||||
v.sort_by_key(|(id, _)| (id.len(), id.clone()));
|
||||
v.truncate(8);
|
||||
v
|
||||
|
||||
let mut out: Vec<Suggestion> = Vec::new();
|
||||
let mut seen_ids: HashSet<&'static str> = HashSet::new();
|
||||
|
||||
// 1. Exact match: alias resolution or exact command name match.
|
||||
let resolved = cadcraft_engine::cmd::resolve_alias(&p);
|
||||
if let Some(spec) = cadcraft_engine::cmd::find_command(&resolved)
|
||||
&& !spec.id.contains('.')
|
||||
{
|
||||
let display =
|
||||
if spec.id == p { spec.id.to_ascii_uppercase() } else { format!("{} ({})", p.to_ascii_uppercase(), spec.id.to_ascii_uppercase()) };
|
||||
out.push(Suggestion { id: spec.id.to_ascii_uppercase(), display, label: spec.label });
|
||||
seen_ids.insert(spec.id);
|
||||
}
|
||||
|
||||
// 2. Command ID prefix matches (excluding already matched exact command).
|
||||
let mut id_matches: Vec<&'static cadcraft_engine::CommandSpec> =
|
||||
cadcraft_engine::command_specs().iter().filter(|c| !c.id.contains('.') && !seen_ids.contains(c.id) && c.id.starts_with(&p)).collect();
|
||||
id_matches.sort_by_key(|c| (c.id.len(), c.id));
|
||||
for c in id_matches {
|
||||
out.push(Suggestion { id: c.id.to_ascii_uppercase(), display: c.id.to_ascii_uppercase(), label: c.label });
|
||||
seen_ids.insert(c.id);
|
||||
}
|
||||
|
||||
// 3. Command alias prefix matches (for commands not yet added).
|
||||
let mut alias_matches: Vec<(&'static str, &'static cadcraft_engine::CommandSpec)> = Vec::new();
|
||||
for c in cadcraft_engine::command_specs() {
|
||||
if c.id.contains('.') || seen_ids.contains(c.id) {
|
||||
continue;
|
||||
}
|
||||
let matching_alias = c.aliases.iter().copied().filter(|a| a.starts_with(&p)).min_by_key(|a| (a.len(), *a));
|
||||
if let Some(alias) = matching_alias {
|
||||
alias_matches.push((alias, c));
|
||||
}
|
||||
}
|
||||
alias_matches.sort_by_key(|(alias, c)| (alias.len(), *alias, c.id));
|
||||
for (alias, c) in alias_matches {
|
||||
if seen_ids.insert(c.id) {
|
||||
out.push(Suggestion {
|
||||
id: c.id.to_ascii_uppercase(),
|
||||
display: format!("{} ({})", alias.to_ascii_uppercase(), c.id.to_ascii_uppercase()),
|
||||
label: c.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.truncate(8);
|
||||
out
|
||||
}
|
||||
|
||||
impl CmdLine {
|
||||
@@ -462,23 +515,23 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
);
|
||||
p.rect_filled(lr, 3.0, t.list_bg);
|
||||
p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
|
||||
for (i, (id, label)) in sug.iter().enumerate() {
|
||||
for (i, s) in sug.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(lr.left(), lr.top() + rh * i as f32), vec2(lr.width(), rh));
|
||||
let resp = ui.interact(r, ui.id().with(("sug", i)), Sense::click());
|
||||
if resp.hovered() || i == 0 {
|
||||
p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row });
|
||||
}
|
||||
p.text(Pos2::new(r.left() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, id, crate::theme::body(), t.text);
|
||||
p.text(Pos2::new(r.left() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, &s.display, crate::theme::body(), t.text);
|
||||
p.text(
|
||||
Pos2::new(r.right() - 8.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
crate::i18n::t(label),
|
||||
crate::i18n::t(s.label),
|
||||
crate::theme::small(),
|
||||
t.text_faint,
|
||||
);
|
||||
if resp.clicked() {
|
||||
app.cmd.buffer.clear();
|
||||
app.start(&id.to_ascii_lowercase());
|
||||
app.start(&s.id.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,3 +768,52 @@ mod delete_key_tests {
|
||||
assert!(!c.typed_since_selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod autocomplete_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_alias_is_first_suggestion_with_alias_display() {
|
||||
let rec = suggestions("rec");
|
||||
assert!(!rec.is_empty());
|
||||
assert_eq!(rec[0].id, "RECTANG");
|
||||
assert_eq!(rec[0].display, "REC (RECTANG)");
|
||||
assert_eq!(rec[0].label, "Rectangle");
|
||||
// RECOVER should follow RECTANG, not precede it
|
||||
assert!(rec.iter().any(|s| s.id == "RECOVER"));
|
||||
assert_eq!(rec[1].id, "RECOVER");
|
||||
assert_eq!(rec[1].display, "RECOVER");
|
||||
|
||||
let l = suggestions("l");
|
||||
assert_eq!(l[0].id, "LINE");
|
||||
assert_eq!(l[0].display, "L (LINE)");
|
||||
|
||||
let c = suggestions("c");
|
||||
assert_eq!(c[0].id, "CIRCLE");
|
||||
assert_eq!(c[0].display, "C (CIRCLE)");
|
||||
|
||||
let co = suggestions("co");
|
||||
assert_eq!(co[0].id, "COPY");
|
||||
assert_eq!(co[0].display, "CO (COPY)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_without_alias_shows_command_id() {
|
||||
let rect = suggestions("rect");
|
||||
assert_eq!(rect[0].id, "RECTANG");
|
||||
assert_eq!(rect[0].display, "RECTANG");
|
||||
|
||||
let line = suggestions("line");
|
||||
assert_eq!(line[0].id, "LINE");
|
||||
assert_eq!(line[0].display, "LINE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_autocompletes_alias_to_resolved_command() {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default());
|
||||
app.cmd.buffer = "rec".to_string();
|
||||
tab(&mut app);
|
||||
assert_eq!(app.cmd.buffer, "RECTANG");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! Pan/Zoom, Quick Select, Find, Properties and Options.
|
||||
//! - **While a command runs** ([`command_entries`]): a quick right-click is Enter; holding the
|
||||
//! button for [`HOLD_SECS`] opens Enter, Cancel, Recent Input, the prompt's options, Snap
|
||||
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click).
|
||||
//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). With that turned off
|
||||
//! in OPTIONS (`timeSensitiveRightClick`), every right-click opens the menu.
|
||||
//! - **Shift/Ctrl+right-click** ([`snap_entries`]): the object snap menu. Its items type the point
|
||||
//! modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the current point prompt.
|
||||
//!
|
||||
@@ -302,7 +303,8 @@ struct Press(f64, egui::Pos2);
|
||||
|
||||
/// The canvas's right button while a command runs (Shift/Ctrl+right-click is the snap menu's): a
|
||||
/// quick click is Enter — returns `true` when the caller should send it — and a click held for
|
||||
/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer. Call it every frame
|
||||
/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer (any click does with
|
||||
/// the time-sensitive right-click turned off). Call it every frame
|
||||
/// with whether the pointer is on the canvas and whether a right-click ended there; it also shows
|
||||
/// the menu while it is open.
|
||||
pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool {
|
||||
@@ -323,9 +325,10 @@ pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked:
|
||||
if released && let Some(Press(t0, p0)) = ctx.data_mut(|d| d.remove_temp::<Press>(press_id)) {
|
||||
// A drag isn't a click; a click held long enough asks for the menu.
|
||||
let still = pos.is_some_and(|p| p.distance(p0) <= 6.0);
|
||||
open = running && still && now - t0 >= HOLD_SECS;
|
||||
let held = now - t0 >= HOLD_SECS || !app.options.time_sensitive_right_click;
|
||||
open = running && still && held;
|
||||
}
|
||||
let enter = running && inside && right_clicked && !open;
|
||||
let enter = running && inside && right_clicked && !open && app.options.time_sensitive_right_click;
|
||||
if !running {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let Some(d) = app.ui.dialog.clone() else { return };
|
||||
let mut open = true;
|
||||
match d.as_str() {
|
||||
"spacemouse" => crate::spacemouse::dialog(app, ctx, &mut open),
|
||||
"language" => language(app, ctx, &mut open),
|
||||
"layers" => crate::layers::dialog(app, ctx, &mut open),
|
||||
"qselect" => crate::quick::qselect_dialog(app, ctx, &mut open),
|
||||
@@ -28,7 +29,8 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
o if o == "options" || o.starts_with("options:") => crate::options::dialog(app, ctx, o, &mut open),
|
||||
other => crate::managers::dialog(app, ctx, other, &mut open),
|
||||
}
|
||||
if !open {
|
||||
// A dialog that closed by opening another (Plot Preview back to Plot) keeps the new one.
|
||||
if !open && app.ui.dialog.as_deref() == Some(d.as_str()) {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,34 @@ struct Preview {
|
||||
shown: Result<(TextureHandle, String), String>,
|
||||
}
|
||||
|
||||
/// Render the plot preview of the current layout (or model) as a texture.
|
||||
/// A preview the Plot dialog asked for: its PLOT parameters (PNG output) and a caption.
|
||||
#[derive(Clone)]
|
||||
struct FromPlot {
|
||||
params: Value,
|
||||
info: String,
|
||||
}
|
||||
|
||||
const FROM_PLOT: &str = "cc_plot_preview_from_plot";
|
||||
|
||||
/// Show the Plot dialog's settings as they plot (PLOT with `params`, a PNG); Print... and Close
|
||||
/// return to the dialog.
|
||||
pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, info: String) {
|
||||
PREVIEW_GEN.fetch_add(1, Ordering::Relaxed);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new(FROM_PLOT), FromPlot { params, info }));
|
||||
app.ui.dialog = Some("preview".into());
|
||||
}
|
||||
|
||||
/// Render the plot preview of the current layout (or model), or of the Plot dialog's settings,
|
||||
/// as a texture.
|
||||
fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> {
|
||||
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))) {
|
||||
let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?;
|
||||
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
|
||||
let size = [img.width() as usize, img.height() as usize];
|
||||
let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR);
|
||||
return Ok((tex, f.info));
|
||||
}
|
||||
let r = app.session.execute("preview", &json!({ "width": 1600 })).map_err(|e| e.to_string())?;
|
||||
let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();
|
||||
@@ -111,10 +137,18 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
if close || print {
|
||||
*open = false;
|
||||
}
|
||||
let from_plot = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))).is_some();
|
||||
if !*open {
|
||||
ctx.data_mut(|d| d.remove::<Preview>(id));
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Preview>(id);
|
||||
d.remove::<FromPlot>(egui::Id::new(FROM_PLOT));
|
||||
});
|
||||
}
|
||||
if print {
|
||||
if from_plot && !*open {
|
||||
// Back to the Plot dialog with its settings; Print... plots them.
|
||||
crate::managers::set_flag(ctx, crate::managers::PLOT_NOW, print);
|
||||
app.ui.dialog = Some("plot".into());
|
||||
} else if print {
|
||||
app.start("plot");
|
||||
}
|
||||
}
|
||||
@@ -157,6 +191,9 @@ mod tests {
|
||||
app.cmdline("pre");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("preview"));
|
||||
app.ui.dialog = None;
|
||||
app.start("preview");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("preview"));
|
||||
app.ui.dialog = None;
|
||||
|
||||
let _ = app.run("export", json!({ "format": "svg" }));
|
||||
let _ = app.run("import", json!({}));
|
||||
|
||||
+206
-41
@@ -25,8 +25,9 @@ use egui_wgpu::wgpu::util::DeviceExt;
|
||||
|
||||
/// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8).
|
||||
const VERTEX_SIZE: usize = 12;
|
||||
/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32).
|
||||
const INSTANCE_SIZE: usize = 24;
|
||||
/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32) +
|
||||
/// length along its polyline before `a`, in world units (f32; dashing).
|
||||
const INSTANCE_SIZE: usize = 28;
|
||||
/// Vertices (or instances) per GPU buffer; divisible by 2 and 3 so no primitive straddles two.
|
||||
const CHUNK: usize = 4_194_300;
|
||||
/// Point markers are 1.5 × 1.5 points (as on the CPU path).
|
||||
@@ -127,12 +128,13 @@ fn push_vertex(out: &mut Vec<u8>, p: [f32; 2], c: [u8; 4]) {
|
||||
out.extend_from_slice(&c);
|
||||
}
|
||||
|
||||
fn push_instance(out: &mut Vec<u8>, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32) {
|
||||
fn push_instance(out: &mut Vec<u8>, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32, start: f32) {
|
||||
for v in [a[0], a[1], b[0], b[1]] {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&c);
|
||||
out.extend_from_slice(&width.to_le_bytes());
|
||||
out.extend_from_slice(&start.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Lineweight in screen points for a primitive (as on the CPU path), or `None` for a hairline.
|
||||
@@ -161,7 +163,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
|
||||
Some(w) => {
|
||||
for s in pts.windows(2) {
|
||||
if let [a, b] = s {
|
||||
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w);
|
||||
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +194,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
|
||||
Kind::Point => {
|
||||
if let Some(q) = pts.first() {
|
||||
let p = rel(*q, origin);
|
||||
push_instance(&mut m.quads, p, p, col, POINT_SIZE);
|
||||
push_instance(&mut m.quads, p, p, col, POINT_SIZE, 0.0);
|
||||
}
|
||||
}
|
||||
// Drawn on the CPU each frame (clipped to the view).
|
||||
@@ -202,6 +204,48 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) ->
|
||||
m
|
||||
}
|
||||
|
||||
/// Vertex data drawing primitives `prims` of `list` as `width`-point lines in `color` (the
|
||||
/// selection highlight; dashed by the callback's `dash`). Fills are outlined by their bounding
|
||||
/// box, as on the CPU path; infinite lines are left to the CPU (they are clipped per frame).
|
||||
pub fn build_highlight_mesh(list: &DisplayList, prims: &[usize], origin: Vec2, color: [u8; 4], width: f32) -> MeshData {
|
||||
let mut m = MeshData::default();
|
||||
for prim in prims.iter().filter_map(|&i| list.prims.get(i)) {
|
||||
let pts = list.points(prim);
|
||||
match prim.kind {
|
||||
Kind::Polyline => {
|
||||
let mut run = 0.0;
|
||||
for s in pts.windows(2) {
|
||||
if let [a, b] = s {
|
||||
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, width, run as f32);
|
||||
run += a.dist(*b);
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Point => {
|
||||
if let Some(q) = pts.first() {
|
||||
let p = rel(*q, origin);
|
||||
push_instance(&mut m.quads, p, p, color, 3.0, 0.0);
|
||||
}
|
||||
}
|
||||
Kind::Tris | Kind::Mask => {
|
||||
let bb = Bounds2::from_points(pts.iter().copied());
|
||||
if !bb.is_empty() {
|
||||
let c = [bb.min, Vec2::new(bb.max.x, bb.min.y), bb.max, Vec2::new(bb.min.x, bb.max.y), bb.min];
|
||||
let mut run = 0.0;
|
||||
for s in c.windows(2) {
|
||||
if let [a, b] = s {
|
||||
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), color, 1.0, run as f32);
|
||||
run += a.dist(*b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Infinite { .. } => {}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The vertex origin for a display list seen at `view_center` / `view_height`: the list's centre
|
||||
/// normally; at deep zoom (the view much smaller than the drawing) a point near the view, snapped
|
||||
/// to a coarse grid so panning re-uploads only every few dozen screens.
|
||||
@@ -232,20 +276,32 @@ pub struct CanvasCallback {
|
||||
pub scale: f32,
|
||||
/// Canvas rect centre in points.
|
||||
pub center: [f32; 2],
|
||||
/// Which buffers to draw: [`LAYER_DRAWING`] or [`LAYER_DRAG`].
|
||||
pub layer: usize,
|
||||
/// Affine transform applied to the origin-relative positions before `offset`: the linear
|
||||
/// part (a, b, c, d as in `Mat3`) and the translation. [`IDENTITY_XFORM`] for the drawing.
|
||||
pub xform: [f32; 6],
|
||||
/// Dash period in points for wide lines (0 = solid): the selection highlight.
|
||||
pub dash: f32,
|
||||
}
|
||||
|
||||
/// The drawing's display list.
|
||||
pub const LAYER_DRAWING: usize = 0;
|
||||
/// The objects a MOVE/COPY/ROTATE/SCALE preview drags, uploaded once per drag.
|
||||
pub const LAYER_DRAG: usize = 1;
|
||||
/// The selection highlight, rebuilt only when the selection or the drawing changes.
|
||||
pub const LAYER_HIGHLIGHT: usize = 2;
|
||||
pub const IDENTITY_XFORM: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
|
||||
struct Batch {
|
||||
buffer: wgpu::Buffer,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
line_pipeline: wgpu::RenderPipeline,
|
||||
tri_pipeline: wgpu::RenderPipeline,
|
||||
quad_pipeline: wgpu::RenderPipeline,
|
||||
/// One uploaded mesh with its own view uniform (each callback's transform differs).
|
||||
struct Layer {
|
||||
uniform: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
linear_out: bool,
|
||||
key: Option<u64>,
|
||||
lines: Vec<Batch>,
|
||||
tris: Vec<Batch>,
|
||||
@@ -253,23 +309,36 @@ struct Resources {
|
||||
breaks: Vec<[usize; 3]>,
|
||||
}
|
||||
|
||||
const UNIFORM_SIZE: u64 = 48;
|
||||
struct Resources {
|
||||
line_pipeline: wgpu::RenderPipeline,
|
||||
tri_pipeline: wgpu::RenderPipeline,
|
||||
quad_pipeline: wgpu::RenderPipeline,
|
||||
linear_out: bool,
|
||||
layers: Vec<Layer>,
|
||||
}
|
||||
|
||||
const UNIFORM_SIZE: u64 = 80;
|
||||
|
||||
const SHADER: &str = r#"
|
||||
struct View {
|
||||
a: vec4<f32>, // offset x, offset y (world), scale (device px per unit), 1 = linear output
|
||||
b: vec4<f32>, // canvas centre x, y (device px), screen w, h (device px)
|
||||
c: vec4<f32>, // pixels per point
|
||||
c: vec4<f32>, // pixels per point, dash period in points (0 = solid)
|
||||
d: vec4<f32>, // transform linear part: a, b, c, d (x' = a x + c y, y' = b x + d y)
|
||||
e: vec4<f32>, // transform translation x, y (world, origin-relative)
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> view: View;
|
||||
|
||||
struct VOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) color: vec4<f32>,
|
||||
// Device pixels along a wide line from its start (dashing).
|
||||
@location(1) along: f32,
|
||||
};
|
||||
|
||||
fn to_px(p: vec2<f32>) -> vec2<f32> {
|
||||
let w = (p + view.a.xy) * view.a.z;
|
||||
let q = vec2<f32>(view.d.x * p.x + view.d.z * p.y, view.d.y * p.x + view.d.w * p.y) + view.e.xy;
|
||||
let w = (q + view.a.xy) * view.a.z;
|
||||
return vec2<f32>(view.b.x + w.x, view.b.y - w.y);
|
||||
}
|
||||
|
||||
@@ -291,6 +360,7 @@ fn vs_line(@location(0) pos: vec2<f32>, @location(1) color: vec4<f32>) -> VOut {
|
||||
var o: VOut;
|
||||
o.pos = px_to_clip(to_px(pos));
|
||||
o.color = out_color(color);
|
||||
o.along = 0.0;
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -301,6 +371,7 @@ fn vs_quad(
|
||||
@location(1) b: vec2<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) width: f32,
|
||||
@location(4) start: f32,
|
||||
) -> VOut {
|
||||
let pa = to_px(a);
|
||||
let pb = to_px(b);
|
||||
@@ -319,11 +390,18 @@ fn vs_quad(
|
||||
var o: VOut;
|
||||
o.pos = px_to_clip(along + n * hw * s);
|
||||
o.color = out_color(color);
|
||||
o.along = start * view.a.z + mix(-hw, len + hw, t);
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(v: VOut) -> @location(0) vec4<f32> {
|
||||
if (view.c.y > 0.0) {
|
||||
// 6 on, 4 off (as the CPU selection highlight's dashes).
|
||||
if (fract(v.along / (view.c.y * view.c.x)) > 0.6) {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
return v.color;
|
||||
}
|
||||
"#;
|
||||
@@ -334,7 +412,7 @@ fn vertex_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
}
|
||||
|
||||
fn instance_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
const ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32];
|
||||
const ATTRS: [wgpu::VertexAttribute; 5] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32, 4 => Float32];
|
||||
wgpu::VertexBufferLayout { array_stride: INSTANCE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &ATTRS }
|
||||
}
|
||||
|
||||
@@ -344,7 +422,7 @@ impl Resources {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("cad_canvas"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
};
|
||||
@@ -354,17 +432,20 @@ impl Resources {
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("cad_canvas_view"),
|
||||
size: UNIFORM_SIZE,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("cad_canvas"),
|
||||
layout: &bgl,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
|
||||
});
|
||||
let layer = || {
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("cad_canvas_view"),
|
||||
size: UNIFORM_SIZE,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("cad_canvas"),
|
||||
layout: &bgl,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
|
||||
});
|
||||
Layer { uniform, bind_group, key: None, lines: Vec::new(), tris: Vec::new(), quads: Vec::new(), breaks: Vec::new() }
|
||||
};
|
||||
let pipeline = |label: &str, vs: &str, topology: wgpu::PrimitiveTopology, buffer: wgpu::VertexBufferLayout<'static>| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -396,14 +477,8 @@ impl Resources {
|
||||
line_pipeline: pipeline("cad_lines", "vs_line", wgpu::PrimitiveTopology::LineList, vertex_layout()),
|
||||
tri_pipeline: pipeline("cad_fills", "vs_line", wgpu::PrimitiveTopology::TriangleList, vertex_layout()),
|
||||
quad_pipeline: pipeline("cad_quads", "vs_quad", wgpu::PrimitiveTopology::TriangleList, instance_layout()),
|
||||
uniform,
|
||||
bind_group,
|
||||
linear_out: target.format.is_srgb(),
|
||||
key: None,
|
||||
lines: Vec::new(),
|
||||
tris: Vec::new(),
|
||||
quads: Vec::new(),
|
||||
breaks: Vec::new(),
|
||||
layers: vec![layer(), layer(), layer()],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,9 +494,79 @@ fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create the canvas pipelines in the renderer's callback resources. Call once with the app's
|
||||
/// render state (eframe `CreationContext::wgpu_render_state`).
|
||||
pub fn install(rs: &egui_wgpu::RenderState) -> GpuTarget {
|
||||
/// GPU trouble wgpu reported: uncaptured errors (validation, out of memory) and the device being
|
||||
/// lost (a driver reset or a hung GPU). wgpu's default handler panics on the first uncaptured
|
||||
/// error, closing the app with any unsaved work (#602); the handlers [`install`] registers record
|
||||
/// them here instead, and the app autosaves and tells the user (see `CadApp::gpu_fault_tick`).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GpuFaults {
|
||||
state: Mutex<FaultState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FaultState {
|
||||
errors: u64,
|
||||
errors_seen: u64,
|
||||
lost: bool,
|
||||
lost_seen: bool,
|
||||
last: String,
|
||||
}
|
||||
|
||||
/// What [`GpuFaults::take`] reports.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GpuFault {
|
||||
/// Uncaptured errors so far (an invalid resource can fail again every frame).
|
||||
pub errors: u64,
|
||||
/// The device was lost: nothing more can be drawn until the app restarts.
|
||||
pub lost: bool,
|
||||
/// The latest error or device-lost message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl GpuFaults {
|
||||
/// Errors logged in full before only every 1000th is (a repeating error would flood the log).
|
||||
const LOGGED: u64 = 10;
|
||||
|
||||
pub fn error(&self, message: String) {
|
||||
let Ok(mut s) = self.state.lock() else { return };
|
||||
s.errors += 1;
|
||||
if s.errors <= Self::LOGGED || s.errors % 1000 == 0 {
|
||||
log::error!("wgpu error #{}: {message}", s.errors);
|
||||
}
|
||||
s.last = message;
|
||||
}
|
||||
|
||||
pub fn device_lost(&self, reason: wgpu::DeviceLostReason, message: String) {
|
||||
// `Destroyed` is the device being dropped on purpose (the app closing), not a failure.
|
||||
if reason == wgpu::DeviceLostReason::Destroyed {
|
||||
return;
|
||||
}
|
||||
log::error!("wgpu device lost ({reason:?}): {message}");
|
||||
let Ok(mut s) = self.state.lock() else { return };
|
||||
s.lost = true;
|
||||
s.last = message;
|
||||
}
|
||||
|
||||
/// Faults reported since the last call, if any.
|
||||
pub fn take(&self) -> Option<GpuFault> {
|
||||
let Ok(mut s) = self.state.lock() else { return None };
|
||||
if s.errors == s.errors_seen && s.lost == s.lost_seen {
|
||||
return None;
|
||||
}
|
||||
s.errors_seen = s.errors;
|
||||
s.lost_seen = s.lost;
|
||||
Some(GpuFault { errors: s.errors, lost: s.lost, message: s.last.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the canvas pipelines in the renderer's callback resources, and report wgpu errors and
|
||||
/// device loss to `faults` instead of panicking. Call once with the app's render state (eframe
|
||||
/// `CreationContext::wgpu_render_state`).
|
||||
pub fn install(rs: &egui_wgpu::RenderState, faults: &Arc<GpuFaults>) -> GpuTarget {
|
||||
let f = Arc::clone(faults);
|
||||
rs.device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| f.error(e.to_string())));
|
||||
let f = Arc::clone(faults);
|
||||
rs.device.set_device_lost_callback(move |reason, message| f.device_lost(reason, message));
|
||||
let target = GpuTarget { format: rs.target_format };
|
||||
let res = Resources::new(&rs.device, target);
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
@@ -439,6 +584,8 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
|
||||
let linear_out = res.linear_out;
|
||||
let Some(res) = res.layers.get_mut(self.layer) else { return Vec::new() };
|
||||
if res.key != Some(self.key) {
|
||||
let pending = self.slot.lock().ok().and_then(|mut s| s.take());
|
||||
if let Some(m) = pending {
|
||||
@@ -452,16 +599,20 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
|
||||
}
|
||||
let ppp = screen.pixels_per_point;
|
||||
let [w, h] = screen.size_in_pixels;
|
||||
let a = [self.offset[0], self.offset[1], self.scale * ppp, if res.linear_out { 1.0 } else { 0.0 }];
|
||||
let a = [self.offset[0], self.offset[1], self.scale * ppp, if linear_out { 1.0 } else { 0.0 }];
|
||||
let b = [self.center[0] * ppp, self.center[1] * ppp, w.max(1) as f32, h.max(1) as f32];
|
||||
let c = [ppp, 0.0, 0.0, 0.0];
|
||||
let bytes: Vec<u8> = a.iter().chain(&b).chain(&c).flat_map(|v| v.to_le_bytes()).collect();
|
||||
let c = [ppp, self.dash, 0.0, 0.0];
|
||||
let [ma, mb, mc, md, tx, ty] = self.xform;
|
||||
let d = [ma, mb, mc, md];
|
||||
let e = [tx, ty, 0.0, 0.0];
|
||||
let bytes: Vec<u8> = a.iter().chain(&b).chain(&c).chain(&d).chain(&e).flat_map(|v| v.to_le_bytes()).collect();
|
||||
queue.write_buffer(&res.uniform, 0, &bytes);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) {
|
||||
let Some(res) = resources.get::<Resources>() else { return };
|
||||
let Some(shared) = resources.get::<Resources>() else { return };
|
||||
let Some(res) = shared.layers.get(self.layer) else { return };
|
||||
if res.key != Some(self.key) {
|
||||
return;
|
||||
}
|
||||
@@ -478,7 +629,7 @@ impl egui_wgpu::CallbackTrait for CanvasCallback {
|
||||
let mut from = [0usize; 3];
|
||||
for to in res.breaks.iter().copied().chain(std::iter::once(all)) {
|
||||
for (i, (pipeline, batches)) in
|
||||
[(&res.tri_pipeline, &res.tris), (&res.line_pipeline, &res.lines), (&res.quad_pipeline, &res.quads)].into_iter().enumerate()
|
||||
[(&shared.tri_pipeline, &res.tris), (&shared.line_pipeline, &res.lines), (&shared.quad_pipeline, &res.quads)].into_iter().enumerate()
|
||||
{
|
||||
let parts = batch_ranges(batches.iter().map(|b| b.count), from[i]..to[i]);
|
||||
if parts.is_empty() {
|
||||
@@ -564,6 +715,20 @@ mod tests {
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_dashes_run_on_along_each_polyline() {
|
||||
let l = list();
|
||||
// The 3-point polyline (two unit segments) and the point marker.
|
||||
let m = build_highlight_mesh(&l, &[0, 2], Vec2::new(1e6, 1e6), [1, 2, 3, 255], 1.5);
|
||||
assert_eq!(m.quad_instances(), 3);
|
||||
let start = |i: usize| {
|
||||
let o = i * INSTANCE_SIZE + 24;
|
||||
f32::from_le_bytes([m.quads[o], m.quads[o + 1], m.quads[o + 2], m.quads[o + 3]])
|
||||
};
|
||||
assert_eq!([start(0), start(1), start(2)], [0.0, 1.0, 0.0]);
|
||||
assert!(m.lines.is_empty() && m.tris.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_counts_and_precision() {
|
||||
let l = list();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,7 @@
|
||||
Close All Alle schließen
|
||||
Closed Geschlossen
|
||||
Coincident Koinzident
|
||||
Collapse Palettes Paletten einklappen
|
||||
Collapse Tool Sets Werkzeuggruppen einklappen
|
||||
Collinear Kollinear
|
||||
Color Farbe
|
||||
@@ -523,6 +524,7 @@
|
||||
Plot Plotten
|
||||
Plot (click: do not plot) Plotten (Klick: nicht plotten)
|
||||
Plot Preview Plotvorschau
|
||||
Plot Window Plotfenster
|
||||
Plot style Plotstil
|
||||
Plot style attached to Plotstil zugeordnet zu
|
||||
Plot style table Plotstiltabelle
|
||||
@@ -993,3 +995,23 @@
|
||||
Hide Others Andere ausblenden
|
||||
Accessibility Bedienungshilfen
|
||||
Show System Cursor Systemzeiger anzeigen
|
||||
3D Mouse... 3D-Maus...
|
||||
3D Mouse 3D-Maus
|
||||
Calibrate 3D Mouse 3D-Maus kalibrieren
|
||||
3D Mouse Navigation Navigation mit der 3D-Maus
|
||||
3D mouse input is unavailable on this platform 3D-Mauseingabe ist auf dieser Plattform nicht verfügbar
|
||||
No supported 3D mouse found Keine unterstützte 3D-Maus gefunden
|
||||
Release the 3D mouse to calibrate its resting position 3D-Maus loslassen, um ihre Ruheposition zu kalibrieren
|
||||
3D mouse connected 3D-Maus verbunden
|
||||
3D mouse busy, possibly 3DxWare or missing device permissions 3D-Maus belegt, möglicherweise durch 3DxWare oder fehlende Geräteberechtigungen
|
||||
3D mouse disconnected 3D-Maus getrennt
|
||||
3D mouse input could not be started 3D-Mauseingabe konnte nicht gestartet werden
|
||||
Enable 3D mouse navigation Navigation mit der 3D-Maus aktivieren
|
||||
Pan sensitivity Empfindlichkeit beim Verschieben
|
||||
Zoom sensitivity Zoom-Empfindlichkeit
|
||||
Reverse zoom direction Zoomrichtung umkehren
|
||||
Calibrate Kalibrieren
|
||||
Move sideways to pan; push/pull or twist to zoom. Seitlich bewegen zum Verschieben; drücken, ziehen oder drehen zum Zoomen.
|
||||
Left device button: zoom extents. Right device button: calibrate. Linke Gerätetaste: auf Grenzen zoomen. Rechte Gerätetaste: kalibrieren.
|
||||
Hatch Pattern Schraffurmuster
|
||||
Search for pattern Muster suchen
|
||||
|
||||
|
+259
-18
@@ -27,22 +27,34 @@
|
||||
<> Not Equal <> Distinto de
|
||||
= Equals = Igual a
|
||||
> Greater than > Mayor que
|
||||
A drawing opened from a file keeps that file's format. Un dibujo abierto desde un archivo conserva el formato de ese archivo.
|
||||
A variable name starts with a letter and has only letters, digits and _ El nombre de una variable empieza por una letra y solo lleva letras, dígitos y _
|
||||
About Acerca de
|
||||
About CADCraft Acerca de CADCraft
|
||||
Absolute Absolutas
|
||||
Add Current Scale Añadir escala actual
|
||||
Add Leader Añadir directriz
|
||||
Add Vertex Añadir vértice
|
||||
Add/Delete Scales... Añadir/eliminar escalas...
|
||||
Additional angles Ángulos adicionales
|
||||
Align Alinear
|
||||
Align Text Alinear texto
|
||||
Aligned Alineada
|
||||
All Todas
|
||||
All Annotation Objects Todos los objetos de anotación
|
||||
All Scales Todas las escalas
|
||||
All Used Layers Todas las capas usadas
|
||||
All files Todos los archivos
|
||||
An array that would create more objects than this is refused. Se rechaza una matriz que crearía más objetos que este límite.
|
||||
Angle Ángulo
|
||||
Angular Angular
|
||||
Annotation Visibility Visibilidad de anotaciones
|
||||
Annotation scale Escala de anotación
|
||||
Annotative Object Scale Escala de objeto anotativo
|
||||
Annotative Update Actualizar anotativos
|
||||
Apparent Intersection Intersección aparente
|
||||
Append to current selection set Añadir al conjunto de selección actual
|
||||
Apply Aplicar
|
||||
Apply to: Aplicar a:
|
||||
Apply true color Aplicar color verdadero
|
||||
Arc Arco
|
||||
@@ -51,6 +63,7 @@
|
||||
Arrange Panel Organizar panel
|
||||
Array Matriz
|
||||
Attach Adjuntar
|
||||
Attach DWG... Adjuntar DWG...
|
||||
Attribute Atributo
|
||||
Attribute Definition Definición de atributo
|
||||
Attribute Display Visualización de atributos
|
||||
@@ -58,12 +71,18 @@
|
||||
Auto (system language) Automático (idioma del sistema)
|
||||
Auto Constrain Restricciones automáticas
|
||||
AutoConstrain Restricciones automáticas
|
||||
Automatic save every Guardado automático cada
|
||||
Automatic save file location Ubicación de los archivos de guardado automático
|
||||
Axis, End Eje, extremo
|
||||
B N
|
||||
Base Base
|
||||
Baseline Línea base
|
||||
Batch Publish Publicar en lote
|
||||
Batch Publish... Publicar en lote...
|
||||
Binary assets added Recursos binarios añadidos
|
||||
Binary assets removed Recursos binarios eliminados
|
||||
Bind Unir
|
||||
Bind... Unir...
|
||||
Blend Fusión de curvas
|
||||
Block Bloque
|
||||
Block Attribute Manager... Administrador de atributos de bloque...
|
||||
@@ -79,11 +98,16 @@
|
||||
Box Prisma rectangular
|
||||
Break Partir
|
||||
Break At Point Partir en un punto
|
||||
Bring Above Objects Traer por encima de objetos
|
||||
Bring Annotations to Front Traer las anotaciones al frente
|
||||
Bring to Front Traer al frente
|
||||
Bulleted list Lista con viñetas
|
||||
ByBlock PorBloque
|
||||
ByColor PorColor
|
||||
ByLayer PorCapa
|
||||
CADCraft CADCraft
|
||||
CADCraft Help Ayuda de CADCraft
|
||||
CADCraft keeps these preferences, object snap modes and drafting toggles for your user account and restores them when it starts. CADCraft guarda estas preferencias, los modos de referencia a objetos y los ajustes de dibujo para tu cuenta de usuario y los restaura al iniciarse.
|
||||
Cancel Cancelar
|
||||
Cartesian (x, y) Cartesianas (x, y)
|
||||
Center Centro
|
||||
@@ -100,28 +124,41 @@
|
||||
Chamfer Chaflán
|
||||
Change Space Cambiar espacio
|
||||
Change to Current Layer Cambiar a la capa actual
|
||||
Changed: {n} Modificados: {n}
|
||||
Changes apply at once and are kept for next time. Los cambios se aplican al instante y se conservan para la próxima vez.
|
||||
Circle Círculo
|
||||
Circumference Circunferencia
|
||||
Clear Limpiar
|
||||
Clear Command History Borrar el historial de comandos
|
||||
Clear History Borrar historial
|
||||
Clip Delimitar
|
||||
Clip Viewport Delimitar ventana gráfica
|
||||
Clipboard Portapapeles
|
||||
Close Cerrar
|
||||
Close All Cerrar todo
|
||||
Close All Other Drawings Cerrar todos los demás dibujos
|
||||
Close Array Editing Cerrar la edición de matriz
|
||||
Close Comparison Cerrar comparación
|
||||
Closed Cerrada
|
||||
Coincident Coincidente
|
||||
Collapse Tool Sets Contraer herramientas de dibujo
|
||||
Collapse Palettes Contraer paletas
|
||||
Collapse Tool Sets Contraer conjuntos de herramientas
|
||||
Collinear Colineal
|
||||
Color Color
|
||||
Color 8 Color 8
|
||||
Color 9 Color 9
|
||||
Color Index Índice de color
|
||||
Color... Color...
|
||||
Command Aliases Alias de comandos
|
||||
Command History Historial de comandos
|
||||
Command Line Línea de comandos
|
||||
Command Line History Lines Líneas del historial de comandos
|
||||
Command Reference Referencia de comandos
|
||||
Command line history Historial de la línea de comandos
|
||||
Commits Commits
|
||||
Company Empresa
|
||||
Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Diseño y dibujo asistidos por computadora: una aplicación CAD de código abierto, desarrollada de forma independiente en Rust.
|
||||
Compare Drawings... Comparar dibujos...
|
||||
Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Diseño y dibujo asistidos por computadora: una aplicación CAD de código abierto, desarrollada de forma independiente y escrita íntegramente en Rust.
|
||||
Concentric Concéntrica
|
||||
Cone Cono
|
||||
Constraint Bars Barras de restricciones
|
||||
@@ -131,15 +168,25 @@
|
||||
Continue Continuar
|
||||
Contributors Colaboradores
|
||||
Control Vertices Vértices de control
|
||||
Convert Convertir
|
||||
Convert to Arc Convertir en arco
|
||||
Convert to Line Convertir en línea
|
||||
Copy Copiar
|
||||
Copy Full File Path Copiar la ruta completa del archivo
|
||||
Copy History Copiar historial
|
||||
Copy Layout Copiar presentación
|
||||
Copy Nested Objects Copiar objetos anidados
|
||||
Copy Selection Copiar selección
|
||||
Copy as JSON Copiar como JSON
|
||||
Copy with Base Point Copiar con punto base
|
||||
Count Contar
|
||||
Count List Lista de recuento
|
||||
Count Table Tabla de recuento
|
||||
Create Block Crear bloque
|
||||
Creates a user parameter Crea un parámetro de usuario
|
||||
Ctrl+Enter to finish Ctrl+Enter para terminar
|
||||
Crosshair size Tamaño del cursor en cruz
|
||||
Ctrl+Enter to finish Ctrl+Intro para terminar
|
||||
Current Scale Only Solo la escala actual
|
||||
Current layer Capa actual
|
||||
Current selection Selección actual
|
||||
Current: {color} Actual: {color}
|
||||
@@ -147,17 +194,25 @@
|
||||
Cyan Cian
|
||||
Cylinder Cilindro
|
||||
DWG DWG
|
||||
DWG Reference... Referencia DWG...
|
||||
DXF DXF
|
||||
Dark Oscuro
|
||||
Data Extraction... Extracción de datos...
|
||||
Data Link... Vínculo de datos...
|
||||
Default Predeterminado
|
||||
Default Save Format Formato de guardado predeterminado
|
||||
Default Save Version Versión de guardado predeterminada
|
||||
Default save version Versión de guardado predeterminada
|
||||
Define Attributes Definir atributos
|
||||
Define Attributes... Definir atributos...
|
||||
Defined width Anchura definida
|
||||
Degree Grado
|
||||
Degrees of Freedom Grados de libertad
|
||||
Degrees of freedom: {n} Grados de libertad: {n}
|
||||
Delete Eliminar
|
||||
Delete Column Eliminar columna
|
||||
Delete Constraints Eliminar restricciones
|
||||
Delete Current Scale Eliminar escala actual
|
||||
Delete Dimension Style Eliminar estilo de cota
|
||||
Delete Duplicate Objects Eliminar objetos duplicados
|
||||
Delete Layer Eliminar capa
|
||||
@@ -169,9 +224,13 @@
|
||||
Delta Y Incremento Y
|
||||
Description Descripción
|
||||
Deselect All Deseleccionar todo
|
||||
Detach Desenlazar
|
||||
Diameter Diámetro
|
||||
Dim style Estilo de cota
|
||||
Dimension Acotación
|
||||
Dimension Break Partir cota
|
||||
Dimension Edit Editar cota
|
||||
Dimension Objects Only Solo objetos de cota
|
||||
Dimension Space Espaciado de cotas
|
||||
Dimension Style Override Modificar estilo de cota
|
||||
Dimension Style... Estilo de cota...
|
||||
@@ -181,6 +240,7 @@
|
||||
Dimensional Constraint Parameters Parámetros de restricciones dimensionales
|
||||
Dimensional Constraints Restricciones dimensionales
|
||||
Disassociate Dimensions Desasociar cotas
|
||||
Display Visualización
|
||||
Display name Nombre visible
|
||||
Distance Distancia
|
||||
Divide Dividir
|
||||
@@ -196,23 +256,32 @@
|
||||
Drawing (DWG) Dibujo (DWG)
|
||||
Drawing (DXF) Dibujo (DXF)
|
||||
Drawing Limits Límites del dibujo
|
||||
Drawing Recovery Manager... Administrador de recuperación de dibujos...
|
||||
Drawing Template (DWT) Plantilla de dibujo (DWT)
|
||||
Drawing Utilities Utilidades de dibujo
|
||||
Drawings (DWG, DXF) Dibujos (DWG, DXF)
|
||||
Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Los dibujos que aún no tienen archivo se guardan en la versión predeterminada. 0 minutos desactiva el guardado automático.
|
||||
Dynamic Dinámico
|
||||
Dynamic Dimensions Cotas dinámicas
|
||||
Dynamic Input Entrada dinámica
|
||||
Dynamic Input (F12) Entrada dinámica (F12)
|
||||
E E
|
||||
Edit Edición
|
||||
Edit Array Editar matriz
|
||||
Edit Attributes... Editar atributos...
|
||||
Edit Table Cell Editar celda de tabla
|
||||
Edit Text... Editar texto...
|
||||
Elevation Elevación
|
||||
Ellipse Elipse
|
||||
Elliptical Arc Arco elíptico
|
||||
Enable Dynamic Input (F12) Activar entrada dinámica (F12)
|
||||
End Object Isolation Terminar aislamiento de objetos
|
||||
End Width Anchura final
|
||||
End X X final
|
||||
End Y Y final
|
||||
End angle Ángulo final
|
||||
Endpoint Punto final
|
||||
Enter Intro
|
||||
Enter Top, Zero or Bottom. Introduce Top, Zero o Bottom.
|
||||
Enter a fit data option Introduce una opción de datos de ajuste
|
||||
Enter a pattern name Introduce un nombre de patrón
|
||||
@@ -259,14 +328,19 @@
|
||||
Entire drawing Todo el dibujo
|
||||
Equal Igual
|
||||
Erase Borrar
|
||||
Evaluate Evaluar
|
||||
Exclude from new selection set Excluir del nuevo conjunto de selección
|
||||
Explode Descomponer
|
||||
Export Exportar
|
||||
Export Table Exportar tabla
|
||||
Export to PDF... Exportar a PDF...
|
||||
Export... Exportar...
|
||||
Expression Expresión
|
||||
Extend Alargar
|
||||
Extension Extensión
|
||||
Extents Extensión
|
||||
External Reference Referencia externa
|
||||
External References Referencias externas
|
||||
Extrude Extruir
|
||||
File Archivo
|
||||
File Tab Pestañas de archivos
|
||||
@@ -289,6 +363,7 @@
|
||||
Freeze/Thaw in all viewports Inutilizar/reutilizar en todas las ventanas gráficas
|
||||
Freeze/Thaw in the current viewport Inutilizar/reutilizar en la ventana gráfica actual
|
||||
From Desde
|
||||
From Template... Desde plantilla...
|
||||
General General
|
||||
Geometric Center Centro geométrico
|
||||
Geometric Constraint Restricción geométrica
|
||||
@@ -296,24 +371,32 @@
|
||||
Geometry Geometría
|
||||
Get Variable Consultar variable
|
||||
Get View Consultar vista
|
||||
Get from the Drawing Obtener del dibujo
|
||||
Global Global
|
||||
Global width Anchura global
|
||||
Grab bag Lista
|
||||
Grab bag Miscelánea
|
||||
Gradient Degradado
|
||||
Gradient... Degradado...
|
||||
Green Verde
|
||||
Grid (F7) Rejilla (F7)
|
||||
Grid Display Mostrar rejilla
|
||||
Grid On (F7) Activar rejilla (F7)
|
||||
Grid snap Forzar cursor a la rejilla
|
||||
Grid spacing Espaciado de rejilla
|
||||
Grip Edit Editar con pinzamientos
|
||||
Grip Mirror Simetría con pinzamientos
|
||||
Grip Option Opción de pinzamiento
|
||||
Grip Rotate Girar con pinzamientos
|
||||
Grip Scale Escalar con pinzamientos
|
||||
Grip Stretch Estirar con pinzamientos
|
||||
Grip size Tamaño de los pinzamientos
|
||||
Group Agrupar
|
||||
Group Edit Editar grupo
|
||||
Group Options Opciones de grupo
|
||||
Group Selection Selección de grupo
|
||||
Hatch Sombreado
|
||||
Hatch Edit Editar sombreado
|
||||
Hatch Patterns Patrones de sombreado
|
||||
Hatch... Sombreado...
|
||||
Height Altura
|
||||
Height: {height} Altura: {height}
|
||||
@@ -323,24 +406,32 @@
|
||||
Hide All Constraint Bars Ocultar todas las barras de restricciones
|
||||
Hide All Dynamic Dimensions Ocultar todas las cotas dinámicas
|
||||
Hide Layer List Ocultar lista de capas
|
||||
Hide Objects Ocultar objetos
|
||||
Hide Palette Ocultar paleta
|
||||
Hide Palettes Ocultar paletas
|
||||
History Historial
|
||||
Hold Shift to add to the selection Mantener pulsada Mayús para añadir a la selección
|
||||
Home Inicio
|
||||
Horizontal Horizontal
|
||||
HorizontalDistance Distancia horizontal
|
||||
How to apply: Cómo aplicar:
|
||||
ID Point Coordenadas de un punto
|
||||
Import Importar
|
||||
Import... Importar...
|
||||
In Acercar
|
||||
In-window Menu Bar Barra de menús en la ventana
|
||||
Include in new selection set Incluir en el nuevo conjunto de selección
|
||||
Increment angle Incremento angular
|
||||
Index Color Color indexado
|
||||
Index color: {index} Color de índice: {index}
|
||||
Index color: {index} Color indexado: {index}
|
||||
Infer Constraints Inferir restricciones
|
||||
Inquiry Consultar
|
||||
Insert Insertar
|
||||
Insert Block Insertar bloque
|
||||
Insert Column Insertar columna
|
||||
Insert Row Insertar fila
|
||||
Insert Table Formula Insertar fórmula en tabla
|
||||
Insert tab Insertar tabulación
|
||||
Insert this block Insertar este bloque
|
||||
Insertion Inserción
|
||||
Insertion X Inserción X
|
||||
@@ -348,17 +439,27 @@
|
||||
Insertion Z Inserción Z
|
||||
Inspect Constraints Inspeccionar restricciones
|
||||
Inspect Drawing Inspeccionar dibujo
|
||||
Inspection Inspección
|
||||
Interface Language Idioma de la interfaz
|
||||
Interface Theme Tema de la interfaz
|
||||
Interface language Idioma de la interfaz
|
||||
Interface theme Tema de la interfaz
|
||||
Intersect Intersección
|
||||
Intersection Intersección
|
||||
Isolate Aislar
|
||||
Isolate Layer Aislar capa
|
||||
Isolate Objects Aislar objetos
|
||||
Isometric Drafting Dibujo isométrico
|
||||
Isometric Plane Plano isométrico
|
||||
Isometric snap Forzar cursor isométrico
|
||||
Jogged Con recodo
|
||||
Jogged Linear Lineal con recodo
|
||||
Join Unir
|
||||
Join the ArtCraft Discord… Únete al Discord de ArtCraft…
|
||||
Join us on Discord Únete a nosotros en Discord
|
||||
Keep the system cursor visible over the drawing area Mantener visible el cursor del sistema sobre el área de dibujo
|
||||
Language Idioma
|
||||
Largest array Matriz más grande
|
||||
Last Último
|
||||
Last commit Último commit
|
||||
Layer Capa
|
||||
@@ -367,8 +468,8 @@
|
||||
Layer Merge Fusionar capas
|
||||
Layer Off Desactivar capa
|
||||
Layer Properties Propiedades de capa
|
||||
Layer Properties Manager Administrador de capas
|
||||
Layer Properties Manager — current viewport Administrador de capas — ventana gráfica actual
|
||||
Layer Properties Manager Administrador de propiedades de capas
|
||||
Layer Properties Manager — current viewport Administrador de propiedades de capas — ventana gráfica actual
|
||||
Layer State Estado de capas
|
||||
Layer States Manager... Administrador de estados de capas...
|
||||
Layer Tools Herramientas de capas
|
||||
@@ -379,7 +480,9 @@
|
||||
Layers displayed: {shown} of {total}. Current layer: {layer} Capas mostradas: {shown} de {total}. Capa actual: {layer}
|
||||
Layout Presentación
|
||||
Layout list Lista de presentaciones
|
||||
Layout paper Papel de la presentación
|
||||
Leader Directriz
|
||||
Leaders Only Solo directrices
|
||||
Left Izquierda
|
||||
Length Longitud
|
||||
Length X Longitud X
|
||||
@@ -392,9 +495,12 @@
|
||||
Lines +/− Líneas +/−
|
||||
Lines added Líneas añadidas
|
||||
Lines deleted Líneas eliminadas
|
||||
Lines of Command History Líneas del historial de comandos
|
||||
Linetype Tipo de línea
|
||||
Linetype Scale Escala del tipo de línea
|
||||
Linetype scale Escala del tipo de línea
|
||||
Loaded Cargada
|
||||
Lock viewport Bloquear ventana gráfica
|
||||
Ltscale Escala de tipo de línea
|
||||
Linetype... Tipo de línea...
|
||||
Lineweight Grosor de línea
|
||||
@@ -414,13 +520,15 @@
|
||||
Lock Layer Bloquear capa
|
||||
Lock/Unlock Bloquear/desbloquear
|
||||
Loft Solevación
|
||||
MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT O Apache-2.0. Sin afiliación con Autodesk, Inc.
|
||||
MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Sin afiliación con Autodesk, Inc.
|
||||
MODEL MODELO
|
||||
MText Texto multilínea
|
||||
Magenta Magenta
|
||||
Major line every Línea principal cada
|
||||
Make Current Establecer actual
|
||||
Make Object's Layer Current Usar como actual la capa del objeto
|
||||
Make it a block of this drawing, keeping its layer names Convertirla en un bloque de este dibujo, conservando los nombres de sus capas
|
||||
Make it a block of this drawing; its layers become NAME$0$LAYER Convertirla en un bloque de este dibujo; sus capas pasan a ser NAME$0$LAYER
|
||||
Make... Crear...
|
||||
Match Layer Igualar capa
|
||||
Match Properties Igualar propiedades
|
||||
@@ -434,16 +542,20 @@
|
||||
Mirror Simetría
|
||||
Model Modelo
|
||||
Model Space (in viewport) Espacio modelo (en ventana gráfica)
|
||||
Model background Fondo del modelo
|
||||
Modeling Modelado
|
||||
Models Modelos
|
||||
Modify Modificar
|
||||
More toolbar commands Más comandos de la barra de herramientas
|
||||
Move Desplazar
|
||||
Multileader Directriz múltiple
|
||||
Multileader Style... Estilo de directriz múltiple...
|
||||
Multileader style Estilo de directriz múltiple
|
||||
Multiline Línea múltiple
|
||||
Multiline Style... Estilo de línea múltiple...
|
||||
Multiline Text Texto multilínea
|
||||
Multiline Text... Texto multilínea...
|
||||
Multiline... Línea múltiple...
|
||||
Multiple Varios
|
||||
Multiple Point Puntos múltiples
|
||||
My Mías
|
||||
@@ -451,6 +563,7 @@
|
||||
N N
|
||||
Name Nombre
|
||||
Name (A–Z) Nombre (A–Z)
|
||||
Named Views... Vistas con nombre...
|
||||
Nearest Cercano
|
||||
New Nuevo
|
||||
New (metric) Nuevo (métrico)
|
||||
@@ -459,20 +572,26 @@
|
||||
New Drawing... Nuevo dibujo...
|
||||
New Layer Nueva capa
|
||||
New Layout Nueva presentación
|
||||
New VP Freeze Inutilizar en nuevas ventanas
|
||||
New VP Freeze Inutilizar en nuevas ventanas gráficas
|
||||
New Viewports... Nuevas ventanas gráficas...
|
||||
New from Template... Nuevo desde plantilla...
|
||||
New layout Nueva presentación
|
||||
New state name Nombre del nuevo estado
|
||||
Next Change Cambio siguiente
|
||||
No No
|
||||
No blocks defined in this drawing. No hay bloques definidos en este dibujo.
|
||||
No contributor data was built into this copy. Esta copia no incluye datos de colaboradores.
|
||||
No external references are attached. No hay referencias externas adjuntas.
|
||||
No model credits were built into this copy. Esta copia no incluye créditos de modelos.
|
||||
No saved layer states No hay estados de capas guardados
|
||||
No selection Sin selección
|
||||
Node Nodo
|
||||
None Ninguno
|
||||
Not Found No encontrada
|
||||
Not available No disponible
|
||||
Not available yet Aún no disponible
|
||||
Not loaded No cargada
|
||||
Numbered list Lista numerada
|
||||
OK Aceptar
|
||||
Object Objeto
|
||||
Object Snap Referencia a objetos
|
||||
@@ -480,17 +599,28 @@
|
||||
Object Snap On (F3) Activar referencia a objetos (F3)
|
||||
Object Snap Tracking Rastreo de referencia a objetos
|
||||
Object Snap Tracking (F11) Rastreo de referencia a objetos (F11)
|
||||
Object Snap Tracking Settings Configuración del rastreo de referencia a objetos
|
||||
Object snap aperture Apertura de referencia a objetos
|
||||
Object snap marker size Tamaño del marcador de referencia a objetos
|
||||
Object type: Tipo de objeto:
|
||||
Oblique Oblicua
|
||||
Off Desactivado
|
||||
Offset Desfase
|
||||
On Activado
|
||||
On/Off Activar/desactivar
|
||||
One of the Crafting Apps by the ArtCraft team and community. Una de las Crafting Apps del equipo y la comunidad de ArtCraft.
|
||||
Only in this drawing: {n} Solo en este dibujo: {n}
|
||||
Only in {name}: {n} Solo en {name}: {n}
|
||||
Open Abrir
|
||||
Open (⌘O) Abrir (⌘O)
|
||||
Open Sample Drawing Abrir dibujo de ejemplo
|
||||
Open Xref Abrir referencia externa
|
||||
Open and Save Abrir y guardar
|
||||
Open sample drawing Abrir dibujo de ejemplo
|
||||
Open... Abrir...
|
||||
Operator: Operador:
|
||||
Options Opciones
|
||||
Options... Opciones...
|
||||
Orbit Órbita
|
||||
Ordinate Coordenadas
|
||||
Ortho (F8) Ortogonal (F8)
|
||||
@@ -498,9 +628,15 @@
|
||||
Ortho Mode (F8) Modo ortogonal (F8)
|
||||
Osnap Settings... Configuración de referencia a objetos...
|
||||
Out Alejar
|
||||
Over-constrained Restricciones excesivas
|
||||
Overlay Superposición
|
||||
Override Modificar
|
||||
PAPER PAPEL
|
||||
Paste to Command Line Pegar en la línea de comandos
|
||||
Percent Porcentaje
|
||||
Pickbox size Tamaño de la caja de selección
|
||||
Plotter Manager Administrador de trazadores
|
||||
Plotter Manager... Administrador de trazadores...
|
||||
Polar (distance < angle) Polares (distancia < ángulo)
|
||||
PRs PRs
|
||||
Page Setup Manager Administrador de configuración de página
|
||||
@@ -522,7 +658,8 @@
|
||||
Perpendicular Perpendicular
|
||||
Plot Trazar
|
||||
Plot (click: do not plot) Se trazará (clic: no trazar)
|
||||
Plot Preview Vista previa de impresión
|
||||
Plot Preview Vista previa de trazado
|
||||
Plot Window Ventana de trazado
|
||||
Plot style Estilo de trazado
|
||||
Plot style attached to Estilo de trazado asociado a
|
||||
Plot style table Tabla de estilos de trazado
|
||||
@@ -533,7 +670,74 @@
|
||||
New CTB Nueva CTB
|
||||
New STB Nueva STB
|
||||
New style Nuevo estilo
|
||||
Polar angle measurement Medición del ángulo polar
|
||||
Polar distance Distancia polar
|
||||
PolarSnap Forzar cursor polar
|
||||
Polygonal Viewport Ventana gráfica poligonal
|
||||
Polyline Edit Editar polilínea
|
||||
Previous Change Cambio anterior
|
||||
Profiles Perfiles
|
||||
QuickCalc Pick Designar para la calculadora rápida
|
||||
Recalculate Table Formulas Recalcular fórmulas de tabla
|
||||
Recent Commands Comandos recientes
|
||||
Recent Input Entradas recientes
|
||||
Rectangular snap Forzar cursor rectangular
|
||||
Reference Manager Administrador de referencias
|
||||
Reference Name Nombre de la referencia
|
||||
References: {n} Referencias: {n}
|
||||
Reinitialize Reinicializar
|
||||
Relative to last segment Relativo al último segmento
|
||||
Reload Volver a cargar
|
||||
Reload All Volver a cargar todas
|
||||
Remove Vertex Eliminar vértice
|
||||
Reset Panel Layout Restablecer disposición de paneles
|
||||
Reset keeps the interface language. Restablecer conserva el idioma de la interfaz.
|
||||
Reset to Defaults Restablecer valores predeterminados
|
||||
SVG drawings (SVG, SVGZ) Dibujos SVG (SVG, SVGZ)
|
||||
Save new drawings as Guardar los dibujos nuevos como
|
||||
Saved Path Ruta guardada
|
||||
Scale List... Lista de escalas...
|
||||
Screen magnifiers, screen readers and recorders follow the system cursor. Las lupas, los lectores de pantalla y las grabadoras siguen el cursor del sistema.
|
||||
Select Similar Seleccionar similares
|
||||
Select objects first, then choose a command Seleccionar primero los objetos y luego elegir un comando
|
||||
Selection Selección
|
||||
Send Hatches to Back Enviar sombreados al fondo
|
||||
Send Under Objects Enviar por debajo de objetos
|
||||
Set Current Viewport Establecer ventana gráfica actual
|
||||
Set Table Cell Style Establecer estilo de celda de tabla
|
||||
Settings... Configuración...
|
||||
Show Annotative Objects Mostrar objetos anotativos
|
||||
Show Quick Properties for selected objects Mostrar las propiedades rápidas de los objetos seleccionados
|
||||
Show and hide the tools of this category Mostrar y ocultar las herramientas de esta categoría
|
||||
Show command prompting near the crosshairs Mostrar las solicitudes de comandos junto al cursor en cruz
|
||||
Show in Tool Sets Mostrar en los conjuntos de herramientas
|
||||
Snap Overrides Anulaciones de referencia a objetos
|
||||
Snap type Tipo de forzado de cursor
|
||||
Snap, grid, polar tracking and Dynamic Input: Forzar cursor, rejilla, rastreo polar y entrada dinámica:
|
||||
Solid Editing Edición de sólidos
|
||||
Spline Edit Editar spline
|
||||
Stretch Vertex Estirar vértice
|
||||
Synchronize Multiple-scale Positions Sincronizar posiciones de varias escalas
|
||||
Table Breaks Divisiones de tabla
|
||||
Table Cell Style Estilo de celda de tabla
|
||||
Temporary track point Punto de rastreo temporal
|
||||
Text Objects Only Solo objetos de texto
|
||||
Thaw Layer Reutilizar capa
|
||||
The aperture is how near the cursor must come to a point for an object snap to find it. La apertura indica cuánto debe acercarse el cursor a un punto para que la referencia a objetos lo encuentre.
|
||||
Time-sensitive right-click: a quick click is Enter, holding opens the menu Clic derecho según duración: un clic rápido equivale a Intro; mantenerlo abre el menú
|
||||
To A
|
||||
Tolerance... Tolerancia...
|
||||
Track orthogonally only Rastrear solo ortogonalmente
|
||||
Track using all polar angle settings Rastrear con todos los ángulos polares configurados
|
||||
Turn On Layer Activar capa
|
||||
Turned off, a right-click while a command runs always opens the shortcut menu. Desactivado, un clic derecho durante un comando siempre abre el menú contextual.
|
||||
Units Conversion Conversión de unidades
|
||||
Unload Descargar
|
||||
Unloaded Descargada
|
||||
Unlock viewport Desbloquear ventana gráfica
|
||||
Unreferenced Sin referencias
|
||||
Unresolved Sin resolver
|
||||
Update Data Links Actualizar vínculos de datos
|
||||
Use for this layout Usar en esta presentación
|
||||
Display plot styles Mostrar estilos de trazado
|
||||
No plot style tables. No hay tablas de estilos de trazado.
|
||||
@@ -694,7 +898,7 @@
|
||||
Select objects to array. Selecciona objetos para crear la matriz.
|
||||
Select objects to stretch by crossing-window or crossing-polygon... Selecciona objetos para estirar mediante ventana o polígono de captura...
|
||||
Select objects: Selecciona objetos:
|
||||
Select one polyline (Multiple is available through the JSON form). Selecciona una polilínea (Múltiple está disponible mediante JSON).
|
||||
Select one polyline (Multiple is available through the JSON form). Selecciona una polilínea (la opción Multiple está disponible en la forma JSON).
|
||||
Select path curve Selecciona la curva del camino
|
||||
Select point or object Selecciona un punto u objeto
|
||||
Select polyline or Selecciona una polilínea o
|
||||
@@ -731,9 +935,9 @@
|
||||
Snap Referencia
|
||||
Snap Mode Forzar cursor
|
||||
Snap Mode (F9) Forzar cursor (F9)
|
||||
Snap On (F9) Forzar cursor (F9)
|
||||
Snap X spacing Intervalo de resolución X
|
||||
Snap and Grid Resolución y rejilla
|
||||
Snap On (F9) Activar forzar cursor (F9)
|
||||
Snap X spacing Intervalo X de forzado de cursor
|
||||
Snap and Grid Forzar cursor y rejilla
|
||||
Solid Sólido
|
||||
Sort Ordenar
|
||||
Specify a point Indica un punto
|
||||
@@ -904,7 +1108,7 @@
|
||||
Text Height Altura del texto
|
||||
Text Style... Estilo de texto...
|
||||
Text height Altura del texto
|
||||
Text override Texto alternativo
|
||||
Text override Sustitución de texto
|
||||
Text style Estilo de texto
|
||||
Thaw All Layers Reutilizar todas las capas
|
||||
There is nothing here yet. Todavía no hay dibujos aquí.
|
||||
@@ -912,7 +1116,7 @@
|
||||
Time Tiempo
|
||||
To get started, create or open a drawing. Para empezar, crea o abre un dibujo.
|
||||
Tool Bar Barra de herramientas
|
||||
Tool Sets Herramientas de dibujo
|
||||
Tool Sets Conjuntos de herramientas
|
||||
Tools Herramientas
|
||||
Torus Toroide
|
||||
Trace Trazo
|
||||
@@ -940,12 +1144,20 @@
|
||||
Use System Setting Usar configuración del sistema
|
||||
User Usuario
|
||||
User Parameters Parámetros de usuario
|
||||
User Preferences Preferencias de usuario
|
||||
Username Nombre de usuario
|
||||
VP Color Color en ventana
|
||||
VP Freeze Inutilizar en ventana
|
||||
VP Color Color en ventana gráfica
|
||||
VP Freeze Inutilizar en ventana gráfica
|
||||
Value Valor
|
||||
Value: Valor:
|
||||
Variables Variables
|
||||
Version Versión
|
||||
Version 2000 Versión 2000
|
||||
Version 2004 Versión 2004
|
||||
Version 2007 Versión 2007
|
||||
Version 2010 Versión 2010
|
||||
Version 2013 Versión 2013
|
||||
Version 2018 Versión 2018
|
||||
Version {version} Versión {version}
|
||||
Vertical Vertical
|
||||
VerticalDistance Distancia vertical
|
||||
@@ -954,8 +1166,11 @@
|
||||
Viewport Ventana gráfica
|
||||
Viewport Layer Freeze Inutilizar capas en ventana gráfica
|
||||
Viewport Properties Propiedades de ventana gráfica
|
||||
Viewport is locked La ventana gráfica está bloqueada
|
||||
Viewport scale Escala de la ventana gráfica
|
||||
Viewport {id} Ventana gráfica {id}
|
||||
Viewports Ventanas gráficas
|
||||
Volume Volumen
|
||||
W O
|
||||
WCS ⌄ SCU ⌄
|
||||
White Blanco
|
||||
@@ -964,18 +1179,24 @@
|
||||
Wipeout Cobertura
|
||||
Workspace Switching Cambiar espacio de trabajo
|
||||
Write Block Escribir bloque
|
||||
X X
|
||||
Xline Línea auxiliar
|
||||
Y Y
|
||||
Yellow Amarillo
|
||||
Yes Sí
|
||||
Zoom Zoom
|
||||
Zoom All Zoom a todo
|
||||
Zoom Dynamic Zoom dinámico
|
||||
Zoom Extents Zoom a extensión
|
||||
Zoom In Acercar
|
||||
Zoom Object Zoom a objeto
|
||||
Zoom Out Alejar
|
||||
Zoom Previous Zoom anterior
|
||||
Zoom Realtime Zoom en tiempo real
|
||||
Zoom Window Zoom por ventana
|
||||
error error
|
||||
min min
|
||||
objects objetos
|
||||
{contributors} · {commits} {contributors} · {commits}
|
||||
{kind} (constraint {id}) {kind} (restricción {id})
|
||||
{kind} constraint {name} = {expression} (double-click to edit) Restricción {kind} {name} = {expression} (doble clic para editar)
|
||||
@@ -993,3 +1214,23 @@
|
||||
Hide Others Ocultar otros
|
||||
Accessibility Accesibilidad
|
||||
Show System Cursor Mostrar cursor del sistema
|
||||
3D Mouse... Ratón 3D...
|
||||
3D Mouse Ratón 3D
|
||||
Calibrate 3D Mouse Calibrar ratón 3D
|
||||
3D Mouse Navigation Navegación con ratón 3D
|
||||
3D mouse input is unavailable on this platform El ratón 3D no está disponible en esta plataforma
|
||||
No supported 3D mouse found No se encontró un ratón 3D compatible
|
||||
Release the 3D mouse to calibrate its resting position Suelta el ratón 3D para calibrar su posición de reposo
|
||||
3D mouse connected Ratón 3D conectado
|
||||
3D mouse busy, possibly 3DxWare or missing device permissions Ratón 3D ocupado, posiblemente por 3DxWare o por falta de permisos del dispositivo
|
||||
3D mouse disconnected Ratón 3D desconectado
|
||||
3D mouse input could not be started No se pudo iniciar el ratón 3D
|
||||
Enable 3D mouse navigation Activar navegación con ratón 3D
|
||||
Pan sensitivity Sensibilidad del encuadre
|
||||
Zoom sensitivity Sensibilidad de zoom
|
||||
Reverse zoom direction Invertir dirección del zoom
|
||||
Calibrate Calibrar
|
||||
Move sideways to pan; push/pull or twist to zoom. Mueve el mando lateralmente para encuadrar; empuja, tira o gira para hacer zoom.
|
||||
Left device button: zoom extents. Right device button: calibrate. Botón izquierdo del dispositivo: zoom a la extensión. Botón derecho: calibrar.
|
||||
Hatch Pattern Patrón de sombreado
|
||||
Search for pattern Buscar patrón
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,29 @@ pub enum Preference {
|
||||
German,
|
||||
#[serde(rename = "es")]
|
||||
Spanish,
|
||||
#[serde(rename = "cs")]
|
||||
Czech,
|
||||
#[serde(rename = "ru")]
|
||||
Russian,
|
||||
#[serde(rename = "fr")]
|
||||
French,
|
||||
#[serde(rename = "zh")]
|
||||
Chinese,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
pub const ALL: [Self; 6] = [Self::Auto, Self::English, Self::Ukrainian, Self::BrazilianPortuguese, Self::German, Self::Spanish];
|
||||
pub const ALL: [Self; 10] = [
|
||||
Self::Auto,
|
||||
Self::English,
|
||||
Self::Ukrainian,
|
||||
Self::BrazilianPortuguese,
|
||||
Self::German,
|
||||
Self::Spanish,
|
||||
Self::Czech,
|
||||
Self::Russian,
|
||||
Self::French,
|
||||
Self::Chinese,
|
||||
];
|
||||
|
||||
/// The codes a preference can be given as, for messages ("auto, en or uk").
|
||||
pub fn codes() -> String {
|
||||
@@ -45,6 +64,10 @@ impl Preference {
|
||||
Self::BrazilianPortuguese => "pt-BR",
|
||||
Self::German => "de",
|
||||
Self::Spanish => "es",
|
||||
Self::Czech => "cs",
|
||||
Self::Russian => "ru",
|
||||
Self::French => "fr",
|
||||
Self::Chinese => "zh",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +79,10 @@ impl Preference {
|
||||
Self::BrazilianPortuguese => "Português (Brasil)",
|
||||
Self::German => "Deutsch",
|
||||
Self::Spanish => "Español",
|
||||
Self::Czech => "Čeština",
|
||||
Self::Russian => "Русский",
|
||||
Self::French => "Français",
|
||||
Self::Chinese => "简体中文",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +102,10 @@ impl Preference {
|
||||
Self::BrazilianPortuguese => "pt-BR",
|
||||
Self::German => "de",
|
||||
Self::Spanish => "es",
|
||||
Self::Czech => "cs",
|
||||
Self::Russian => "ru",
|
||||
Self::French => "fr",
|
||||
Self::Chinese => "zh",
|
||||
Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"),
|
||||
}
|
||||
}
|
||||
@@ -91,7 +122,7 @@ pub struct Language {
|
||||
catalog: OnceLock<Catalog<'static>>,
|
||||
}
|
||||
|
||||
pub static LANGUAGES: [Language; 5] = [
|
||||
pub static LANGUAGES: [Language; 9] = [
|
||||
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
Language {
|
||||
code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new()
|
||||
@@ -108,6 +139,14 @@ pub static LANGUAGES: [Language; 5] = [
|
||||
Language { code: "de", name: "Deutsch", source: include_str!("de.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
// Spanish integer plurals follow the English rule too.
|
||||
Language { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
Language { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_cs, plural_forms: 3, catalog: OnceLock::new() },
|
||||
// Russian integer plurals follow the Ukrainian rule: one (1, 21), few (2–4), many.
|
||||
Language { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() },
|
||||
// French integer plurals (CLDR `fr`): one (0, 1), other, the Portuguese rule.
|
||||
Language { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_pt, plural_forms: 2, catalog: OnceLock::new() },
|
||||
// Chinese has no grammatical plural: both `@plural` forms read the same. Its glyphs come from
|
||||
// an installed CJK font (`theme::cjk_font`); without one the interface stays English.
|
||||
Language { code: "zh", name: "简体中文", source: include_str!("zh.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
/// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`.
|
||||
@@ -168,6 +207,16 @@ pub fn t(source: &str) -> &str {
|
||||
CURRENT.with(|l| tr(l.get(), source))
|
||||
}
|
||||
|
||||
/// Menu headings can differ from command or object labels (e.g. Czech Kótování / Kóta).
|
||||
/// Catalogs without a `menu` entry retain their ordinary translation.
|
||||
pub fn tr_menu<'a>(language: &str, source: &'a str) -> &'a str {
|
||||
catalog(language).and_then(|c| c.get(&("menu", source))).copied().unwrap_or_else(|| tr(language, source))
|
||||
}
|
||||
|
||||
pub fn menu(source: &str) -> &str {
|
||||
CURRENT.with(|l| tr_menu(l.get(), source))
|
||||
}
|
||||
|
||||
/// Named placeholders allow translations to reorder values. Inserted values are never looked up
|
||||
/// or substituted again, so a user's name containing `{n}` is preserved verbatim.
|
||||
pub fn format(source: &str, values: &[(&str, String)]) -> String {
|
||||
@@ -213,6 +262,15 @@ fn plural_en(n: u64) -> usize {
|
||||
usize::from(n != 1)
|
||||
}
|
||||
|
||||
/// Czech integer plurals: one (1), few (2–4), other (0 and 5 or more).
|
||||
pub fn plural_cs(n: u64) -> usize {
|
||||
match n {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count(n: u64, one: &str, other: &str) -> String {
|
||||
let language = CURRENT.get();
|
||||
let source = catalog(language).and_then(|c| c.get(&("@plural", one))).copied();
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
Close All Fechar tudo
|
||||
Closed Fechada
|
||||
Coincident Coincidente
|
||||
Collapse Palettes Recolher paletas
|
||||
Collapse Tool Sets Recolher conjuntos de ferramentas
|
||||
Collinear Colinear
|
||||
Color Cor
|
||||
@@ -523,6 +524,7 @@
|
||||
Plot Plotar
|
||||
Plot (click: do not plot) Plotar (clique para não plotar)
|
||||
Plot Preview Visualizar plotagem
|
||||
Plot Window Janela de plotagem
|
||||
Plot style Estilo de plotagem
|
||||
Plot style attached to Estilo de plotagem anexado a
|
||||
Plot style table Tabela de estilos de plotagem
|
||||
@@ -993,3 +995,5 @@
|
||||
Hide Others Ocultar outros
|
||||
Accessibility Acessibilidade
|
||||
Show System Cursor Exibir cursor do sistema
|
||||
Hatch Pattern Padrão de hachura
|
||||
Search for pattern Pesquisar padrão
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {
|
||||
let (catalog, errors) = parse(language.source);
|
||||
assert!(errors.is_empty(), "{}: {errors:?}", language.code);
|
||||
for ((context, key), value) in catalog {
|
||||
assert!(["", "@plural"].contains(&context), "unknown context: {context}");
|
||||
assert!(["", "menu", "@plural"].contains(&context), "unknown context: {context}");
|
||||
let forms: Vec<_> = if context == "@plural" { value.split('|').collect() } else { vec![value] };
|
||||
if context == "@plural" {
|
||||
assert_eq!(forms.len(), language.plural_forms, "{}: {key}", language.code);
|
||||
@@ -39,7 +39,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {
|
||||
/// to English for what they don't cover yet; their gaps are reported, not enforced, so adding a
|
||||
/// command doesn't require a row in every language.
|
||||
const REQUIRED: &[&str] = &["uk"];
|
||||
const REPORTED: &[&str] = &["pt-BR", "de", "es"];
|
||||
const REPORTED: &[&str] = &["pt-BR", "de", "es", "cs", "ru", "fr", "zh"];
|
||||
|
||||
fn check(language: &str, what: &str, missing: Vec<String>) {
|
||||
if REQUIRED.contains(&language) {
|
||||
@@ -94,6 +94,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
|
||||
include_str!("../cmdline.rs"),
|
||||
include_str!("../credits.rs"),
|
||||
include_str!("../palettes/quickcalc.rs"),
|
||||
include_str!("../palettes/patterns.rs"),
|
||||
include_str!("../dialogs.rs"),
|
||||
include_str!("../layers.rs"),
|
||||
include_str!("../canvas/compare.rs"),
|
||||
@@ -101,6 +102,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
|
||||
include_str!("../parametric.rs"),
|
||||
include_str!("../quick.rs"),
|
||||
include_str!("../vpscale.rs"),
|
||||
include_str!("../options/dialog.rs"),
|
||||
];
|
||||
let mut missing = BTreeSet::new();
|
||||
for source in sources {
|
||||
@@ -145,14 +147,18 @@ fn locale_and_saved_preference_resolution() {
|
||||
for tag in ["de", "de-DE", "de_DE.UTF-8", "DE-at", "de-CH"] {
|
||||
assert_eq!(locale_language(tag), Some("de"), "{tag}");
|
||||
}
|
||||
for tag in ["ua", "ukulele", "ru-RU", "", "garbage", "💙"] {
|
||||
for (tag, code) in [("ru", "ru"), ("ru-RU", "ru"), ("ru_RU.UTF-8", "ru"), ("fr", "fr"), ("fr-FR", "fr"), ("fr_CA.UTF-8", "fr")] {
|
||||
assert_eq!(locale_language(tag), Some(code), "{tag}");
|
||||
}
|
||||
for tag in ["ua", "ukulele", "pl-PL", "", "garbage", "💙"] {
|
||||
assert_eq!(locale_language(tag), None, "{tag}");
|
||||
}
|
||||
for tag in ["pt", "pt-BR", "pt_BR.UTF-8", "PT-br", "pt-PT"] {
|
||||
assert_eq!(locale_language(tag), Some("pt-BR"), "{tag}");
|
||||
}
|
||||
assert_eq!(locale_language("ptolemy"), None);
|
||||
assert_eq!(Preference::Auto.resolve(&["fr-FR".into(), "pt-BR".into()]), "pt-BR");
|
||||
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "pt-BR".into()]), "pt-BR");
|
||||
assert_eq!(Preference::Auto.resolve(&["fr-FR".into(), "pt-BR".into()]), "fr");
|
||||
for tag in ["es", "es-ES", "es_MX.UTF-8", "ES-ar"] {
|
||||
assert_eq!(locale_language(tag), Some("es"), "{tag}");
|
||||
}
|
||||
@@ -388,3 +394,150 @@ fn default_fonts_cover_every_ukrainian_letter_in_the_catalog() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_locales_preferences_and_plural_forms() {
|
||||
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS-cz", " cs-CZ ", "cs_CZ@euro"] {
|
||||
assert_eq!(locale_language(tag), Some("cs"), "{tag}");
|
||||
}
|
||||
for tag in ["cz", "csharp", "csomething"] {
|
||||
assert_eq!(locale_language(tag), None, "{tag}");
|
||||
}
|
||||
assert_eq!(Preference::parse("cs"), Some(Preference::Czech));
|
||||
assert_eq!(Preference::Czech.name(), "Čeština");
|
||||
assert_eq!(Preference::Czech.resolve(&["en-US".into()]), "cs");
|
||||
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "cs-CZ".into(), "en-US".into()]), "cs");
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.ui.interface_language = Preference::Czech;
|
||||
let mut restored = CadApp::new(Session::new(), Services::default());
|
||||
restored.load_prefs(&app.prefs_json());
|
||||
assert_eq!(restored.ui.interface_language, Preference::Czech);
|
||||
with_language("cs", || {
|
||||
for (n, form) in [(0, 2), (1, 0), (2, 1), (4, 1), (5, 2), (11, 2), (21, 2), (22, 2), (25, 2), (u64::MAX, 2)] {
|
||||
assert_eq!(plural_cs(n), form, "{n}");
|
||||
let contributor = ["přispěvatel", "přispěvatelé", "přispěvatelů"][form];
|
||||
let commit = ["commit", "commity", "commitů"][form];
|
||||
assert_eq!(count(n, "{n} contributor", "{n} contributors"), format!("{n} {contributor}"));
|
||||
assert_eq!(count(n, "{n} commit", "{n} commits"), format!("{n} {commit}"));
|
||||
}
|
||||
assert_eq!(format("Current: {color}", &[("color", "{n} Open".into())]), "Aktuální: {n} Open");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_menu_headings_keep_object_labels_and_other_catalogs() {
|
||||
with_language("cs", || {
|
||||
assert_eq!(menu("View"), "Zobrazení");
|
||||
assert_eq!(t("View"), "Pohled");
|
||||
assert_eq!(menu("Dimension"), "Kótování");
|
||||
assert_eq!(t("Dimension"), "Kóta");
|
||||
assert_eq!(menu("Draw"), "Kreslení");
|
||||
assert_eq!(menu("Modify"), "Modifikace");
|
||||
assert_eq!(menu("Edit"), "Úpravy");
|
||||
assert_eq!(menu("File"), "Soubor");
|
||||
});
|
||||
for language in ["en", "uk", "de", "es", "pt-BR", "unknown"] {
|
||||
for source in crate::menus::MENUS {
|
||||
assert_eq!(tr_menu(language, source), tr(language, source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_czech_switch_preserves_drawing_command_and_saved_choice() {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.ui.in_window_menu = true;
|
||||
app.session.state_mut().unwrap().title = "Open".into();
|
||||
let ctx = egui::Context::default();
|
||||
app.logic(&ctx);
|
||||
assert!(render(&mut app, &ctx).contains("Properties"));
|
||||
app.start("line");
|
||||
let prompt = crate::control::cmdline_state(&app);
|
||||
let commands = crate::control::all_commands(&app);
|
||||
app.run("ui.language", json!({"lang":"cs"})).unwrap();
|
||||
let text = render(&mut app, &ctx);
|
||||
for label in ["Vlastnosti", "Soubor", "Zobrazení", "Kreslení", "Kótování", "Určete první bod"] {
|
||||
assert!(text.contains(label), "missing {label}: {text}");
|
||||
}
|
||||
assert_eq!(app.language(), "cs");
|
||||
assert_eq!(app.session.state().unwrap().title, "Open");
|
||||
assert_eq!(crate::control::cmdline_state(&app), prompt);
|
||||
assert_eq!(crate::control::all_commands(&app), commands);
|
||||
assert_eq!(t("Properties"), "Properties");
|
||||
app.run("ui.resetpalettes", json!({})).unwrap();
|
||||
assert_eq!(app.language(), "cs");
|
||||
app.run("ui.language", json!({"lang":"en"})).unwrap();
|
||||
assert!(render(&mut app, &ctx).contains("Properties"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_dialogs_render_with_stable_ids() {
|
||||
for (dialog, heading) in [
|
||||
("dsettings", "Nastavení kreslení"),
|
||||
("about", "O aplikaci CADCraft"),
|
||||
("commands", "Přehled příkazů"),
|
||||
("blocks", "Bloky"),
|
||||
("layers", "Správce vlastností hladin"),
|
||||
("qselect", "Rychlý výběr"),
|
||||
("parameters", "Správce parametrů"),
|
||||
("language", "Čeština"),
|
||||
("options:preferences", "Uživatelské předvolby"),
|
||||
] {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.ui.interface_language = Preference::Czech;
|
||||
app.ui.dialog = Some(dialog.into());
|
||||
let ctx = egui::Context::default();
|
||||
app.logic(&ctx);
|
||||
let text = render(&mut app, &ctx);
|
||||
assert!(text.contains(heading), "{dialog}: {text}");
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some(dialog));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_fonts_cover_czech_letters_and_typographic_quotes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let letters: BTreeSet<_> = "ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽáčďéěíňóřšťúůýž„“".chars().collect();
|
||||
ctx.fonts_mut(|view| {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
let mut font = view.fonts.font(&family);
|
||||
for c in &letters {
|
||||
assert!(font.characters().contains_key(c), "missing {c} in {family:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_and_french_plurals() {
|
||||
with_language("ru", || {
|
||||
for (n, word) in [(1, "участник"), (2, "участника"), (5, "участников"), (21, "участник"), (22, "участника"), (11, "участников")]
|
||||
{
|
||||
assert_eq!(count(n, "{n} contributor", "{n} contributors"), format!("{n} {word}"));
|
||||
}
|
||||
});
|
||||
with_language("fr", || {
|
||||
for (n, word) in [(0, "contributeur"), (1, "contributeur"), (2, "contributeurs"), (10, "contributeurs")] {
|
||||
assert_eq!(count(n, "{n} contributor", "{n} contributors"), format!("{n} {word}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Chinese shows only with an installed CJK font (none is bundled); without one the interface
|
||||
/// stays English instead of drawing boxes, and choosing it says why.
|
||||
#[test]
|
||||
fn chinese_needs_an_installed_cjk_font() {
|
||||
assert_eq!(locale_language("zh-CN"), Some("zh"));
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
let r = app.run("ui.language", json!({"lang": "zh"})).unwrap();
|
||||
assert_eq!(r["interfaceLanguage"], "zh");
|
||||
let font = crate::theme::cjk_font();
|
||||
assert_eq!(app.language(), if font.is_some() { "zh" } else { "en" });
|
||||
assert_eq!(r["shown"], app.language());
|
||||
assert!(crate::theme::can_show("uk") && crate::theme::can_show("en"));
|
||||
if font.is_none() {
|
||||
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("No Chinese font")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
Closed Замкнений
|
||||
Close Comparison Закрити порівняння
|
||||
Coincident Збіг
|
||||
Collapse Palettes Згорнути палітри
|
||||
Collapse Tool Sets Згорнути набори інструментів
|
||||
Collinear Колінеарність
|
||||
Color Колір
|
||||
@@ -252,6 +253,7 @@
|
||||
Drawing Template (DWT) Шаблон креслення (DWT)
|
||||
Drawing Utilities Утиліти креслення
|
||||
Drawings (DWG, DXF) Креслення (DWG, DXF)
|
||||
SVG drawings (SVG, SVGZ) Креслення SVG (SVG, SVGZ)
|
||||
Dynamic Динамічно
|
||||
Dynamic Dimensions Динамічні розміри
|
||||
Dynamic Input Динамічне введення
|
||||
@@ -620,6 +622,7 @@
|
||||
Plot Друк креслення
|
||||
Plot (click: do not plot) Друкувати (натисніть, щоб вимкнути друк)
|
||||
Plot Preview Попередній перегляд друку
|
||||
Plot Window Вікно друку
|
||||
Plot style Стиль друку
|
||||
Plot style attached to Стиль друку застосовано до
|
||||
Plot style table Таблиця стилів друку
|
||||
@@ -839,6 +842,8 @@
|
||||
Show All Показати всі
|
||||
Show All Constraint Bars Показати всі панелі обмежень
|
||||
Show All Dynamic Dimensions Показати всі динамічні розміри
|
||||
Show and hide the tools of this category Показати й сховати інструменти цієї категорії
|
||||
Show in Tool Sets Показувати в наборах інструментів
|
||||
Show command prompting near the crosshairs Показувати запити команд біля перехрестя
|
||||
Show Layer List Показати список шарів
|
||||
Show/Hide Lineweight Показати/приховати товщину ліній
|
||||
@@ -1199,3 +1204,32 @@
|
||||
Multiline... Мультилінія...
|
||||
Multiline Style... Стиль мультилінії...
|
||||
Solid Editing Редагування тіл
|
||||
3D Mouse... 3D-миша...
|
||||
3D Mouse 3D-миша
|
||||
Calibrate 3D Mouse Калібрувати 3D-мишу
|
||||
3D Mouse Navigation Навігація за допомогою 3D-миші
|
||||
3D mouse input is unavailable on this platform Введення з 3D-миші недоступне на цій платформі
|
||||
No supported 3D mouse found Сумісну 3D-мишу не знайдено
|
||||
Release the 3D mouse to calibrate its resting position Відпустіть 3D-мишу, щоб відкалібрувати її положення спокою
|
||||
3D mouse connected 3D-мишу підключено
|
||||
3D mouse busy, possibly 3DxWare or missing device permissions 3D-миша зайнята, можливо, через 3DxWare або недостатні права доступу до пристрою
|
||||
3D mouse disconnected 3D-мишу відключено
|
||||
3D mouse input could not be started Не вдалося запустити введення з 3D-миші
|
||||
Enable 3D mouse navigation Увімкнути навігацію 3D-мишею
|
||||
Pan sensitivity Чутливість панорамування
|
||||
Zoom sensitivity Чутливість масштабування
|
||||
Reverse zoom direction Змінити напрямок масштабування
|
||||
Calibrate Калібрувати
|
||||
Move sideways to pan; push/pull or twist to zoom. Рухайте вбік для панорамування; натискайте, тягніть або обертайте для масштабування.
|
||||
Left device button: zoom extents. Right device button: calibrate. Ліва кнопка пристрою: масштабування до меж. Права кнопка: калібрування.
|
||||
More toolbar commands Інші команди панелі інструментів
|
||||
Default save version Типова версія збереження
|
||||
Automatic save every Автозбереження кожні
|
||||
min хв
|
||||
Automatic save file location Папка файлів автозбереження
|
||||
Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Креслення, що ще не мають файлу, зберігаються в типовій версії. 0 хвилин вимикає автозбереження.
|
||||
Time-sensitive right-click: a quick click is Enter, holding opens the menu Клік правою кнопкою з урахуванням часу: швидкий клік — Enter, утримання відкриває меню
|
||||
Turned off, a right-click while a command runs always opens the shortcut menu. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню.
|
||||
Hatch Pattern Зразок штрихування
|
||||
Search for pattern Пошук зразка
|
||||
Run Script... Виконати сценарій...
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
+39
-10
@@ -19,6 +19,7 @@ pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod docking;
|
||||
pub mod spacemouse;
|
||||
// The External References palette.
|
||||
pub mod xrefs;
|
||||
|
||||
@@ -77,6 +78,9 @@ pub struct UiState {
|
||||
/// "Drafting" or "Modeling".
|
||||
pub toolset_tab: String,
|
||||
pub collapsed_groups: Vec<String>,
|
||||
/// Tools the user took out of the Tool Sets palette (command ids); each category's gear
|
||||
/// menu puts them back.
|
||||
pub hidden_tools: Vec<String>,
|
||||
pub properties_all: bool,
|
||||
/// Show an in-window menu bar (platforms without a native menu bar, or on request).
|
||||
pub in_window_menu: bool,
|
||||
@@ -150,6 +154,7 @@ impl Default for UiState {
|
||||
system_cursor: std::env::var("CADCRAFT_SYSTEM_CURSOR").is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false" | "off")),
|
||||
toolset_tab: "Drafting".into(),
|
||||
collapsed_groups: Vec::new(),
|
||||
hidden_tools: Vec::new(),
|
||||
properties_all: true,
|
||||
in_window_menu: !cfg!(target_os = "macos"),
|
||||
start_tab: false,
|
||||
@@ -177,6 +182,8 @@ pub struct CadApp {
|
||||
pub options: options::UiOptions,
|
||||
pub services: Services,
|
||||
pub canvas: canvas::CanvasState,
|
||||
/// Desktop HID status and navigation preferences; the web host supplies no HID backend.
|
||||
pub spacemouse: spacemouse::State,
|
||||
pub cmd: cmdline::CmdLine,
|
||||
pub status: Option<(String, f64)>,
|
||||
pub integrated_titlebar: bool,
|
||||
@@ -188,7 +195,8 @@ pub struct CadApp {
|
||||
/// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub frame_cap: Option<gpu::FrameCap>,
|
||||
styled: bool,
|
||||
/// Interface fonts installed, and whether with a CJK font (`None` until the first frame).
|
||||
fonts_cjk: Option<bool>,
|
||||
/// The theme last installed into egui (`None` until the first frame).
|
||||
shown_theme: Option<egui::Theme>,
|
||||
/// Whether the last applied theme was handed to egui as the System preference.
|
||||
@@ -204,6 +212,10 @@ pub struct CadApp {
|
||||
pub autosave: Option<cadcraft_engine::autosave::Autosaver>,
|
||||
/// Drawing Recovery: autosave files a crashed app left behind.
|
||||
pub recovery: autosave::Recovery,
|
||||
/// GPU errors and device loss reported by wgpu ([`gpu::GpuFaults`]).
|
||||
pub gpu_faults: std::sync::Arc<gpu::GpuFaults>,
|
||||
/// The notice shown after one (see [`autosave::gpu_notice`]).
|
||||
pub gpu_notice: Option<autosave::GpuNotice>,
|
||||
}
|
||||
|
||||
impl CadApp {
|
||||
@@ -215,6 +227,7 @@ impl CadApp {
|
||||
options: options::UiOptions::default(),
|
||||
services,
|
||||
canvas: canvas::CanvasState::default(),
|
||||
spacemouse: spacemouse::State::default(),
|
||||
cmd: cmdline::CmdLine::default(),
|
||||
status: None,
|
||||
integrated_titlebar: false,
|
||||
@@ -225,7 +238,7 @@ impl CadApp {
|
||||
synthetic: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
frame_cap: None,
|
||||
styled: false,
|
||||
fonts_cjk: None,
|
||||
shown_theme: None,
|
||||
shown_follows_system: false,
|
||||
frame_ms: 0.0,
|
||||
@@ -234,17 +247,22 @@ impl CadApp {
|
||||
closing: None,
|
||||
autosave: None,
|
||||
recovery: autosave::Recovery::default(),
|
||||
gpu_faults: std::sync::Arc::default(),
|
||||
gpu_notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface language shown: the chosen one, or English when this computer has no font
|
||||
/// for it (Chinese without an installed CJK font would be boxes).
|
||||
pub fn language(&self) -> &'static str {
|
||||
self.ui.interface_language.resolve(&self.system_languages)
|
||||
let l = self.ui.interface_language.resolve(&self.system_languages);
|
||||
if theme::can_show(l) { l } else { "en" }
|
||||
}
|
||||
|
||||
/// Draw the canvas on the GPU with the app's wgpu render state (eframe's
|
||||
/// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU.
|
||||
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState) {
|
||||
self.canvas.gpu = Some(gpu::install(rs));
|
||||
self.canvas.gpu = Some(gpu::install(rs, &self.gpu_faults));
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
@@ -304,18 +322,24 @@ impl CadApp {
|
||||
|
||||
/// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]).
|
||||
pub fn prefs_json(&self) -> String {
|
||||
json!({
|
||||
let mut prefs = json!({
|
||||
"theme": self.ui.theme.as_str(),
|
||||
"interfaceLanguage": self.ui.interface_language.code(),
|
||||
"saveFormat": self.ui.save_format.as_str(),
|
||||
"saveVersion": self.ui.save_version,
|
||||
})
|
||||
.to_string()
|
||||
});
|
||||
if self.spacemouse.settings != spacemouse::Settings::default() {
|
||||
prefs["spacemouse"] = json!(self.spacemouse.settings);
|
||||
}
|
||||
prefs.to_string()
|
||||
}
|
||||
|
||||
/// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored.
|
||||
pub fn load_prefs(&mut self, json: &str) {
|
||||
let Ok(v) = serde_json::from_str::<Value>(json) else { return };
|
||||
if let Some(prefs) = v.get("spacemouse") {
|
||||
let _ = self.spacemouse.load_settings(prefs);
|
||||
}
|
||||
if let Some(t) = v.get("theme").and_then(Value::as_str).and_then(theme::ThemePref::parse) {
|
||||
self.ui.theme = t;
|
||||
}
|
||||
@@ -390,9 +414,10 @@ impl CadApp {
|
||||
if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
self.styled = true;
|
||||
let cjk = self.language().starts_with("zh");
|
||||
if self.fonts_cjk != Some(cjk) {
|
||||
theme::install_fonts(ctx, cjk);
|
||||
self.fonts_cjk = Some(cjk);
|
||||
}
|
||||
// Resolved every frame, so a System choice follows the OS appearance live (winit and the
|
||||
// browser report changes as input); the saved choice itself never changes here.
|
||||
@@ -411,6 +436,8 @@ impl CadApp {
|
||||
}
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
self.gpu_fault_tick(ctx);
|
||||
managers::plot_window_resume(self, ctx);
|
||||
self.autosave_tick(ctx);
|
||||
menus::shortcuts(self, ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -476,8 +503,10 @@ impl CadApp {
|
||||
}
|
||||
});
|
||||
dialogs::show(self, ui.ctx());
|
||||
palettes::patterns::prompt_picker(self, ui.ctx());
|
||||
closing::prompt(self, ui.ctx());
|
||||
autosave::show(self, ui.ctx());
|
||||
autosave::gpu_notice(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
|
||||
+257
-27
@@ -26,7 +26,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
let dialog = match id {
|
||||
"battman" => "battman",
|
||||
"linetype" | "lt" | "ltype" => "linetype",
|
||||
"ddptype" => "ptype",
|
||||
"ddptype" | "ptype" => "ptype",
|
||||
"style" | "st" => "textstyle",
|
||||
"tablestyle" | "ts" => "tablestyle",
|
||||
"mleaderstyle" | "mls" => "mleaderstyle",
|
||||
@@ -484,6 +484,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
|
||||
.or_else(|| layouts.first().map(|l| l.1.clone()))
|
||||
.unwrap_or_default();
|
||||
let mut action = None;
|
||||
let choices = PageChoices::new(ctx, d);
|
||||
egui::Window::new("Page Setup Manager").open(open).default_size(vec2(560.0, 300.0)).resizable(true).show(ctx, |ui| {
|
||||
if layouts.is_empty() {
|
||||
ui.label("This drawing has no layouts.");
|
||||
@@ -504,7 +505,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
|
||||
// Layout plotting always prints the whole sheet, so only that area is offered.
|
||||
let rows = PageRows { areas: &["layout"], scale: true, center: true };
|
||||
egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| {
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows) {
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices) {
|
||||
action = Some(("pagesetup", json!({ "layout": name, k: v })));
|
||||
}
|
||||
});
|
||||
@@ -524,6 +525,43 @@ struct PageRows {
|
||||
center: bool,
|
||||
}
|
||||
|
||||
/// The devices and plot style tables the page-setup rows offer.
|
||||
struct PageChoices {
|
||||
devices: std::sync::Arc<Vec<cadcraft_doc::PlotterConfig>>,
|
||||
styles: Vec<String>,
|
||||
}
|
||||
|
||||
impl PageChoices {
|
||||
fn new(ctx: &egui::Context, d: &cadcraft_doc::Drawing) -> Self {
|
||||
PageChoices { devices: device_choices(ctx), styles: cadcraft_doc::plot_style_table_names(d) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The plotter devices (built-in, then the user's files), read again at most every 2 s: the
|
||||
/// dialogs ask every frame and user devices are files.
|
||||
fn device_choices(ctx: &egui::Context) -> std::sync::Arc<Vec<cadcraft_doc::PlotterConfig>> {
|
||||
type Cached = (f64, std::sync::Arc<Vec<cadcraft_doc::PlotterConfig>>);
|
||||
let now = crate::now_ms();
|
||||
if let Some((at, list)) = temp::<Cached>(ctx, "plot_devices")
|
||||
&& now - at < 2000.0
|
||||
{
|
||||
return list;
|
||||
}
|
||||
let list = std::sync::Arc::new(cadcraft_engine::cmd::plotters::devices());
|
||||
set_temp::<Cached>(ctx, "plot_devices", (now, list.clone()));
|
||||
list
|
||||
}
|
||||
|
||||
/// A page setup's device, or `"None"` (plot to PDF on the page's paper).
|
||||
fn device_label(page: &cadcraft_doc::PageSetup) -> &str {
|
||||
if page.device.trim().is_empty() { "None" } else { page.device.as_str() }
|
||||
}
|
||||
|
||||
/// A page setup's plot style table, or `"None"`.
|
||||
fn style_label(page: &cadcraft_doc::PageSetup) -> &str {
|
||||
if cadcraft_doc::no_plot_style_table(&page.plot_style_table) { "None" } else { page.plot_style_table.as_str() }
|
||||
}
|
||||
|
||||
fn area_label(a: &str) -> &str {
|
||||
match a {
|
||||
"layout" => "Layout",
|
||||
@@ -544,17 +582,51 @@ fn parse_ratio(t: &str) -> Option<f64> {
|
||||
(v.is_finite() && v > 0.0).then_some(v)
|
||||
}
|
||||
|
||||
/// The page-setup rows (paper, orientation, plot area, fit, scale, center, lineweights) shared by
|
||||
/// the Page Setup Manager and the Plot dialog, inside a two-column grid. Returns the setting the
|
||||
/// user changed as a `pagesetup` parameter (`paper`, `landscape`, `plotArea`, `scaleToFit`,
|
||||
/// `scale`, `center` or `lineweights`).
|
||||
fn page_rows(ui: &mut egui::Ui, salt: egui::Id, page: &cadcraft_doc::PageSetup, rows: &PageRows) -> Option<(&'static str, Value)> {
|
||||
/// The page-setup rows (device, paper, orientation, plot area, fit, scale, center, lineweights,
|
||||
/// plot style table) shared by the Page Setup Manager and the Plot dialog, inside a two-column
|
||||
/// grid. Returns the setting the user changed as a `pagesetup` parameter (`device`, `paper`,
|
||||
/// `landscape`, `plotArea`, `scaleToFit`, `scale`, `center`, `lineweights` or `plotStyleTable`).
|
||||
fn page_rows(
|
||||
ui: &mut egui::Ui,
|
||||
salt: egui::Id,
|
||||
page: &cadcraft_doc::PageSetup,
|
||||
rows: &PageRows,
|
||||
choices: &PageChoices,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let mut change = None;
|
||||
ui.label("Printer/plotter");
|
||||
egui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(device_label(page) == "None", "None").on_hover_text("PDF on the paper chosen below").clicked()
|
||||
&& device_label(page) != "None"
|
||||
{
|
||||
change = Some(("device", json!("None")));
|
||||
}
|
||||
for dev in choices.devices.iter() {
|
||||
let on = dev.name.eq_ignore_ascii_case(&page.device);
|
||||
if ui.selectable_label(on, &dev.name).on_hover_text(format!("{} ({})", dev.description, dev.output.as_str().to_uppercase())).clicked()
|
||||
&& !on
|
||||
{
|
||||
change = Some(("device", json!(dev.name)));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
// The device's papers, else the standard sizes.
|
||||
let device = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&page.device));
|
||||
ui.label("Paper size");
|
||||
egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| {
|
||||
for p in cadcraft_render::PAPER_SIZES {
|
||||
if ui.selectable_label(p.name == page.paper, p.name).clicked() && p.name != page.paper {
|
||||
change = Some(("paper", json!(p.name)));
|
||||
egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device {
|
||||
Some(dev) => {
|
||||
for p in &dev.papers {
|
||||
if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper {
|
||||
change = Some(("paper", json!(p.name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for p in cadcraft_render::PAPER_SIZES {
|
||||
if ui.selectable_label(p.name == page.paper, p.name).clicked() && p.name != page.paper {
|
||||
change = Some(("paper", json!(p.name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -611,19 +683,42 @@ fn page_rows(ui: &mut egui::Ui, salt: egui::Id, page: &cadcraft_doc::PageSetup,
|
||||
change = Some(("lineweights", json!(lw)));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Plot style table");
|
||||
egui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" {
|
||||
change = Some(("plotStyleTable", json!("None")));
|
||||
}
|
||||
for name in &choices.styles {
|
||||
let on = name.eq_ignore_ascii_case(&page.plot_style_table);
|
||||
if ui.selectable_label(on, name).clicked() && !on {
|
||||
change = Some(("plotStyleTable", json!(name)));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
change
|
||||
}
|
||||
|
||||
/// Apply a [`page_rows`] change to a page setup held by a dialog.
|
||||
/// Device and paper changes follow PAGESETUP's rules (`plotters::page_device`): a device brings
|
||||
/// its papers, orientation and printable areas.
|
||||
fn apply_page_change(page: &mut cadcraft_doc::PageSetup, key: &str, v: &Value) {
|
||||
use cadcraft_engine::cmd::plotters::page_device;
|
||||
match key {
|
||||
"device" => {
|
||||
let _ = page_device("plot", &json!({ "device": v }), page);
|
||||
}
|
||||
"paper" => {
|
||||
if let Some(ps) = v.as_str().and_then(cadcraft_render::paper_size) {
|
||||
// A device's paper, else a standard size (no device).
|
||||
if !page_device("plot", &json!({ "paper": v }), page).unwrap_or(false)
|
||||
&& let Some(ps) = v.as_str().and_then(cadcraft_render::paper_size)
|
||||
{
|
||||
page.paper = ps.name.into();
|
||||
page.width_mm = ps.width_mm;
|
||||
page.height_mm = ps.height_mm;
|
||||
}
|
||||
}
|
||||
"plotStyleTable" => page.plot_style_table = v.as_str().unwrap_or_default().chars().take(260).collect(),
|
||||
"plotArea" => page.plot_area = v.as_str().unwrap_or("extents").into(),
|
||||
"scale" => page.scale = v.as_f64().filter(|s| s.is_finite() && *s > 0.0).unwrap_or(page.scale),
|
||||
_ => {
|
||||
@@ -644,6 +739,31 @@ fn apply_page_change(page: &mut cadcraft_doc::PageSetup, key: &str, v: &Value) {
|
||||
/// Bumped each time PLOT opens its dialog, so it starts again from the page setup.
|
||||
static PLOT_GEN: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Set while PLOTWINDOW picks the Plot dialog's window ("Window <").
|
||||
const PICKING_WINDOW: &str = "plot_picking_window";
|
||||
/// Set by Print... in a preview opened from the Plot dialog: plot as soon as the dialog is back.
|
||||
pub(crate) const PLOT_NOW: &str = "plot_now";
|
||||
|
||||
/// Every frame: once PLOTWINDOW (started by the Plot dialog's "Window <") ends, bring the dialog
|
||||
/// back with its settings, plotting the picked window (unchanged if the pick was cancelled).
|
||||
pub fn plot_window_resume(app: &mut CadApp, ctx: &egui::Context) {
|
||||
if temp::<bool>(ctx, PICKING_WINDOW) != Some(true) || app.session.running.is_some() {
|
||||
return;
|
||||
}
|
||||
set_temp(ctx, PICKING_WINDOW, false);
|
||||
if let (Some(w), Some(mut st)) = (app.session.plot_window.take(), temp::<PlotState>(ctx, "plot_state")) {
|
||||
st.window = [w.min.x, w.min.y, w.max.x, w.max.y];
|
||||
st.page.plot_area = "window".into();
|
||||
set_temp(ctx, "plot_state", st);
|
||||
}
|
||||
app.ui.dialog = Some("plot".into());
|
||||
}
|
||||
|
||||
/// Set a flag the Plot dialog reads (see [`PLOT_NOW`]).
|
||||
pub(crate) fn set_flag(ctx: &egui::Context, id: &str, on: bool) {
|
||||
set_temp(ctx, id, on);
|
||||
}
|
||||
|
||||
/// Plot areas for model space (a layout always plots its sheet).
|
||||
const MODEL_AREAS: &[&str] = &["extents", "display", "limits", "window"];
|
||||
|
||||
@@ -677,8 +797,9 @@ fn plot_page(d: &cadcraft_doc::Drawing, target: &str) -> cadcraft_doc::PageSetup
|
||||
page
|
||||
}
|
||||
|
||||
/// The `plot` parameters for the dialog's settings (without the output path).
|
||||
fn plot_params(st: &PlotState) -> Value {
|
||||
/// The `plot` parameters for the dialog's settings (without the output path). `preview_dpi`
|
||||
/// asks for a PNG at that resolution instead of the device's output, on the same sheet.
|
||||
fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {
|
||||
let page = &st.page;
|
||||
let mut p = json!({
|
||||
"layout": st.target,
|
||||
@@ -687,11 +808,33 @@ fn plot_params(st: &PlotState) -> Value {
|
||||
"lineweights": page.lineweights,
|
||||
});
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
if cadcraft_render::paper_size(&page.paper).is_some() {
|
||||
o.insert("paper".into(), json!(page.paper));
|
||||
} else {
|
||||
o.insert("width".into(), json!(page.width_mm));
|
||||
o.insert("height".into(), json!(page.height_mm));
|
||||
let device = device_label(page) != "None";
|
||||
match preview_dpi {
|
||||
// The page's own sheet, so a device's paper and printable area show as they plot.
|
||||
Some(dpi) => {
|
||||
o.insert("device".into(), json!("None"));
|
||||
o.insert("output".into(), json!("png"));
|
||||
o.insert("resolution".into(), json!(dpi));
|
||||
o.insert("width".into(), json!(page.width_mm));
|
||||
o.insert("height".into(), json!(page.height_mm));
|
||||
o.insert("margins".into(), json!(page.margins_mm));
|
||||
}
|
||||
None if device => {
|
||||
o.insert("device".into(), json!(page.device));
|
||||
o.insert("paper".into(), json!(page.paper));
|
||||
}
|
||||
None if cadcraft_render::paper_size(&page.paper).is_some() => {
|
||||
o.insert("device".into(), json!("None"));
|
||||
o.insert("paper".into(), json!(page.paper));
|
||||
}
|
||||
None => {
|
||||
o.insert("device".into(), json!("None"));
|
||||
o.insert("width".into(), json!(page.width_mm));
|
||||
o.insert("height".into(), json!(page.height_mm));
|
||||
}
|
||||
}
|
||||
if style_label(page) != "None" {
|
||||
o.insert("plotStyleTable".into(), json!(page.plot_style_table));
|
||||
}
|
||||
if st.target == "Model" {
|
||||
o.insert("plotArea".into(), json!(page.plot_area));
|
||||
@@ -732,12 +875,13 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
PlotState { generation, page: plot_page(d, &target), target, window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y] }
|
||||
});
|
||||
let model = st.target == "Model";
|
||||
let (mut ok, mut cancel, mut action) = (false, false, None);
|
||||
let choices = PageChoices::new(ctx, d);
|
||||
// Print... in a preview opened from here plots with these settings.
|
||||
let mut ok = temp::<bool>(ctx, PLOT_NOW) == Some(true);
|
||||
set_temp(ctx, PLOT_NOW, false);
|
||||
let (mut cancel, mut action, mut pick, mut preview) = (false, None, false, false);
|
||||
egui::Window::new("Plot").open(open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||
egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label("Printer/plotter");
|
||||
ui.label("PDF (vector)").on_hover_text("CADCraft plots to vector PDF files");
|
||||
ui.end_row();
|
||||
ui.label("Layout");
|
||||
egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {
|
||||
for n in &targets {
|
||||
@@ -757,7 +901,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
} else {
|
||||
PageRows { areas: &["layout"], scale: false, center: false }
|
||||
};
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows) {
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices) {
|
||||
apply_page_change(&mut st.page, k, &v);
|
||||
}
|
||||
if model && st.page.plot_area == "window" {
|
||||
@@ -776,6 +920,9 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("");
|
||||
pick = ui.button("Window <").on_hover_text("Pick the window's corners in the drawing").clicked();
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
sheet_sketch(ui, &st.page, &t);
|
||||
@@ -789,22 +936,42 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
json!({ "layout": st.target, "paper": page.paper, "landscape": page.landscape, "scaleToFit": page.scale_to_fit, "lineweights": page.lineweights }),
|
||||
));
|
||||
}
|
||||
preview = ui.button("Preview").on_hover_text("The sheet as these settings plot it").clicked();
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel = ui.button("Cancel").clicked();
|
||||
ok = ui.button("OK").clicked();
|
||||
ok |= ui.button("OK").clicked();
|
||||
});
|
||||
});
|
||||
});
|
||||
// The output file type follows the device.
|
||||
let ext = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&st.page.device)).map_or("pdf", |dv| dv.output.as_str());
|
||||
set_temp(ctx, "plot_state", st.clone());
|
||||
if cancel {
|
||||
*open = false;
|
||||
}
|
||||
if pick {
|
||||
// The dialog comes back with these settings and the picked window (`plot_window_resume`).
|
||||
set_temp(ctx, PICKING_WINDOW, true);
|
||||
*open = false;
|
||||
app.start("plotwindow");
|
||||
return action;
|
||||
}
|
||||
if preview {
|
||||
// A sheet about 1600 pixels on its long side.
|
||||
let long_in = st.page.width_mm.max(st.page.height_mm).max(1.0) / 25.4;
|
||||
let mut info = format!("{} · {}", st.target, st.page.paper);
|
||||
if st.page.landscape {
|
||||
info.push_str(" · landscape");
|
||||
}
|
||||
crate::exchange::preview_plot(app, ctx, plot_params(&st, Some((1600.0 / long_in).clamp(50.0, 300.0))), info);
|
||||
return action;
|
||||
}
|
||||
if ok {
|
||||
let mut params = plot_params(&st);
|
||||
let mut params = plot_params(&st, None);
|
||||
if let Some(pick) = app.services.pick_save.as_ref() {
|
||||
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
|
||||
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
|
||||
let suggested = format!("{}-{}.pdf", stem.unwrap_or_else(|| "Drawing".into()), st.target);
|
||||
let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), st.target);
|
||||
// Cancelling the file dialog keeps the Plot dialog open with its settings.
|
||||
let Some(path) = pick(&suggested) else { return action };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
@@ -910,3 +1077,66 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plot_dialog_tests {
|
||||
use super::*;
|
||||
use cadcraft_engine::Session;
|
||||
|
||||
fn state(target: &str) -> PlotState {
|
||||
let d = cadcraft_doc::Drawing::new_metric();
|
||||
PlotState { generation: 0, page: plot_page(&d, target), target: target.into(), window: [0.0, 0.0, 10.0, 10.0] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_brings_its_papers_and_the_plot_names_them() {
|
||||
let mut st = state("Model");
|
||||
apply_page_change(&mut st.page, "device", &json!("CADCraft PNG"));
|
||||
assert_eq!(st.page.device, "CADCraft PNG");
|
||||
let dev = cadcraft_engine::cmd::plotters::find_device("CADCraft PNG").unwrap();
|
||||
assert!(dev.papers.iter().any(|p| p.name == st.page.paper), "a paper of the device: {}", st.page.paper);
|
||||
assert!(st.page.margins_mm.iter().any(|m| *m > 0.0), "the paper's printable area");
|
||||
apply_page_change(&mut st.page, "plotStyleTable", &json!("monochrome.ctb"));
|
||||
let p = plot_params(&st, None);
|
||||
assert_eq!((p["device"].as_str(), p["paper"].as_str()), (Some("CADCraft PNG"), Some(st.page.paper.as_str())));
|
||||
assert_eq!(p["plotStyleTable"], json!("monochrome.ctb"));
|
||||
// The preview plots the same sheet to PNG, whatever the device.
|
||||
let q = plot_params(&st, Some(150.0));
|
||||
assert_eq!((q["output"].as_str(), q["device"].as_str()), (Some("png"), Some("None")));
|
||||
assert_eq!(q["width"].as_f64(), Some(st.page.width_mm));
|
||||
assert_eq!(q["margins"], json!(st.page.margins_mm));
|
||||
// Back to no device: a standard paper, no device sent.
|
||||
apply_page_change(&mut st.page, "device", &json!("None"));
|
||||
apply_page_change(&mut st.page, "paper", &json!("A3"));
|
||||
let p = plot_params(&st, None);
|
||||
let a3 = cadcraft_render::paper_size("A3").unwrap().name;
|
||||
assert_eq!((p["device"].as_str(), p["paper"].as_str()), (Some("None"), Some(a3)));
|
||||
apply_page_change(&mut st.page, "plotStyleTable", &json!("None"));
|
||||
assert!(plot_params(&st, None).get("plotStyleTable").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_pick_brings_the_dialog_back_with_the_picked_window() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = CadApp::new(Session::new(), crate::Services::default());
|
||||
set_temp(&ctx, "plot_state", state("Model"));
|
||||
set_temp(&ctx, PICKING_WINDOW, true);
|
||||
app.start("plotwindow");
|
||||
plot_window_resume(&mut app, &ctx);
|
||||
assert_eq!(app.ui.dialog, None, "still picking");
|
||||
app.session.cmdline("2,3").unwrap();
|
||||
app.session.cmdline("12,9").unwrap();
|
||||
plot_window_resume(&mut app, &ctx);
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("plot"));
|
||||
let st = temp::<PlotState>(&ctx, "plot_state").unwrap();
|
||||
assert_eq!((st.page.plot_area.as_str(), st.window), ("window", [2.0, 3.0, 12.0, 9.0]));
|
||||
// A cancelled pick brings the dialog back unchanged.
|
||||
app.ui.dialog = None;
|
||||
set_temp(&ctx, PICKING_WINDOW, true);
|
||||
app.start("plotwindow");
|
||||
app.session.input(cadcraft_engine::Input::Cancel).ok();
|
||||
plot_window_resume(&mut app, &ctx);
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some("plot"));
|
||||
assert_eq!(temp::<PlotState>(&ctx, "plot_state").unwrap().window, [2.0, 3.0, 12.0, 9.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools"
|
||||
|
||||
/// UI-only commands: (id, label, menu path, shortcut).
|
||||
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.spacemouse", "3D Mouse...", &["Tools", "3D Mouse..."], None),
|
||||
("ui.spacemouse.calibrate", "Calibrate 3D Mouse", &[], None),
|
||||
("ui.spacemouse.navigate", "3D Mouse Navigation", &[], None),
|
||||
("ui.dock", "Arrange Panel", &[], None),
|
||||
("ui.dock.reset", "Reset Panel Layout", &["Window", "Reset Panel Layout"], None),
|
||||
("ui.open", "Open...", &[], Some("Cmd+O")),
|
||||
@@ -71,6 +74,9 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option<Resu
|
||||
}
|
||||
|
||||
fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
if let Some(result) = crate::spacemouse::route(app, id, params) {
|
||||
return Some(result);
|
||||
}
|
||||
if let Some(r) = crate::xrefs::route(app, id, params) {
|
||||
return Some(r);
|
||||
}
|
||||
@@ -108,7 +114,13 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
return Some(Err(format!("language must be {}", crate::i18n::Preference::codes())));
|
||||
};
|
||||
app.ui.interface_language = preference;
|
||||
Ok(json!({"interfaceLanguage": preference.code()}))
|
||||
let shown = app.language();
|
||||
if shown != preference.resolve(&app.system_languages) {
|
||||
app.set_status(
|
||||
"No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.",
|
||||
);
|
||||
}
|
||||
Ok(json!({"interfaceLanguage": preference.code(), "shown": shown}))
|
||||
}
|
||||
"ui.open" | "open" if no_path => {
|
||||
let picked = app.services.pick_open.as_ref().and_then(|f| f());
|
||||
@@ -456,7 +468,7 @@ pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option<String
|
||||
}
|
||||
}
|
||||
Entry::Sub { label, children } => {
|
||||
ui.menu_button(crate::i18n::t(label), |ui| {
|
||||
ui.menu_button(crate::i18n::menu(label), |ui| {
|
||||
for c in children {
|
||||
entry_ui(ui, c, clicked);
|
||||
}
|
||||
@@ -474,7 +486,7 @@ pub fn menu_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
|ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
for (name, entries) in &tree {
|
||||
ui.menu_button(crate::i18n::t(name), |ui| {
|
||||
ui.menu_button(crate::i18n::menu(name), |ui| {
|
||||
for e in entries {
|
||||
entry_ui(ui, e, &mut clicked);
|
||||
}
|
||||
|
||||
@@ -185,8 +185,47 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
ui.label(crate::tl!("Default save version"));
|
||||
let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default();
|
||||
let mut version = current;
|
||||
egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| {
|
||||
for v in crate::SaveVersion::ALL {
|
||||
ui.selectable_value(&mut version, v, v.year());
|
||||
}
|
||||
});
|
||||
if version != current {
|
||||
changes.insert("saveVersion".into(), json!(version.year()));
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
ui.label(crate::tl!("Automatic save every"));
|
||||
let mut minutes = cadcraft_engine::autosave::savetime();
|
||||
let max = cadcraft_engine::autosave::MAX_SAVETIME;
|
||||
if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).changed() {
|
||||
changes.insert("SAVETIME".into(), json!(minutes));
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
ui.label(crate::tl!("Automatic save file location"));
|
||||
// Edited as text; the folder changes when the field loses focus.
|
||||
let id = egui::Id::new("cc_options_savefilepath");
|
||||
let current = cadcraft_engine::autosave::savefilepath();
|
||||
let mut path = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| current.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0));
|
||||
if r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(id, path.clone()));
|
||||
}
|
||||
if r.lost_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
if path.trim() != current {
|
||||
changes.insert("SAVEFILEPATH".into(), json!(path.trim()));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
hint(ui, crate::tl!("A drawing opened from a file keeps that file's format."));
|
||||
hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off."));
|
||||
}
|
||||
|
||||
fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>, start: &mut Option<&'static str>) {
|
||||
@@ -283,6 +322,12 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>
|
||||
ui.end_row();
|
||||
});
|
||||
hint(ui, crate::tl!("An array that would create more objects than this is refused."));
|
||||
ui.add_space(6.0);
|
||||
let mut timed = app.options.time_sensitive_right_click;
|
||||
if ui.checkbox(&mut timed, crate::tl!("Time-sensitive right-click: a quick click is Enter, holding opens the menu")).changed() {
|
||||
changes.insert("timeSensitiveRightClick".into(), json!(timed));
|
||||
}
|
||||
hint(ui, crate::tl!("Turned off, a right-click while a command runs always opens the shortcut menu."));
|
||||
}
|
||||
|
||||
fn profiles(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
|
||||
@@ -9,13 +9,20 @@
|
||||
//! | name | value |
|
||||
//! |---|---|
|
||||
//! | `theme` | `"system"`, `"light"` or `"dark"` |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"` or `"uk"` |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"` or `"zh"` |
|
||||
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
|
||||
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
|
||||
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
|
||||
//! | `SAVEFILEPATH` | the folder automatic saves go to |
|
||||
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu |
|
||||
//! | `systemCursor` | keep the system cursor visible over the drawing area |
|
||||
//! | `historyLines` | command-line history lines, 0–12 |
|
||||
//! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default |
|
||||
//! | `snapMarkerSize` | object snap marker size in points, 5–31 |
|
||||
//!
|
||||
//! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state
|
||||
//! rather than in the profile.
|
||||
//!
|
||||
//! The profile variables and the interface preferences survive restarts: the host saves
|
||||
//! [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface prefs and restores it with
|
||||
//! [`CadApp::load_profile`].
|
||||
@@ -36,6 +43,9 @@ pub const PROFILE_KEY: &str = "cadcraft.profile";
|
||||
/// The largest saved profile [`CadApp::load_profile`] reads.
|
||||
const MAX_PROFILE_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// The longest SAVEFILEPATH the JSON form accepts.
|
||||
const MAX_PATH_CHARS: usize = 1024;
|
||||
|
||||
/// Object snap marker sizes the Options dialog offers, in points.
|
||||
pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive<f32> = 5.0..=31.0;
|
||||
|
||||
@@ -48,11 +58,14 @@ pub struct UiOptions {
|
||||
pub paper_background: Option<Color32>,
|
||||
/// Object snap marker size in points.
|
||||
pub snap_marker_size: f32,
|
||||
/// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu
|
||||
/// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu.
|
||||
pub time_sensitive_right_click: bool,
|
||||
}
|
||||
|
||||
impl Default for UiOptions {
|
||||
fn default() -> Self {
|
||||
UiOptions { model_background: None, paper_background: None, snap_marker_size: theme::SNAP_MARKER }
|
||||
UiOptions { model_background: None, paper_background: None, snap_marker_size: theme::SNAP_MARKER, time_sensitive_right_click: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +119,10 @@ struct UiChanges {
|
||||
model_background: Option<Option<Color32>>,
|
||||
paper_background: Option<Option<Color32>>,
|
||||
snap_marker_size: Option<f32>,
|
||||
save_version: Option<crate::SaveVersion>,
|
||||
savetime: Option<u32>,
|
||||
savefilepath: Option<String>,
|
||||
time_sensitive_right_click: Option<bool>,
|
||||
}
|
||||
|
||||
fn color_param(key: &str, v: &Value) -> Result<Option<Color32>, String> {
|
||||
@@ -137,6 +154,23 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
|
||||
let n = v.as_f64().filter(|n| SNAP_MARKER_SIZES.contains(&(*n as f32)) && n.is_finite()).ok_or("snapMarkerSize must be from 5 to 31")?;
|
||||
c.snap_marker_size = Some(n as f32);
|
||||
}
|
||||
"saveVersion" => {
|
||||
let name = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?);
|
||||
}
|
||||
"timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?),
|
||||
k if k.eq_ignore_ascii_case("SAVETIME") => {
|
||||
let max = cadcraft_engine::autosave::MAX_SAVETIME;
|
||||
let n = v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && (0.0..=f64::from(max)).contains(n))
|
||||
.ok_or_else(|| format!("SAVETIME must be from 0 to {max} minutes"))?;
|
||||
c.savetime = Some(n.round() as u32);
|
||||
}
|
||||
k if k.eq_ignore_ascii_case("SAVEFILEPATH") => {
|
||||
let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control));
|
||||
c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string());
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
Ok(true)
|
||||
@@ -154,6 +188,10 @@ fn ui_json(app: &CadApp) -> Map<String, Value> {
|
||||
m.insert("modelBackground".into(), color_json(o.model_background));
|
||||
m.insert("paperBackground".into(), color_json(o.paper_background));
|
||||
m.insert("snapMarkerSize".into(), json!(o.snap_marker_size));
|
||||
m.insert("saveVersion".into(), json!(app.ui.save_version));
|
||||
m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime()));
|
||||
m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath()));
|
||||
m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click));
|
||||
m
|
||||
}
|
||||
|
||||
@@ -206,6 +244,18 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
if let Some(n) = ui.snap_marker_size {
|
||||
app.options.snap_marker_size = n;
|
||||
}
|
||||
if let Some(v) = ui.save_version {
|
||||
app.ui.save_version = v.year().to_string();
|
||||
}
|
||||
if let Some(m) = ui.savetime {
|
||||
cadcraft_engine::autosave::set_savetime(m);
|
||||
}
|
||||
if let Some(p) = &ui.savefilepath {
|
||||
cadcraft_engine::autosave::set_savefilepath(p);
|
||||
}
|
||||
if let Some(b) = ui.time_sensitive_right_click {
|
||||
app.options.time_sensitive_right_click = b;
|
||||
}
|
||||
Ok(report(app))
|
||||
}
|
||||
|
||||
@@ -214,6 +264,7 @@ fn reset_ui(app: &mut CadApp) {
|
||||
let d = crate::UiState::default();
|
||||
app.ui.theme = d.theme;
|
||||
app.ui.save_format = d.save_format;
|
||||
app.ui.save_version = d.save_version;
|
||||
app.ui.system_cursor = d.system_cursor;
|
||||
app.ui.history_lines = d.history_lines;
|
||||
app.options = UiOptions::default();
|
||||
@@ -232,6 +283,7 @@ impl CadApp {
|
||||
"modelBackground": color_json(self.options.model_background),
|
||||
"paperBackground": color_json(self.options.paper_background),
|
||||
"snapMarkerSize": self.options.snap_marker_size,
|
||||
"timeSensitiveRightClick": self.options.time_sensitive_right_click,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -247,7 +299,7 @@ impl CadApp {
|
||||
cadcraft_engine::cmd::options::restore(&mut self.session, vars);
|
||||
}
|
||||
let mut c = UiChanges::default();
|
||||
for key in ["systemCursor", "modelBackground", "paperBackground", "snapMarkerSize"] {
|
||||
for key in ["systemCursor", "modelBackground", "paperBackground", "snapMarkerSize", "timeSensitiveRightClick"] {
|
||||
if let Some(value) = v.get(key) {
|
||||
let _ = parse_ui(key, value, &mut c);
|
||||
}
|
||||
@@ -265,6 +317,9 @@ impl CadApp {
|
||||
if let Some(n) = c.snap_marker_size {
|
||||
self.options.snap_marker_size = n;
|
||||
}
|
||||
if let Some(b) = c.time_sensitive_right_click {
|
||||
self.options.time_sensitive_right_click = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::CadApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The hatch pattern picker.
|
||||
pub mod patterns;
|
||||
/// The QuickCalc palette.
|
||||
pub mod quickcalc;
|
||||
|
||||
@@ -160,6 +162,8 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let body = Rect::from_min_max(pos2(r.left(), r.top() + tab_h), r.max);
|
||||
let mut clicked: Option<&'static str> = None;
|
||||
let mut toggle_group = None;
|
||||
let mut tool_visible: Option<(String, bool)> = None;
|
||||
let mut restore: Vec<String> = Vec::new();
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(body), |ui| {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
@@ -182,11 +186,28 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
egui::FontId::proportional(12.5),
|
||||
t.text,
|
||||
);
|
||||
icons::paint(pp, Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(11.0, 11.0)), Icon::Gear, false);
|
||||
let gear = Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(13.0, 13.0));
|
||||
let gresp = ui.interact(gear, ui.id().with(("ts_gear", name)), Sense::click());
|
||||
icons::paint(pp, Rect::from_center_size(gear.center(), vec2(11.0, 11.0)), Icon::Gear, false);
|
||||
icons::describe_control(ui, &hresp, name, Some(!collapsed), false);
|
||||
icons::describe_control(ui, &gresp, &format!("{name} options"), None, false);
|
||||
let gresp = gresp.on_hover_text(crate::tl!("Show and hide the tools of this category"));
|
||||
if hresp.clicked() {
|
||||
toggle_group = Some(name.to_string());
|
||||
}
|
||||
let hidden = |cmd: &str| app.ui.hidden_tools.iter().any(|h| h == cmd);
|
||||
egui::Popup::from_toggle_button_response(&gresp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.label(crate::tl!("Show in Tool Sets"));
|
||||
for (_, cmd, tip) in large.iter().chain(small.iter()) {
|
||||
let mut shown = !hidden(cmd);
|
||||
if ui.checkbox(&mut shown, *tip).changed() {
|
||||
tool_visible = Some((cmd.to_string(), shown));
|
||||
}
|
||||
}
|
||||
if ui.selectable_label(false, crate::tl!("Show All")).clicked() {
|
||||
restore = large.iter().chain(small.iter()).map(|(_, cmd, _)| cmd.to_string()).collect();
|
||||
}
|
||||
});
|
||||
if !collapsed {
|
||||
ui.add_space(4.0);
|
||||
// Large icons.
|
||||
@@ -195,6 +216,9 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
ui.add_space(8.0);
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 0.0);
|
||||
for (icon, cmd, tip) in large.iter() {
|
||||
if hidden(cmd) {
|
||||
continue;
|
||||
}
|
||||
ui.add_enabled_ui(command_known(cmd), |ui| {
|
||||
if icons::button(ui, *icon, 40.0, tip, false).clicked() {
|
||||
clicked = Some(cmd);
|
||||
@@ -204,7 +228,8 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
// Small icons, 8 per row.
|
||||
for row in small.chunks(8) {
|
||||
let visible: Vec<Tool> = small.iter().copied().filter(|(_, cmd, _)| !hidden(cmd)).collect();
|
||||
for row in visible.chunks(8) {
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
@@ -225,6 +250,16 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some((cmd, shown)) = tool_visible {
|
||||
if shown {
|
||||
app.ui.hidden_tools.retain(|h| h != &cmd);
|
||||
} else if !app.ui.hidden_tools.contains(&cmd) {
|
||||
app.ui.hidden_tools.push(cmd);
|
||||
}
|
||||
}
|
||||
if !restore.is_empty() {
|
||||
app.ui.hidden_tools.retain(|h| !restore.contains(h));
|
||||
}
|
||||
if let Some(g) = toggle_group {
|
||||
if let Some(i) = app.ui.collapsed_groups.iter().position(|x| *x == g) {
|
||||
app.ui.collapsed_groups.remove(i);
|
||||
@@ -274,6 +309,9 @@ fn swatch(p: &egui::Painter, r: Rect, c: Color) {
|
||||
}
|
||||
|
||||
pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
if !app.ui.show_palettes {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
egui::Panel::right("cc_palettes").exact_size(300.0).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
@@ -285,7 +323,13 @@ pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let br = Rect::from_center_size(pos2(r.left() + 30.0 + i as f32 * 52.0, r.top() + tab_h / 2.0), vec2(20.0, 20.0));
|
||||
icons::paint(&p, br, *icon, false);
|
||||
}
|
||||
icons::paint(&p, Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)), Icon::ChevronRight, false);
|
||||
let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0));
|
||||
let cresp = ui.interact(cr, ui.id().with("palettes_collapse"), Sense::click());
|
||||
icons::paint(&p, cr, Icon::ChevronRight, false);
|
||||
icons::describe_control(ui, &cresp, "Collapse Palettes", None, false);
|
||||
if cresp.on_hover_text(crate::tl!("Collapse Palettes")).clicked() {
|
||||
app.ui.show_palettes = false;
|
||||
}
|
||||
let body = Rect::from_min_max(pos2(r.left(), r.top() + tab_h), r.max);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(body), |ui| {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
@@ -972,11 +1016,9 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
EntityKind::Hatch(hh) => {
|
||||
prop_row(ui, "Pattern", |ui| {
|
||||
ui.menu_button(hh.pattern.clone(), |ui| {
|
||||
for pat in cadcraft_doc::library::standard_patterns() {
|
||||
if ui.button(pat.name).clicked() {
|
||||
set = Some(json!({ "handles": ids, "pattern": pat.name }));
|
||||
ui.close();
|
||||
}
|
||||
if let Some(name) = patterns::menu(ui, &hh.pattern) {
|
||||
set = Some(json!({ "handles": ids, "pattern": name }));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -997,6 +1039,9 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
EntityKind::Insert(i) => {
|
||||
prop_row(ui, "Name", |ui| read_only(ui, &i.block));
|
||||
prop_row(ui, "Position X", |ui| read_only(ui, &format!("{:.4}", i.insert.x)));
|
||||
prop_row(ui, "Position Y", |ui| read_only(ui, &format!("{:.4}", i.insert.y)));
|
||||
prop_row(ui, "Position Z", |ui| read_only(ui, &format!("{:.4}", i.insert.z)));
|
||||
num(ui, "Rotation", "rotation", i.rotation.to_degrees(), &mut set, &ids);
|
||||
num(ui, "Scale", "scale", i.scale.x, &mut set, &ids);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! The hatch pattern picker: a swatch of every pattern offered for new hatches, beside HATCH's
|
||||
//! and HATCHEDIT's prompts and in the Properties palette, so a pattern is chosen by its look
|
||||
//! instead of by typing its name.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, PatternChoice, Picker, pattern_choices};
|
||||
use egui::{Align2, Rect, Sense, Stroke, pos2, vec2};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// A swatch's side, in points.
|
||||
const CELL: f32 = 56.0;
|
||||
/// Room under a swatch for the pattern's name.
|
||||
const LABEL_H: f32 = 16.0;
|
||||
/// Space between swatches.
|
||||
const GAP: f32 = 6.0;
|
||||
/// Swatches per row in the Properties palette's menu.
|
||||
const MENU_COLUMNS: f32 = 6.0;
|
||||
|
||||
/// A pattern as the picker draws it: its runs in the unit square (y up), or a solid fill.
|
||||
struct Swatch {
|
||||
name: String,
|
||||
description: String,
|
||||
runs: Vec<Vec<Vec2>>,
|
||||
solid: bool,
|
||||
}
|
||||
|
||||
/// The patterns' names and line family counts, and their swatches.
|
||||
type Swatches = (Vec<(String, usize)>, Arc<Vec<Swatch>>);
|
||||
|
||||
thread_local! {
|
||||
static SWATCHES: RefCell<Swatches> = RefCell::new((Vec::new(), Arc::new(Vec::new())));
|
||||
}
|
||||
|
||||
/// The swatches of the patterns offered now, made again only when the list changes (a `.pat`
|
||||
/// file loaded more patterns).
|
||||
fn swatches() -> Arc<Vec<Swatch>> {
|
||||
let choices = pattern_choices();
|
||||
let key: Vec<(String, usize)> = choices.iter().map(|c| (c.name.clone(), c.lines.len())).collect();
|
||||
SWATCHES.with(|cell| {
|
||||
let mut cache = cell.borrow_mut();
|
||||
if cache.0 != key {
|
||||
*cache = (key, Arc::new(choices.into_iter().map(swatch).collect()));
|
||||
}
|
||||
cache.1.clone()
|
||||
})
|
||||
}
|
||||
|
||||
fn swatch(c: PatternChoice) -> Swatch {
|
||||
let solid = c.lines.is_empty();
|
||||
Swatch { runs: cadcraft_engine::render::pattern_swatch(&c.lines, 1.0), name: c.name, description: c.description, solid }
|
||||
}
|
||||
|
||||
/// Draw one swatch in `r` (the square and the name under it).
|
||||
fn paint(p: &egui::Painter, r: Rect, s: &Swatch, selected: bool, hovered: bool) {
|
||||
let t = Tokens::get();
|
||||
let square = Rect::from_min_size(r.min, vec2(CELL, CELL));
|
||||
p.rect_filled(square, 3.0, t.field);
|
||||
let inner = square.shrink(3.0);
|
||||
if s.solid {
|
||||
p.rect_filled(inner, 1.0, t.text);
|
||||
} else {
|
||||
let at = |v: &Vec2| pos2(inner.min.x + v.x as f32 * inner.width(), inner.max.y - v.y as f32 * inner.height());
|
||||
let stroke = Stroke::new(1.0, t.text);
|
||||
for run in &s.runs {
|
||||
match run.as_slice() {
|
||||
[] => {}
|
||||
[dot] => {
|
||||
p.circle_filled(at(dot), 0.7, t.text);
|
||||
}
|
||||
pts => {
|
||||
p.add(egui::Shape::line(pts.iter().map(at).collect(), stroke));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let border = match (selected, hovered) {
|
||||
(true, _) => Stroke::new(2.0, t.accent),
|
||||
(false, true) => Stroke::new(1.0, t.accent),
|
||||
_ => Stroke::new(1.0, t.border),
|
||||
};
|
||||
p.rect_stroke(square, 3.0, border, egui::StrokeKind::Inside);
|
||||
let label = Rect::from_min_max(pos2(r.min.x, square.max.y), r.max);
|
||||
let color = if selected { t.accent } else { t.text_dim };
|
||||
p.with_clip_rect(label).text(label.center_top() + vec2(0.0, 2.0), Align2::CENTER_TOP, &s.name, crate::theme::small(), color);
|
||||
}
|
||||
|
||||
/// The swatches whose name or description holds `filter`, `current` marked; returns the
|
||||
/// pattern clicked.
|
||||
pub fn grid(ui: &mut egui::Ui, current: &str, filter: &str) -> Option<String> {
|
||||
let filter = filter.trim().to_lowercase();
|
||||
let mut chosen = None;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(GAP, GAP);
|
||||
let shown = swatches();
|
||||
for s in
|
||||
shown.iter().filter(|s| filter.is_empty() || s.name.to_lowercase().contains(&filter) || s.description.to_lowercase().contains(&filter))
|
||||
{
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(CELL, CELL + LABEL_H), Sense::click());
|
||||
if ui.is_rect_visible(r) {
|
||||
paint(ui.painter(), r, s, s.name.eq_ignore_ascii_case(current), resp.hovered());
|
||||
}
|
||||
let tip = if s.description.is_empty() { s.name.clone() } else { format!("{}\n{}", s.name, s.description) };
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
chosen = Some(s.name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
chosen
|
||||
}
|
||||
|
||||
/// The Properties palette's pattern menu: the swatch grid, scrolling; returns the pattern
|
||||
/// clicked.
|
||||
pub fn menu(ui: &mut egui::Ui, current: &str) -> Option<String> {
|
||||
ui.set_width(MENU_COLUMNS * (CELL + GAP));
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| grid(ui, current, "")).inner
|
||||
}
|
||||
|
||||
/// The picker window while the prompt offers one (HATCH's boundary and pattern prompts,
|
||||
/// HATCHEDIT's pattern prompt): a click enters the pattern's name at the prompt.
|
||||
pub fn prompt_picker(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let Some(Picker::HatchPattern(current)) = app.session.current_prompt().and_then(|p| p.picker) else { return };
|
||||
let filter_id = egui::Id::new("hatch_pattern_filter");
|
||||
let mut filter: String = ctx.data_mut(|d| d.get_temp(filter_id)).unwrap_or_default();
|
||||
let mut chosen = None;
|
||||
let area = ctx.content_rect();
|
||||
egui::Window::new(crate::tl!("Hatch Pattern"))
|
||||
.id(egui::Id::new("Hatch Pattern"))
|
||||
.collapsible(true)
|
||||
.resizable(true)
|
||||
.default_size(vec2(5.0 * (CELL + GAP) + 12.0, 5.0 * (CELL + LABEL_H + GAP) + 40.0))
|
||||
.default_pos(pos2(area.max.x - 5.0 * (CELL + GAP) - 330.0, area.min.y + 120.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY));
|
||||
ui.add_space(4.0);
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
chosen = grid(ui, ¤t, &filter);
|
||||
});
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(filter_id, filter));
|
||||
if let Some(name) = chosen {
|
||||
app.cmd.buffer.clear();
|
||||
if let Err(e) = app.session.input(Input::Text(name)) {
|
||||
app.session.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Host-independent 3D mouse preferences and navigation. Device I/O lives in the desktop app.
|
||||
|
||||
use cadcraft_engine::View;
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
/// Visible extents per second at full deflection.
|
||||
pub pan_speed: f64,
|
||||
/// Exponential zoom rate per second at full deflection.
|
||||
pub zoom_speed: f64,
|
||||
pub reverse_zoom: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, pan_speed: 0.8, zoom_speed: 1.6, reverse_zoom: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Status {
|
||||
#[default]
|
||||
Unavailable,
|
||||
Searching,
|
||||
Calibrating,
|
||||
Connected,
|
||||
OpenFailed,
|
||||
Disconnected,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unavailable => "3D mouse input is unavailable on this platform",
|
||||
Self::Searching => "No supported 3D mouse found",
|
||||
Self::Calibrating => "Release the 3D mouse to calibrate its resting position",
|
||||
Self::Connected => "3D mouse connected",
|
||||
Self::OpenFailed => "3D mouse busy, possibly 3DxWare or missing device permissions",
|
||||
Self::Disconnected => "3D mouse disconnected",
|
||||
Self::Error => "3D mouse input could not be started",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub settings: Settings,
|
||||
pub status: Status,
|
||||
pub device: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
/// The native host consumes this request; no device I/O runs on the UI thread.
|
||||
pub calibrate_requested: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn load_settings(&mut self, value: &Value) -> Result<(), String> {
|
||||
let settings: Settings = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
if !settings.pan_speed.is_finite()
|
||||
|| !(0.1..=3.0).contains(&settings.pan_speed)
|
||||
|| !settings.zoom_speed.is_finite()
|
||||
|| !(0.1..=3.0).contains(&settings.zoom_speed)
|
||||
{
|
||||
return Err("3D mouse sensitivity must be between 0.1 and 3.0".into());
|
||||
}
|
||||
self.settings = settings;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn summary(&self) -> Value {
|
||||
json!({"settings": self.settings, "status": self.status, "device": self.device, "detail": self.detail})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
match id {
|
||||
"ui.spacemouse" => {
|
||||
if params.is_null() {
|
||||
app.ui.dialog = Some("spacemouse".into());
|
||||
} else if let Some(settings) = params.get("settings")
|
||||
&& let Err(error) = app.spacemouse.load_settings(settings)
|
||||
{
|
||||
return Some(Err(error));
|
||||
}
|
||||
Some(Ok(app.spacemouse.summary()))
|
||||
}
|
||||
"ui.spacemouse.calibrate" => {
|
||||
if !matches!(app.spacemouse.status, Status::Connected | Status::Calibrating) {
|
||||
return Some(Err("Connect a 3D mouse before calibrating".into()));
|
||||
}
|
||||
app.spacemouse.calibrate_requested = true;
|
||||
Some(Ok(Value::Null))
|
||||
}
|
||||
"ui.spacemouse.navigate" => Some(navigate(app, params)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of fractional pan and magnification, shared by device input and the control API.
|
||||
/// Quiet view updates keep model/paper/tiled/floating viewport handling in the engine and do
|
||||
/// not flood ZOOM Previous's history with one entry per frame.
|
||||
fn navigate(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
let pan = params.get("pan").and_then(Value::as_array).filter(|p| p.len() == 2);
|
||||
let x = pan.and_then(|p| p.first()).and_then(Value::as_f64);
|
||||
let y = pan.and_then(|p| p.get(1)).and_then(Value::as_f64);
|
||||
let zoom = params.get("zoom").and_then(Value::as_f64);
|
||||
let (Some(x), Some(y), Some(zoom)) = (x, y, zoom) else { return Err("Expected {pan: [x, y], zoom: factor}".into()) };
|
||||
if !x.is_finite() || !y.is_finite() || x.abs() > 0.25 || y.abs() > 0.25 || !zoom.is_finite() || !(0.5..=2.0).contains(&zoom) {
|
||||
return Err("3D mouse navigation frame is outside its limits".into());
|
||||
}
|
||||
if !app.spacemouse.settings.enabled {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let (width, height) = app.session.viewport_px;
|
||||
let aspect = width / height.max(1.0);
|
||||
let state = app.session.state_mut().map_err(|e| e.to_string())?;
|
||||
let view = state.view();
|
||||
let center = view.center - Vec2::new(x * view.height * aspect, y * view.height);
|
||||
let height = (view.height / zoom).clamp(1e-9, 1e12);
|
||||
if !center.is_finite() || !height.is_finite() {
|
||||
return Err("3D mouse navigation would produce an invalid view".into());
|
||||
}
|
||||
state.set_view_quiet(View { center, height });
|
||||
Ok(json!({"center": [center.x, center.y], "height": height}))
|
||||
}
|
||||
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::new("spacemouse")).open(open).resizable(false).show(ctx, |ui| {
|
||||
ui.label(crate::i18n::t(app.spacemouse.status.message()));
|
||||
if let Some(device) = &app.spacemouse.device {
|
||||
ui.label(device);
|
||||
}
|
||||
if let Some(detail) = &app.spacemouse.detail {
|
||||
ui.label(detail);
|
||||
}
|
||||
ui.separator();
|
||||
let mut settings = app.spacemouse.settings;
|
||||
let mut changed = ui.checkbox(&mut settings.enabled, crate::tl!("Enable 3D mouse navigation")).changed();
|
||||
changed |= ui.add(egui::Slider::new(&mut settings.pan_speed, 0.1..=3.0).text(crate::tl!("Pan sensitivity"))).changed();
|
||||
changed |= ui.add(egui::Slider::new(&mut settings.zoom_speed, 0.1..=3.0).text(crate::tl!("Zoom sensitivity"))).changed();
|
||||
changed |= ui.checkbox(&mut settings.reverse_zoom, crate::tl!("Reverse zoom direction")).changed();
|
||||
if changed {
|
||||
let _ = app.run("ui.spacemouse", json!({"settings": settings}));
|
||||
}
|
||||
if ui
|
||||
.add_enabled(matches!(app.spacemouse.status, Status::Connected | Status::Calibrating), egui::Button::new(crate::tl!("Calibrate")))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.spacemouse.calibrate", Value::Null);
|
||||
}
|
||||
ui.label(crate::tl!("Move sideways to pan; push/pull or twist to zoom."));
|
||||
ui.label(crate::tl!("Left device button: zoom extents. Right device button: calibrate."));
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user