Compare commits
41
Commits
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,1000 @@
|
||||
# CADCraft French interface catalog — original translations by @pechnikov2026-star.
|
||||
# License: MIT OR Apache-2.0 (the repository LICENSE-MIT and LICENSE-APACHE).
|
||||
# Written from CADCraft's English strings; no proprietary translations were copied.
|
||||
# UTF-8: context<TAB>English source<TAB>French translation.
|
||||
# Empty context = ordinary label; @plural = one|other forms with {n}.
|
||||
# Command IDs, typed keywords, technology/product names and user data stay unchanged.
|
||||
Cancel Annuler
|
||||
OK OK
|
||||
or [ ou [
|
||||
% of all commits % de toutes les révisions
|
||||
* Wildcard Match * Correspondance générique
|
||||
*VARIES* *DIVER*
|
||||
+ | Top | 2D Wireframe + | Dessus | Filaire 2D
|
||||
+Bin +bin.
|
||||
+LOC +lignes
|
||||
1 Viewport 1 fenêtre
|
||||
2 Points 2 points
|
||||
2 Viewports 2 fenêtres
|
||||
2D Solid Solide 2D
|
||||
3 Points 3 points
|
||||
3 Viewports 3 fenêtres
|
||||
3D Face Face 3D
|
||||
3D Operations Opérations 3D
|
||||
3D Polyline Polyligne 3D
|
||||
4 Viewports 4 fenêtres
|
||||
< Less than < Inférieur à
|
||||
<> Not Equal <> Différent de
|
||||
= Equals = Égal à
|
||||
> Greater than > Supérieur à
|
||||
About À propos
|
||||
About CADCraft À propos de CADCraft
|
||||
Absolute Absolu
|
||||
Add Leader Ajouter un repère
|
||||
Align Aligner
|
||||
Align Text Aligner le texte
|
||||
Aligned Aligné
|
||||
All Tous
|
||||
All Used Layers Tous les calques utilisés
|
||||
All files Tous les fichiers
|
||||
Angle Angle
|
||||
Angular Angulaire
|
||||
Annotation Visibility Visibilité des annotations
|
||||
Annotation scale Échelle des annotations
|
||||
Apparent Intersection Intersection apparente
|
||||
Append to current selection set Ajouter au jeu de sélection actuel
|
||||
Apply to: Appliquer à :
|
||||
Apply true color Appliquer la couleur RGB
|
||||
Arc Arc
|
||||
Arc Length Longueur d'arc
|
||||
Area Aire
|
||||
Arrange Panel Réorganiser le panneau
|
||||
Array Réseau
|
||||
Attach Joindre
|
||||
Attribute Attribut
|
||||
Attribute Definition Définition d'attribut
|
||||
Attribute Display Affichage des attributs
|
||||
Audit Vérifier
|
||||
Auto (system language) Auto (langue du système)
|
||||
Auto Constrain Contraintes automatiques
|
||||
AutoConstrain ContraintesAuto
|
||||
Axis, End Axe, extrémité
|
||||
Base Base
|
||||
Baseline Ligne de base
|
||||
Batch Publish Publication par lots
|
||||
Binary assets added Ressources binaires ajoutées
|
||||
Binary assets removed Ressources binaires supprimées
|
||||
Blend Raccord lissé
|
||||
Block Bloc
|
||||
Block Attribute Manager... Gestionnaire des attributs de bloc...
|
||||
Block Editor Éditeur de blocs
|
||||
Block Reference Référence de bloc
|
||||
Block... Bloc...
|
||||
Blocks Blocs
|
||||
Blue Bleu
|
||||
Bold Gras
|
||||
Boolean Opérations booléennes
|
||||
Boundary Frontière
|
||||
Boundary... Frontière...
|
||||
Box Pavé
|
||||
Break Rupture
|
||||
Break At Point Rupture en un point
|
||||
Bring to Front Mettre au premier plan
|
||||
ByBlock DuBloc
|
||||
ByColor Par couleur
|
||||
ByLayer DuCalque
|
||||
CADCraft Help Aide de CADCraft
|
||||
Cancel Annuler
|
||||
Cartesian (x, y) Cartésien (x, y)
|
||||
Center Centre
|
||||
Center Line Ligne d'axe
|
||||
Center Mark Marque de centre
|
||||
Center X Centre X
|
||||
Center Y Centre Y
|
||||
Center Z Centre Z
|
||||
Center, Diameter Centre, diamètre
|
||||
Center, Radius Centre, rayon
|
||||
Center, Start, Angle Centre, départ, angle
|
||||
Center, Start, End Centre, départ, fin
|
||||
Center, Start, Length Centre, départ, longueur
|
||||
Chamfer Chanfrein
|
||||
Change Space Changer d'espace
|
||||
Change to Current Layer Déplacer vers le calque courant
|
||||
Circle Cercle
|
||||
Circumference Circonférence
|
||||
Clear Effacer
|
||||
Clipboard Presse-papiers
|
||||
Close Fermer
|
||||
Close All Tout fermer
|
||||
Closed Fermé
|
||||
Coincident Coïncidence
|
||||
Collapse Tool Sets Réduire les jeux d'outils
|
||||
Collinear Colinéarité
|
||||
Color Couleur
|
||||
Color 8 Couleur 8
|
||||
Color 9 Couleur 9
|
||||
Color Index Indice de couleur
|
||||
Color... Couleur...
|
||||
Command History Historique des commandes
|
||||
Command Line Ligne de commande
|
||||
Command Line History Lines Lignes d'historique de la ligne de commande
|
||||
Command Reference Référence des commandes
|
||||
Commits Révisions
|
||||
Company Société
|
||||
Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Conception et dessin assistés par ordinateur : application de CAO open source, développée de manière indépendante en pur Rust.
|
||||
Concentric Concentricité
|
||||
Cone Cône
|
||||
Constraint Bars Barres de contraintes
|
||||
Constraint Settings Paramètres de contraintes
|
||||
Construction Line Ligne de construction
|
||||
Contents Sommaire
|
||||
Continue Continuer
|
||||
Contributors Contributeurs
|
||||
Control Vertices Sommets de contrôle
|
||||
Copy Copier
|
||||
Copy Layout Copier la présentation
|
||||
Copy Nested Objects Copier les objets imbriqués
|
||||
Copy Selection Copier la sélection
|
||||
Copy with Base Point Copier avec point de base
|
||||
Count Nombre
|
||||
Create Block Créer un bloc
|
||||
Creates a user parameter Crée un paramètre utilisateur
|
||||
Ctrl+Enter to finish Ctrl+Entrée pour terminer
|
||||
Current layer Calque courant
|
||||
Current selection Sélection actuelle
|
||||
Current: {color} Actuel : {color}
|
||||
Cut Couper
|
||||
Cyan Cyan
|
||||
Cylinder Cylindre
|
||||
DWG DWG
|
||||
DXF DXF
|
||||
Dark Sombre
|
||||
Default Par défaut
|
||||
Default Save Format Format d'enregistrement par défaut
|
||||
Define Attributes Définir les attributs
|
||||
Define Attributes... Définir les attributs...
|
||||
Defined width Largeur définie
|
||||
Degree Degré
|
||||
Delete Supprimer
|
||||
Delete Column Supprimer la colonne
|
||||
Delete Constraints Supprimer les contraintes
|
||||
Delete Dimension Style Supprimer le style de cotation
|
||||
Delete Duplicate Objects Supprimer les objets en double
|
||||
Delete Layer Supprimer le calque
|
||||
Delete Layer State Supprimer l'état des calques
|
||||
Delete Layout Supprimer la présentation
|
||||
Delete Row Supprimer la ligne
|
||||
Delete Text Style Supprimer le style de texte
|
||||
Delta X Écart X
|
||||
Delta Y Écart Y
|
||||
Description Description
|
||||
Deselect All Désélectionner tout
|
||||
Diameter Diamètre
|
||||
Dim style Style de cotation
|
||||
Dimension Cotation
|
||||
Dimension Space Espace entre cotations
|
||||
Dimension Style Override Substitution de style de cotation
|
||||
Dimension Style... Style de cotation...
|
||||
Dimension Text Edit Modifier le texte de cotation
|
||||
Dimension style Style de cotation
|
||||
Dimensional Constraint Contrainte dimensionnelle
|
||||
Dimensional Constraint Parameters Paramètres de contraintes dimensionnelles
|
||||
Dimensional Constraints Contraintes dimensionnelles
|
||||
Disassociate Dimensions Dissocier les cotations
|
||||
Display name Nom d'affichage
|
||||
Distance Distance
|
||||
Divide Diviser
|
||||
Do not plot (click: plot) Ne pas tracer (cliquer pour tracer)
|
||||
Donut Anneau
|
||||
Double-click to edit the description Double-cliquez pour modifier la description
|
||||
Down Bas
|
||||
Drafting Dessin
|
||||
Drafting Settings Paramètres de dessin
|
||||
Drafting Settings... Paramètres de dessin...
|
||||
Draw Dessin
|
||||
Draw Order Ordre de dessin
|
||||
Drawing (DWG) Dessin (DWG)
|
||||
Drawing (DXF) Dessin (DXF)
|
||||
Drawing Limits Limites du dessin
|
||||
Drawing Utilities Utilitaires de dessin
|
||||
Drawings (DWG, DXF) Dessins (DWG, DXF)
|
||||
Dynamic Dimensions Cotations dynamiques
|
||||
Dynamic Input Saisie dynamique
|
||||
Dynamic Input (F12) Saisie dynamique (F12)
|
||||
E Est
|
||||
Edit Édition
|
||||
Edit Text... Modifier le texte...
|
||||
Elevation Élévation
|
||||
Ellipse Ellipse
|
||||
Elliptical Arc Arc elliptique
|
||||
Enable Dynamic Input (F12) Activer la saisie dynamique (F12)
|
||||
End Width Largeur finale
|
||||
End X Extrémité X
|
||||
End Y Extrémité Y
|
||||
End angle Angle final
|
||||
Endpoint Extrémité
|
||||
Enter Top, Zero or Bottom. Entrez Top, Zero ou Bottom.
|
||||
Enter a fit data option Entrez une option de données d'ajustage
|
||||
Enter a pattern name Entrez un nom de motif
|
||||
Enter a vertex editing option Entrez une option d'édition de sommet
|
||||
Enter an option Entrez une option
|
||||
Enter angle of xline Entrez l'angle de la ligne de construction
|
||||
Enter angle of xline (0) or Entrez l'angle de la ligne de construction (0) ou
|
||||
Enter area of rectangle in current units Entrez l'aire du rectangle dans les unités courantes
|
||||
Enter attribute prompt Entrez l'invite de l'attribut
|
||||
Enter attribute tag name Entrez le nom de l'étiquette d'attribut
|
||||
Enter attribute value Entrez la valeur de l'attribut
|
||||
Enter block name Entrez le nom du bloc
|
||||
Enter constraint type Entrez le type de contrainte
|
||||
Enter continuity Entrez la continuité
|
||||
Enter default attribute value Entrez la valeur par défaut de l'attribut
|
||||
Enter degree of spline Entrez le degré de la spline
|
||||
Enter delta angle Entrez l'écart d'angle
|
||||
Enter delta length or Entrez l'écart de longueur ou
|
||||
Enter dimension text Entrez le texte de cotation
|
||||
Enter dimension text (<> = measurement) Entrez le texte de cotation (<> = mesure)
|
||||
Enter dimension value or [name=expression] Entrez la valeur de cotation ou [name=expression]
|
||||
Enter dimension variable name to override Entrez le nom de la variable de cotation à substituer
|
||||
Enter justification type Entrez le type de justification
|
||||
Enter leader text Entrez le texte du repère
|
||||
Enter mline scale Entrez l'échelle de la multiligne
|
||||
Enter mode Entrez le mode
|
||||
Enter name of block to insert Entrez le nom du bloc à insérer
|
||||
Enter new text Entrez le nouveau texte
|
||||
Enter next point Entrez le point suivant
|
||||
Enter number of columns Entrez le nombre de colonnes
|
||||
Enter number of data rows Entrez le nombre de lignes de données
|
||||
Enter number of items Entrez le nombre d'éléments
|
||||
Enter number of items along path Entrez le nombre d'éléments le long du tracé
|
||||
Enter number of sides Entrez le nombre de côtés
|
||||
Enter number of turns Entrez le nombre de tours
|
||||
Enter percentage length Entrez la longueur en pourcentage
|
||||
Enter rectangle length Entrez la longueur du rectangle
|
||||
Enter rectangle width Entrez la largeur du rectangle
|
||||
Enter scale factor Entrez le facteur d'échelle
|
||||
Enter text Entrer le texte
|
||||
Enter text (use \P for new paragraphs) Entrer le texte (utilisez \P pour les nouveaux paragraphes)
|
||||
Enter the number of segments Entrer le nombre de segments
|
||||
Enter twist direction of helix Entrer le sens de vrillage de l'hélice
|
||||
Entire drawing Tout le dessin
|
||||
Equal Égalité
|
||||
Erase Effacer
|
||||
Exclude from new selection set Exclure du nouveau jeu de sélection
|
||||
Explode Décomposer
|
||||
Export Exporter
|
||||
Export to PDF... Exporter en PDF...
|
||||
Expression Expression
|
||||
Extend Prolonger
|
||||
Extension Prolongement
|
||||
Extents Étendue
|
||||
Extrude Extruder
|
||||
File Fichier
|
||||
File Tab Onglet Fichier
|
||||
Fillet Congé
|
||||
Filter Filtre
|
||||
Filters Filtres
|
||||
Find and Replace Rechercher et remplacer
|
||||
Find... Rechercher...
|
||||
First Premier
|
||||
First commit Première révision
|
||||
Fit Points Points d'ajustement
|
||||
Fix Fixation
|
||||
Flatten Objects Aplatir les objets
|
||||
Format Format
|
||||
Freehand À main levée
|
||||
Freeze Geler
|
||||
Freeze Layer Geler le calque
|
||||
Freeze in new viewports Geler dans les nouvelles fenêtres
|
||||
Freeze/Thaw Geler/Dégeler
|
||||
Freeze/Thaw in all viewports Geler/Dégeler dans toutes les fenêtres
|
||||
Freeze/Thaw in the current viewport Geler/Dégeler dans la fenêtre courante
|
||||
From À partir de
|
||||
General Général
|
||||
Geometric Center Centre géométrique
|
||||
Geometric Constraint Contrainte géométrique
|
||||
Geometric Constraints Contraintes géométriques
|
||||
Geometry Géométrie
|
||||
Get Variable Obtenir la variable
|
||||
Get View Obtenir la vue
|
||||
Global Global
|
||||
Global width Largeur globale
|
||||
Grab bag Liste
|
||||
Gradient Dégradé
|
||||
Gradient... Dégradé...
|
||||
Green Vert
|
||||
Grid (F7) Grille (F7)
|
||||
Grid Display Affichage de la grille
|
||||
Grid On (F7) Activer la grille (F7)
|
||||
Grid spacing Espacement de la grille
|
||||
Grip Mirror Miroir par poignées
|
||||
Grip Rotate Rotation par poignées
|
||||
Grip Scale Échelle par poignées
|
||||
Grip Stretch Étirement par poignées
|
||||
Group Grouper
|
||||
Group Edit Modifier le groupe
|
||||
Hatch Hachures
|
||||
Hatch Edit Modifier les hachures
|
||||
Hatch... Hachures...
|
||||
Height Hauteur
|
||||
Height: {height} Hauteur : {height}
|
||||
Helix Hélice
|
||||
Help Aide
|
||||
Hide All Tout masquer
|
||||
Hide All Constraint Bars Masquer toutes les barres de contraintes
|
||||
Hide All Dynamic Dimensions Masquer toutes les cotes dynamiques
|
||||
Hide Layer List Masquer la liste des calques
|
||||
Hide Palettes Masquer les palettes
|
||||
Home Vue d'accueil
|
||||
Horizontal Horizontal
|
||||
HorizontalDistance DistanceHorizontale
|
||||
How to apply: Mode d'application :
|
||||
ID Point ID du point
|
||||
Import Importer
|
||||
In Zoom avant
|
||||
In-window Menu Bar Barre de menus dans la fenêtre
|
||||
Include in new selection set Inclure dans le nouveau jeu de sélection
|
||||
Increment angle Incrément d'angle
|
||||
Index Color Couleur indexée
|
||||
Index color: {index} Couleur indexée : {index}
|
||||
Inquiry Interroger
|
||||
Insert Insertion
|
||||
Insert Block Insérer un bloc
|
||||
Insert Column Insérer une colonne
|
||||
Insert Row Insérer une ligne
|
||||
Insert this block Insérer ce bloc
|
||||
Insertion Point d'insertion
|
||||
Insertion X Insertion X
|
||||
Insertion Y Insertion Y
|
||||
Insertion Z Insertion Z
|
||||
Inspect Constraints Inspecter les contraintes
|
||||
Inspect Drawing Inspecter le dessin
|
||||
Interface Language Langue de l'interface
|
||||
Interface Theme Thème de l'interface
|
||||
Intersect Intersection
|
||||
Intersection Intersection
|
||||
Isolate Isoler
|
||||
Isolate Layer Isoler le calque
|
||||
Isometric Drafting Dessin isométrique
|
||||
Join Rejoindre
|
||||
Join the ArtCraft Discord… Rejoindre le Discord d'ArtCraft…
|
||||
Join us on Discord Rejoignez-nous sur Discord
|
||||
Language Langue
|
||||
Last Dernier
|
||||
Last commit Dernière révision
|
||||
Layer Calque
|
||||
Layer Delete Supprimer le calque
|
||||
Layer Match Faire correspondre le calque
|
||||
Layer Merge Fusionner les calques
|
||||
Layer Off Désactiver le calque
|
||||
Layer Properties Propriétés du calque
|
||||
Layer Properties Manager Gestionnaire des propriétés des calques
|
||||
Layer Properties Manager — current viewport Gestionnaire des propriétés des calques — fenêtre courante
|
||||
Layer State État des calques
|
||||
Layer States Manager... Gestionnaire des états de calques...
|
||||
Layer Tools Outils de calque
|
||||
Layer contains objects (double-click to make current) Le calque contient des objets (double-cliquez pour le rendre courant)
|
||||
Layer is empty (double-click to make current) Le calque est vide (double-cliquez pour le rendre courant)
|
||||
Layers Calques
|
||||
Layers Palette Palette des calques
|
||||
Layers displayed: {shown} of {total}. Current layer: {layer} Calques affichés : {shown} sur {total}. Calque courant : {layer}
|
||||
Layout Présentation
|
||||
Layout list Liste des présentations
|
||||
Leader Repère
|
||||
Left Gauche
|
||||
Length Longueur
|
||||
Length X Longueur X
|
||||
Length Y Longueur Y
|
||||
Lengthen Modifier la longueur
|
||||
Light Clair
|
||||
Line Ligne
|
||||
Line delta Variation de lignes
|
||||
Linear Linéaire
|
||||
Lines +/− Lignes +/−
|
||||
Lines added Lignes ajoutées
|
||||
Lines deleted Lignes supprimées
|
||||
Linetype Type de ligne
|
||||
Linetype Scale Échelle du type de ligne
|
||||
Linetype scale Échelle du type de ligne
|
||||
Ltscale Échelle du type de ligne
|
||||
Linetype... Type de ligne...
|
||||
Lineweight Épaisseur de ligne
|
||||
Lineweight... Épaisseur de ligne...
|
||||
List Liste
|
||||
List Blocks Liste des blocs
|
||||
List Dimension Styles Liste des styles de cote
|
||||
List Groups Liste des groupes
|
||||
List Layer States Liste des états de calques
|
||||
List Layouts Liste des présentations
|
||||
List System Variables Liste des variables système
|
||||
List Text Styles Liste des styles de texte
|
||||
Load all Tout charger
|
||||
Load... Charger...
|
||||
Loaded linetypes Types de ligne chargés
|
||||
Lock Verrouiller
|
||||
Lock Layer Verrouiller le calque
|
||||
Lock/Unlock Verrouiller/Déverrouiller
|
||||
Loft Loft
|
||||
MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Non affilié à Autodesk, Inc.
|
||||
MODEL MODÈLE
|
||||
MText Texte multiligne
|
||||
Magenta Magenta
|
||||
Major line every Ligne principale tous les
|
||||
Make Current Rendre courant
|
||||
Make Object's Layer Current Rendre le calque de l'objet courant
|
||||
Make... Créer...
|
||||
Match Layer Faire correspondre le calque
|
||||
Match Properties Copier les propriétés
|
||||
Measure Mesurer
|
||||
Measure Distance Mesurer la distance
|
||||
Measure Geometry Mesurer la géométrie
|
||||
Merge Cells Fusionner les cellules
|
||||
Merged PRs PR fusionnés
|
||||
Mid Between 2 Points Milieu entre 2 points
|
||||
Midpoint Milieu
|
||||
Mirror Miroir
|
||||
Model Modèle
|
||||
Model Space (in viewport) Espace objet (dans la fenêtre)
|
||||
Modeling Modélisation
|
||||
Models Modèles
|
||||
Modify Modifier
|
||||
Move Déplacer
|
||||
Multileader Repère multiple
|
||||
Multileader Style... Style de repère multiple...
|
||||
Multileader style Style de repère multiple
|
||||
Multiline Multiligne
|
||||
Multiline Text Texte multiligne
|
||||
Multiline Text... Texte multiligne...
|
||||
Multiple Multiple
|
||||
Multiple Point Points multiples
|
||||
My Mes
|
||||
My properties: not available yet Mes propriétés : pas encore disponibles
|
||||
N N
|
||||
Name Nom
|
||||
Name (A–Z) Nom (A–Z)
|
||||
Nearest Plus proche
|
||||
New Nouveau
|
||||
New (metric) Nouveau (métrique)
|
||||
New Drawing Nouveau dessin
|
||||
New Drawing (⌘N) Nouveau dessin (⌘N)
|
||||
New Drawing... Nouveau dessin...
|
||||
New Layer Nouveau calque
|
||||
New Layout Nouvelle présentation
|
||||
New VP Freeze Geler nouvelles fenêtres
|
||||
New Viewports... Nouvelles fenêtres...
|
||||
New layout Nouvelle présentation
|
||||
New state name Nom du nouvel état
|
||||
No Non
|
||||
No blocks defined in this drawing. Aucun bloc défini dans ce dessin.
|
||||
No contributor data was built into this copy. Cette version ne contient pas de données sur les contributeurs.
|
||||
No model credits were built into this copy. Cette version ne contient pas de crédits de modèles.
|
||||
No saved layer states Aucun état de calque enregistré
|
||||
No selection Aucune sélection
|
||||
Node Nœud
|
||||
None Aucun
|
||||
Not available Non disponible
|
||||
Not available yet Pas encore disponible
|
||||
OK OK
|
||||
Object Objet
|
||||
Object Snap Accrochage aux objets
|
||||
Object Snap (F3) Accrochage aux objets (F3)
|
||||
Object Snap On (F3) Activer l'accrochage aux objets (F3)
|
||||
Object Snap Tracking Suivi de l'accrochage aux objets
|
||||
Object Snap Tracking (F11) Suivi de l'accrochage aux objets (F11)
|
||||
Object type: Type d'objet :
|
||||
Off Désactivé
|
||||
Offset Décalage
|
||||
On Activé
|
||||
On/Off Activer/Désactiver
|
||||
One of the Crafting Apps by the ArtCraft team and community. L'une des applications Crafting Apps de l'équipe et de la communauté ArtCraft.
|
||||
Open (⌘O) Ouvrir (⌘O)
|
||||
Open Sample Drawing Ouvrir un dessin d'exemple
|
||||
Open sample drawing Ouvrir un dessin d'exemple
|
||||
Open... Ouvrir...
|
||||
Operator: Opérateur :
|
||||
Orbit Orbite
|
||||
Ordinate Ordonnée
|
||||
Ortho (F8) Ortho (F8)
|
||||
Ortho Mode Mode Ortho
|
||||
Ortho Mode (F8) Mode Ortho (F8)
|
||||
Osnap Settings... Paramètres d'accrochage aux objets...
|
||||
Out Zoom arrière
|
||||
Override Outrepasser
|
||||
PAPER PAPIER
|
||||
Percent Pourcentage
|
||||
Polar (distance < angle) Polaire (distance < angle)
|
||||
PRs PR
|
||||
Page Setup Manager Gestionnaire des mises en page
|
||||
Page Setup Manager... Gestionnaire des mises en page...
|
||||
Pan Panoramique
|
||||
Pan Down Panoramique vers le bas
|
||||
Pan Left Panoramique vers la gauche
|
||||
Pan Right Panoramique vers la droite
|
||||
Pan Up Panoramique vers le haut
|
||||
Paper Space Espace papier
|
||||
Parallel Parallèle
|
||||
Parameters Manager Gestionnaire des paramètres
|
||||
Parametric Paramétrique
|
||||
Paste Coller
|
||||
Paste as Block Coller en tant que bloc
|
||||
Paste to Original Coordinates Coller aux coordonnées d'origine
|
||||
Path Array Réseau sur trajectoire
|
||||
Pattern Motif
|
||||
Perpendicular Perpendiculaire
|
||||
Plot Tracer
|
||||
Plot (click: do not plot) Tracer (cliquer : ne pas tracer)
|
||||
Plot Preview Aperçu du tracé
|
||||
Plot style Style de tracé
|
||||
Plot style attached to Style de tracé associé à
|
||||
Plot style table Table de styles de tracé
|
||||
Plot Style... Style de tracé...
|
||||
Plot Style Manager Gestionnaire des styles de tracé
|
||||
Plot Style Manager... Gestionnaire des styles de tracé...
|
||||
Plot Styles... Styles de tracé...
|
||||
New CTB Nouvelle CTB
|
||||
New STB Nouvelle STB
|
||||
New style Nouveau style
|
||||
Reset Panel Layout Réinitialiser la disposition des panneaux
|
||||
Use for this layout Utiliser pour cet espace
|
||||
Display plot styles Afficher les styles de tracé
|
||||
No plot style tables. Aucune table de styles de tracé.
|
||||
A built-in table: editing it keeps an edited copy in the drawing. Table intégrée : sa modification conserve une copie modifiée dans le dessin.
|
||||
Keep an editable copy of this table in the drawing Conserver une copie modifiable de cette table dans le dessin
|
||||
Layout {name}: {table} Espace {name} : {table}
|
||||
Grayscale Niveaux de gris
|
||||
Screening Trame
|
||||
Use object linetype Utiliser le type de ligne de l'objet
|
||||
Use object lineweight Utiliser l'épaisseur de ligne de l'objet
|
||||
Remove Supprimer
|
||||
Add Ajouter
|
||||
Open a drawing first. Ouvrez d'abord un dessin.
|
||||
Plot table type Type de table de tracé
|
||||
Color-dependent Dépendante des couleurs
|
||||
Named Nommée
|
||||
Plus/minus Plus/moins
|
||||
Point Point
|
||||
Point Filters Filtres de points
|
||||
Point Style... Style de point...
|
||||
Polar Array Réseau polaire
|
||||
Polar Tracking Recherche polaire
|
||||
Polar Tracking (F10) Recherche polaire (F10)
|
||||
Polar Tracking On (F10) Activer la recherche polaire (F10)
|
||||
Polar: {distance} < {angle}° Polaire : {distance} < {angle}°
|
||||
Polygon Polygone
|
||||
Polygonal Polygonal
|
||||
Polyline Polyligne
|
||||
Position X Position X
|
||||
Position Y Position Y
|
||||
Position Z Position Z
|
||||
Previous Précédent
|
||||
Previous Layer Calque précédent
|
||||
Primitives Primitives
|
||||
Print Imprimer
|
||||
Print... Imprimer...
|
||||
Properties Propriétés
|
||||
Properties Inspector Inspecteur de propriétés
|
||||
Properties: Propriétés :
|
||||
Proxy Objet proxy
|
||||
Purge Purger
|
||||
Pyramid Pyramide
|
||||
Quadrant Quadrant
|
||||
Query Entities Interroger les entités
|
||||
Quick Rapide
|
||||
Quick Dimension Cotation rapide
|
||||
Quick Properties Propriétés rapides
|
||||
Quick Select Sélection rapide
|
||||
Quick Select Properties Propriétés de la sélection rapide
|
||||
Quick Select... Sélection rapide...
|
||||
QuickCalc Calculatrice rapide
|
||||
Quit CADCraft Quitter CADCraft
|
||||
Radius Rayon
|
||||
Raster Image Image raster
|
||||
Ray Rayon
|
||||
Read-only Lecture seule
|
||||
Real name Nom réel
|
||||
Realtime Temps réel
|
||||
Reassociate Dimensions Réassocier les cotes
|
||||
Recent Récents
|
||||
Recover... Récupérer...
|
||||
Rectangle Rectangle
|
||||
Rectangular Rectangulaire
|
||||
Rectangular Array Réseau rectangulaire
|
||||
Red Rouge
|
||||
Redo Rétablir
|
||||
Redo (⇧⌘Z) Rétablir (⇧⌘Z)
|
||||
Redraw Redessiner
|
||||
Regen Régénérer
|
||||
Regen All Régénérer tout
|
||||
Region Région
|
||||
Region/Mass Properties Propriétés de région/masse
|
||||
Relative Relatif
|
||||
Remove Leader Supprimer la ligne de repère
|
||||
Remove the viewport override Supprimer le remplacement de la fenêtre
|
||||
Rename Dimension Style Renommer le style de cote
|
||||
Rename Layer Renommer le calque
|
||||
Rename Layer State Renommer l'état des calques
|
||||
Rename Layout Renommer l'espace
|
||||
Rename Text Style Renommer le style de texte
|
||||
Rename... Renommer...
|
||||
Repeat {command} Répéter {command}
|
||||
Requires a point (x,y, @dx,dy or @d<a). Nécessite un point (x,y, @dx,dy ou @d<a).
|
||||
Reset Palettes Réinitialiser les palettes
|
||||
Restore Restaurer
|
||||
Restore Layer State Restaurer l'état des calques
|
||||
Reverse Inverser
|
||||
Reverse the order Inverser l'ordre
|
||||
Revision Cloud Nuage de révisions
|
||||
Revolve Révolution
|
||||
Right Droite
|
||||
Rotate Pivoter
|
||||
Rotation Rotation
|
||||
S S
|
||||
Save Enregistrer
|
||||
Save (⌘S) Enregistrer (⌘S)
|
||||
Save As Enregistrer sous
|
||||
Save As... Enregistrer sous...
|
||||
Save Layer State Enregistrer l'état des calques
|
||||
Save State Enregistrer l'état
|
||||
Scale Échelle
|
||||
Search for layer Rechercher un calque
|
||||
Search for parameter Rechercher un paramètre
|
||||
Second and next points: Deuxième point et points suivants :
|
||||
Select Sélectionner
|
||||
Select 2D polyline Sélectionner une polyligne 2D
|
||||
Select All Sélectionner tout
|
||||
Select Objects Sélectionner les objets
|
||||
Select a 2D polyline. Sélectionner une polyligne 2D.
|
||||
Select a block reference. Sélectionner une référence de bloc.
|
||||
Select a circle or arc. Sélectionner un cercle ou un arc.
|
||||
Select a closed polyline Sélectionner une polyligne fermée
|
||||
Select a closed polyline, circle, ellipse or closed spline. Sélectionner une polyligne fermée, un cercle, une ellipse ou une spline fermée.
|
||||
Select a closed polyline. Sélectionner une polyligne fermée.
|
||||
Select a curve as the path. Sélectionner une courbe comme trajectoire.
|
||||
Select a dimension constraint type Sélectionner un type de contrainte dimensionnelle
|
||||
Select a line object Sélectionner une ligne
|
||||
Select a line object. Sélectionner une ligne.
|
||||
Select a line or an arc. Sélectionner une ligne ou un arc.
|
||||
Select a line or polyline segment Sélectionner une ligne ou un segment de polyligne
|
||||
Select a line, arc or open polyline. Sélectionner une ligne, un arc ou une polyligne ouverte.
|
||||
Select a line, arc, circle, polyline, ellipse or spline as the path. Sélectionner une ligne, un arc, un cercle, une polyligne, une ellipse ou une spline comme trajectoire.
|
||||
Select a line, circle, arc or polyline segment. Sélectionner une ligne, un cercle, un arc ou un segment de polyligne.
|
||||
Select a line. Sélectionner une ligne.
|
||||
Select a polyline. Sélectionner une polyligne.
|
||||
Select a text, mtext, attribute definition or dimension. Sélectionner un texte, un texte multiligne, une définition d'attribut ou une cote.
|
||||
Select an annotation object Sélectionner un objet d'annotation
|
||||
Select an arc or circle Sélectionner un arc ou un cercle
|
||||
Select an arc. Sélectionner un arc.
|
||||
Select an object Sélectionner un objet
|
||||
Select an object to change or Sélectionner un objet à modifier ou
|
||||
Select an object to measure or Sélectionner un objet à mesurer ou
|
||||
Select an object. Sélectionner un objet.
|
||||
Select an open curve. Sélectionner une courbe ouverte.
|
||||
Select an open line, arc, polyline, ellipse arc or spline. Sélectionner une ligne, un arc, une polyligne, un arc elliptique ou une spline ouverts.
|
||||
Select arc or circle Sélectionner un arc ou un cercle
|
||||
Select arc or polyline arc segment Sélectionner un arc ou un segment d'arc de polyligne
|
||||
Select arc style Sélectionner un style d'arc
|
||||
Select arc, circle, line Sélectionner un arc, un cercle, une ligne
|
||||
Select at least two objects to join. Sélectionner au moins deux objets à joindre.
|
||||
Select circle or arc to add center mark Sélectionner un cercle ou un arc pour ajouter une marque de centre
|
||||
Select closed objects (regions, closed polylines, circles). Sélectionner des objets fermés (régions, polylignes fermées, cercles).
|
||||
Select first line Sélectionner la première ligne
|
||||
Select first line or arc Sélectionner la première ligne ou le premier arc
|
||||
Select first object Sélectionner le premier objet
|
||||
Select first object or Sélectionner le premier objet ou
|
||||
Select first point Sélectionner le premier point
|
||||
Select nested objects to copy or Sélectionner des objets imbriqués à copier ou
|
||||
Select object Sélectionner un objet
|
||||
Select object to dimension Sélectionner un objet à coter
|
||||
Select object to divide Sélectionner un objet à diviser
|
||||
Select object to extend or shift-select to trim Sélectionner un objet à prolonger ou sélectionner avec Maj pour rogner
|
||||
Select object to measure Sélectionner un objet à mesurer
|
||||
Select object to offset Sélectionner un objet à décaler
|
||||
Select object to trim or shift-select to extend Sélectionner un objet à rogner ou sélectionner avec Maj pour prolonger
|
||||
Select objects Sélectionner les objets
|
||||
Select objects (crossing window first corner) Sélectionner les objets (premier coin de la fenêtre de capture)
|
||||
Select objects to array. Sélectionner les objets à répartir en réseau.
|
||||
Select objects to stretch by crossing-window or crossing-polygon... Sélectionner les objets à étirer par fenêtre de capture ou polygone de capture...
|
||||
Select objects: Sélectionner les objets :
|
||||
Select one polyline (Multiple is available through the JSON form). Sélectionner une polyligne (l'option Multiple est disponible via le formulaire JSON).
|
||||
Select path curve Sélectionner la courbe de trajectoire
|
||||
Select point or object Sélectionner un point ou un objet
|
||||
Select polyline or Sélectionner une polyligne ou
|
||||
Select second line Sélectionner la deuxième ligne
|
||||
Select second line or shift-select to apply corner Sélectionner la deuxième ligne ou sélectionner avec Maj pour former le coin
|
||||
Select second object Sélectionner le deuxième objet
|
||||
Select second object or shift-select to apply corner Sélectionner le deuxième objet ou sélectionner avec Maj pour former le coin
|
||||
Select second point Sélectionner le deuxième point
|
||||
Select spline Sélectionner une spline
|
||||
Select symmetry line Sélectionner l'axe de symétrie
|
||||
Select two points Sélectionner deux points
|
||||
Selection Cycling Cycle de sélection
|
||||
Send to Back Envoyer à l'arrière-plan
|
||||
Set Current Définir comme courant
|
||||
Set Current Dimension Style Définir le style de cote courant
|
||||
Set Current Text Style Définir le style de texte courant
|
||||
Set Layer Properties Définir les propriétés du calque
|
||||
Set Properties Définir les propriétés
|
||||
Set Table Cell Définir la cellule de tableau
|
||||
Set Variable Définir la variable
|
||||
Set View Définir la vue
|
||||
Share Partager
|
||||
Show Afficher
|
||||
Show All Afficher tout
|
||||
Show All Constraint Bars Afficher toutes les barres de contrainte
|
||||
Show All Dynamic Dimensions Afficher toutes les cotes dynamiques
|
||||
Show Layer List Afficher la liste des calques
|
||||
Show/Hide Lineweight Afficher/Masquer l'épaisseur des lignes
|
||||
Show/Hide Transparency Afficher/Masquer la transparence
|
||||
Single Line Text Texte sur une ligne
|
||||
Single Point Point unique
|
||||
Single... Unique...
|
||||
Smooth Lissage
|
||||
Snap Accrochage
|
||||
Snap Mode Mode d'accrochage
|
||||
Snap Mode (F9) Mode d'accrochage (F9)
|
||||
Snap On (F9) Activer l'accrochage (F9)
|
||||
Snap X spacing Pas d'accrochage X
|
||||
Snap and Grid Accrochage et grille
|
||||
Solid Solide
|
||||
Sort Trier
|
||||
Specify a point Spécifier un point
|
||||
Specify a scale for the hatch pattern Spécifier une échelle pour le motif de hachures
|
||||
Specify a second extension line origin Spécifier l'origine de la deuxième ligne d'attache
|
||||
Specify an angle for the hatch pattern Spécifier un angle pour le motif de hachures
|
||||
Specify angle end point Spécifier le point final de l'angle
|
||||
Specify angle of dimension line Spécifier l'angle de la ligne de cote
|
||||
Specify angle of dimension text Spécifier l'angle du texte de cote
|
||||
Specify angle start point Spécifier le point de départ de l'angle
|
||||
Specify angle vertex Spécifier le sommet de l'angle
|
||||
Specify angle vertex point Spécifier le point du sommet de l'angle
|
||||
Specify arc length dimension location Spécifier l'emplacement de la cote de longueur d'arc
|
||||
Specify axis endpoint of ellipse Spécifier l'extrémité de l'axe de l'ellipse
|
||||
Specify base diameter Spécifiez le diamètre de la base
|
||||
Specify base point Spécifiez le point de base
|
||||
Specify base point or Spécifiez le point de base ou
|
||||
Specify base point or displacement (drag with the middle mouse button for realtime pan) Spécifiez le point de base ou le déplacement (faites glisser avec le bouton central de la souris pour un panoramique en temps réel)
|
||||
Specify base radius or Spécifiez le rayon de base ou
|
||||
Specify center of donut Spécifiez le centre de l'anneau
|
||||
Specify center of ellipse Spécifiez le centre de l'ellipse
|
||||
Specify center of polygon Spécifiez le centre du polygone
|
||||
Specify center point for circle Spécifiez le point central du cercle
|
||||
Specify center point of arc Spécifiez le point central de l'arc
|
||||
Specify center point of array Spécifiez le point central de la matrice
|
||||
Specify center point of base Spécifiez le point central de la base
|
||||
Specify corner of viewport Spécifiez le coin de la fenêtre
|
||||
Specify corner of window, enter a scale factor (nX or nXP) Spécifiez le coin de la fenêtre, entrez un facteur d'échelle (nX ou nXP)
|
||||
Specify diameter of circle Spécifiez le diamètre du cercle
|
||||
Specify dimension arc line location Spécifiez l'emplacement de l'arc de cote
|
||||
Specify dimension line location Spécifiez l'emplacement de la ligne de cote
|
||||
Specify distance between turns Spécifiez la distance entre les spires
|
||||
Specify distance to other axis Spécifiez la distance jusqu'à l'autre axe
|
||||
Specify end angle Spécifiez l'angle de fin
|
||||
Specify end point of arc Spécifiez le point final de l'arc
|
||||
Specify end point of arc (hold Ctrl to switch direction) Spécifiez le point final de l'arc (maintenez Ctrl enfoncé pour changer de direction)
|
||||
Specify ending width Spécifiez la largeur de fin
|
||||
Specify ending width for next segment Spécifiez la largeur de fin du segment suivant
|
||||
Specify endpoint of arc Spécifiez le point final de l'arc
|
||||
Specify endpoint of axis Spécifiez le point final de l'axe
|
||||
Specify endpoint of line Spécifiez le point final de la ligne
|
||||
Specify feature location Spécifiez l'emplacement de l'élément
|
||||
Specify fillet radius Spécifiez le rayon de congé
|
||||
Specify fillet radius for rectangles Spécifiez le rayon de congé des rectangles
|
||||
Specify first angle endpoint Spécifiez le premier point final de l'angle
|
||||
Specify first chamfer distance Spécifiez la première distance de chanfrein
|
||||
Specify first chamfer distance for rectangles Spécifiez la première distance de chanfrein des rectangles
|
||||
Specify first constraint point Spécifiez le premier point de contrainte
|
||||
Specify first corner Spécifiez le premier coin
|
||||
Specify first corner point Spécifiez le premier point d'angle
|
||||
Specify first destination point Spécifiez le premier point de destination
|
||||
Specify first end point of circle's diameter Spécifiez le premier point final du diamètre du cercle
|
||||
Specify first endpoint of edge Spécifiez le premier point final de l'arête
|
||||
Specify first extension line origin Spécifiez l'origine de la première ligne d'attache
|
||||
Specify first point Spécifiez le premier point
|
||||
Specify first point of mirror line Spécifiez le premier point de la ligne de symétrie
|
||||
Specify first point on circle Spécifiez le premier point sur le cercle
|
||||
Specify first point on circle: _tan to Spécifiez le premier point sur le cercle : _tan à
|
||||
Specify first source point Spécifiez le premier point source
|
||||
Specify height Spécifiez la hauteur
|
||||
Specify helix height or Spécifiez la hauteur de l'hélice ou
|
||||
Specify included angle Spécifiez l'angle inclus
|
||||
Specify insertion base point Spécifiez le point de base d'insertion
|
||||
Specify insertion point Spécifiez le point d'insertion
|
||||
Specify inside diameter of donut Spécifiez le diamètre intérieur de l'anneau
|
||||
Specify leader arrowhead location Spécifiez l'emplacement de la tête de flèche du repère
|
||||
Specify leader endpoint Spécifiez le point final du repère
|
||||
Specify leader landing location Spécifiez l'emplacement de la base du repère
|
||||
Specify length for rectangles Spécifiez la longueur des rectangles
|
||||
Specify length of chord Spécifiez la longueur de la corde
|
||||
Specify length of segment Spécifiez la longueur du segment
|
||||
Specify line width for rectangles Spécifiez la largeur de ligne des rectangles
|
||||
Specify location for new vertex Spécifiez l'emplacement du nouveau sommet
|
||||
Specify minimum length of arc Spécifiez la longueur minimale de l'arc
|
||||
Specify new end point Spécifiez le nouveau point final
|
||||
Specify new location for marked vertex Spécifiez le nouvel emplacement du sommet marqué
|
||||
Specify new width for all segments Spécifiez la nouvelle largeur de tous les segments
|
||||
Specify next point Spécifiez le point suivant
|
||||
Specify offset distance Spécifiez la distance de décalage
|
||||
Specify offset distance or Spécifiez la distance de décalage ou
|
||||
Specify opposite corner Spécifiez le coin opposé
|
||||
Specify other corner point Spécifiez l'autre point d'angle
|
||||
Specify other endpoint of axis Spécifiez l'autre point final de l'axe
|
||||
Specify outside diameter of donut Spécifiez le diamètre extérieur de l'anneau
|
||||
Specify point Spécifiez le point
|
||||
Specify point on object for first tangent of circle Spécifiez le point sur l'objet pour la première tangente du cercle
|
||||
Specify point on object for second tangent of circle Spécifiez le point sur l'objet pour la deuxième tangente du cercle
|
||||
Specify point on side to offset Spécifiez le point du côté à décaler
|
||||
Specify radius of arc Spécifiez le rayon de l'arc
|
||||
Specify radius of circle Spécifiez le rayon du cercle
|
||||
Specify rotation angle Spécifiez l'angle de rotation
|
||||
Specify rotation angle of text Spécifiez l'angle de rotation du texte
|
||||
Specify rotation angle or Spécifiez l'angle de rotation ou
|
||||
Specify scale factor Spécifiez le facteur d'échelle
|
||||
Specify second angle endpoint Spécifiez le deuxième point final de l'angle
|
||||
Specify second break point Spécifiez le deuxième point de rupture
|
||||
Specify second chamfer distance for rectangles Spécifiez la deuxième distance de chanfrein des rectangles
|
||||
Specify second constraint point Spécifiez le deuxième point de contrainte
|
||||
Specify second destination point Spécifiez le deuxième point de destination
|
||||
Specify second end point of circle's diameter Spécifiez le deuxième point final du diamètre du cercle
|
||||
Specify second endpoint of edge Spécifiez le deuxième point final de l'arête
|
||||
Specify second extension line origin Spécifiez l'origine de la deuxième ligne d'attache
|
||||
Specify second point Spécifiez le deuxième point
|
||||
Specify second point of arc Spécifiez le deuxième point de l'arc
|
||||
Specify second point of mirror line Spécifiez le deuxième point de la ligne de symétrie
|
||||
Specify second point on circle Spécifiez le deuxième point sur le cercle
|
||||
Specify second point on circle: _tan to Spécifiez le deuxième point sur le cercle : _tan à
|
||||
Specify second point or <use first point as displacement> Spécifiez le deuxième point ou <utiliser le premier point comme déplacement>
|
||||
Specify second source point Spécifiez le deuxième point source
|
||||
Specify side to offset Spécifiez le côté à décaler
|
||||
Specify start angle Spécifiez l'angle de départ
|
||||
Specify start point Spécifiez le point de départ
|
||||
Specify start point of arc Spécifiez le point de départ de l'arc
|
||||
Specify start point of polyline Spécifiez le point de départ de la polyligne
|
||||
Specify start point of text Spécifiez le point de départ du texte
|
||||
Specify starting width Spécifiez la largeur de départ
|
||||
Specify starting width for next segment Spécifiez la largeur de départ du segment suivant
|
||||
Specify tangent direction for the start point of arc Spécifiez la direction de la tangente au point de départ de l'arc
|
||||
Specify the elevation for rectangles Spécifiez l'élévation des rectangles
|
||||
Specify thickness for rectangles Spécifiez l'épaisseur des rectangles
|
||||
Specify third point on circle Spécifiez le troisième point sur le cercle
|
||||
Specify third point on circle: _tan to Spécifiez le troisième point sur le cercle : _tan à
|
||||
Specify third source point or <continue> Spécifiez le troisième point source ou <continuer>
|
||||
Specify through point Spécifiez le point de passage
|
||||
Specify top diameter Spécifiez le diamètre supérieur
|
||||
Specify top radius or Spécifiez le rayon supérieur ou
|
||||
Specify total angle Spécifiez l'angle total
|
||||
Specify total length or Spécifiez la longueur totale ou
|
||||
Specify width for rectangles Spécifiez la largeur des rectangles
|
||||
Sphere Sphère
|
||||
Spline Spline
|
||||
Stack (type 1/2 then select) Empilement (tapez 1/2, puis sélectionnez)
|
||||
Standard Standard
|
||||
Start Départ
|
||||
Start Width Largeur de départ
|
||||
Start X X de départ
|
||||
Start Y Y de départ
|
||||
Start angle Angle de départ
|
||||
Start, Center, Angle Départ, Centre, Angle
|
||||
Start, Center, End Départ, Centre, Fin
|
||||
Start, Center, Length Départ, Centre, Longueur
|
||||
Start, End, Angle Départ, Fin, Angle
|
||||
Start, End, Direction Départ, Fin, Direction
|
||||
Start, End, Radius Départ, Fin, Rayon
|
||||
Status État
|
||||
Status Bar Barre d'état
|
||||
Stretch Étirer
|
||||
Style: Standard Style : Standard
|
||||
Subtract Soustraction
|
||||
Sweep Balayage
|
||||
Switch Drawing Changer de dessin
|
||||
Switch Layout Changer de présentation
|
||||
Switch between model space in a viewport and paper space Basculer entre l'espace modèle dans une fenêtre et l'espace papier
|
||||
Symmetric Symétrie
|
||||
Synchronize Attributes Synchroniser les attributs
|
||||
Toggle PICKADD (off: each pick replaces the selection) Basculer PICKADD (désactivé : chaque sélection remplace la sélection existante)
|
||||
Toggle PICKADD (on: picks add to the selection) Basculer PICKADD (activé : les sélections s'ajoutent à la sélection existante)
|
||||
TOP HAUT
|
||||
Table Tableau
|
||||
Table Style... Style de tableau...
|
||||
Table style Style de tableau
|
||||
Table... Tableau...
|
||||
Tan, Tan, Radius Tangente, Tangente, Rayon
|
||||
Tan, Tan, Tan Tangente, Tangente, Tangente
|
||||
Tangent Tangente
|
||||
Text Texte
|
||||
Text Editor Éditeur de texte
|
||||
Text Height Hauteur du texte
|
||||
Text Style... Style de texte...
|
||||
Text height Hauteur du texte
|
||||
Text override Texte de remplacement
|
||||
Text style Style de texte
|
||||
Thaw All Layers Dégeler tous les calques
|
||||
There is nothing here yet. Il n'y a encore rien ici.
|
||||
Thickness Épaisseur
|
||||
Time Heure
|
||||
To get started, create or open a drawing. Pour commencer, créez ou ouvrez un dessin.
|
||||
Tool Bar Barre d'outils
|
||||
Tool Sets Ensembles d'outils
|
||||
Tools Outils
|
||||
Torus Tore
|
||||
Trace Trace
|
||||
Transparency Transparence
|
||||
Trim Ajuster
|
||||
True Color Couleur vraie
|
||||
Turn All Layers On Activer tous les calques
|
||||
Turn Off Layer Désactiver le calque
|
||||
Turn Quick Properties off (QPMODE) Désactiver les propriétés rapides (QPMODE)
|
||||
Type Type
|
||||
Type a command Tapez une commande
|
||||
Type text; Enter starts a new paragraph Saisissez le texte ; Entrée crée un nouveau paragraphe
|
||||
UCS Icon Icône du SCU
|
||||
Undo Annuler
|
||||
Undo (⌘Z) Annuler (⌘Z)
|
||||
Ungroup Dissocier
|
||||
Union Union
|
||||
Unisolate Layer Annuler l'isolement des calques
|
||||
Units... Unités...
|
||||
Unlock Layer Déverrouiller le calque
|
||||
Unmerge Cells Annuler la fusion des cellules
|
||||
Unsaved Layer State État de calque non enregistré
|
||||
Up Haut
|
||||
Update Mettre à jour
|
||||
Use System Setting Utiliser le paramètre système
|
||||
User Utilisateur
|
||||
User Parameters Paramètres utilisateur
|
||||
Username Nom d'utilisateur
|
||||
VP Color Couleur de la fenêtre
|
||||
VP Freeze Gel dans la fenêtre
|
||||
Value Valeur
|
||||
Value: Valeur :
|
||||
Version Version
|
||||
Version {version} Version {version}
|
||||
Vertical Vertical
|
||||
VerticalDistance VerticalDistance
|
||||
View Vue
|
||||
ViewCube ViewCube
|
||||
Viewport Fenêtre
|
||||
Viewport Layer Freeze Gel du calque dans la fenêtre
|
||||
Viewport Properties Propriétés de la fenêtre
|
||||
Viewport {id} Fenêtre {id}
|
||||
Viewports Fenêtres
|
||||
W O
|
||||
WCS ⌄ SCG ⌄
|
||||
White Blanc
|
||||
Width factor Facteur de largeur
|
||||
Window Fenêtre
|
||||
Wipeout Masquage
|
||||
Workspace Switching Changement d'espace de travail
|
||||
Write Block Enregistrer le bloc dans un fichier
|
||||
Xline Ligne de construction
|
||||
Yellow Jaune
|
||||
Yes Oui
|
||||
Zoom Zoom
|
||||
Zoom All Zoom tout
|
||||
Zoom Extents Zoom étendu
|
||||
Zoom In Zoom avant
|
||||
Zoom Object Zoom objet
|
||||
Zoom Out Zoom arrière
|
||||
Zoom Previous Zoom précédent
|
||||
Zoom Window Zoom fenêtre
|
||||
error erreur
|
||||
{contributors} · {commits} {contributors} · {commits}
|
||||
{kind} (constraint {id}) {kind} (contrainte {id})
|
||||
{kind} constraint {name} = {expression} (double-click to edit) Contrainte {kind} {name} = {expression} (double-cliquez pour modifier)
|
||||
{name}: {distance} < {angle}° {name} : {distance} < {angle}°
|
||||
ƒx New User Parameter ƒx Nouveau paramètre utilisateur
|
||||
ΔLOC ΔLOC
|
||||
−Bin −Bin
|
||||
−LOC −LOC
|
||||
⌘↩ to finish ⌘↩ pour terminer
|
||||
@plural {n} commit {n} révision|{n} révisions
|
||||
@plural {n} contributor {n} contributeur|{n} contributeurs
|
||||
Drawing as bytes Dessin sous forme d'octets
|
||||
Services Services
|
||||
Hide CADCraft Masquer CADCraft
|
||||
Hide Others Masquer les autres
|
||||
Accessibility Accessibilité
|
||||
Show System Cursor Afficher le curseur système
|
||||
Import... Importer...
|
||||
Export... Exporter...
|
||||
Batch Publish... Publication par lots...
|
||||
Hatch Pattern Motif de hachure
|
||||
Search for pattern Rechercher un motif
|
||||
|
@@ -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
|
||||
|
@@ -0,0 +1,1000 @@
|
||||
# CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
|
||||
# License: MIT OR Apache-2.0 (the repository LICENSE-MIT and LICENSE-APACHE).
|
||||
# Written from CADCraft's English strings; no proprietary translations were copied.
|
||||
# UTF-8: context<TAB>English source<TAB>Russian translation.
|
||||
# Empty context = ordinary label; @plural = one|few|many forms with {n}.
|
||||
# Command IDs, typed keywords, technology/product names and user data stay unchanged.
|
||||
Cancel Отмена
|
||||
OK ОК
|
||||
or [ или [
|
||||
% of all commits % всех коммитов
|
||||
* Wildcard Match * По шаблону
|
||||
*VARIES* *РАЗНЫЕ*
|
||||
+ | Top | 2D Wireframe + | Сверху | 2D-каркас
|
||||
+Bin +двоич.
|
||||
+LOC +строк
|
||||
1 Viewport 1 видовой экран
|
||||
2 Points 2 точки
|
||||
2 Viewports 2 видовых экрана
|
||||
2D Solid 2D-заливка
|
||||
3 Points 3 точки
|
||||
3 Viewports 3 видовых экрана
|
||||
3D Face 3D-грань
|
||||
3D Operations 3D-операции
|
||||
3D Polyline 3D-полилиния
|
||||
4 Viewports 4 видовых экрана
|
||||
< Less than < Меньше чем
|
||||
<> Not Equal <> Не равно
|
||||
= Equals = Равно
|
||||
> Greater than > Больше чем
|
||||
About О программе
|
||||
About CADCraft О CADCraft
|
||||
Absolute Абсолютные
|
||||
Add Leader Добавить выноску
|
||||
Align Выровнять
|
||||
Align Text Выровнять текст
|
||||
Aligned Параллельный
|
||||
All Все
|
||||
All Used Layers Все используемые слои
|
||||
All files Все файлы
|
||||
Angle Угол
|
||||
Angular Угловой
|
||||
Annotation Visibility Видимость аннотаций
|
||||
Annotation scale Масштаб аннотаций
|
||||
Apparent Intersection Мнимое пересечение
|
||||
Append to current selection set Добавить к текущему набору выделения
|
||||
Apply to: Применить к:
|
||||
Apply true color Применить цвет RGB
|
||||
Arc Дуга
|
||||
Arc Length Длина дуги
|
||||
Area Площадь
|
||||
Arrange Panel Расположить панель
|
||||
Array Массив
|
||||
Attach Присоединить
|
||||
Attribute Атрибут
|
||||
Attribute Definition Определение атрибута
|
||||
Attribute Display Отображение атрибутов
|
||||
Audit Проверка чертежа
|
||||
Auto (system language) Авто (язык системы)
|
||||
Auto Constrain Автоограничение
|
||||
AutoConstrain Автоограничение
|
||||
Axis, End Ось, конец
|
||||
Base База
|
||||
Baseline Базовый
|
||||
Batch Publish Пакетная публикация
|
||||
Binary assets added Добавленные двоичные ресурсы
|
||||
Binary assets removed Удаленные двоичные ресурсы
|
||||
Blend Сгладить
|
||||
Block Блок
|
||||
Block Attribute Manager... Диспетчер атрибутов блоков...
|
||||
Block Editor Редактор блоков
|
||||
Block Reference Вхождение блока
|
||||
Block... Блок...
|
||||
Blocks Блоки
|
||||
Blue Синий
|
||||
Bold Жирный
|
||||
Boolean Булевы операции
|
||||
Boundary Граница
|
||||
Boundary... Граница...
|
||||
Box Параллелепипед
|
||||
Break Разорвать
|
||||
Break At Point Разорвать в точке
|
||||
Bring to Front На передний план
|
||||
ByBlock Поблоку
|
||||
ByColor ПоЦвету
|
||||
ByLayer Послою
|
||||
CADCraft Help Справка CADCraft
|
||||
Cancel Отмена
|
||||
Cartesian (x, y) Декартовы (x, y)
|
||||
Center Центр
|
||||
Center Line Осевая линия
|
||||
Center Mark Знак центра
|
||||
Center X Центр X
|
||||
Center Y Центр Y
|
||||
Center Z Центр Z
|
||||
Center, Diameter Центр, диаметр
|
||||
Center, Radius Центр, радиус
|
||||
Center, Start, Angle Центр, начало, угол
|
||||
Center, Start, End Центр, начало, конец
|
||||
Center, Start, Length Центр, начало, длина
|
||||
Chamfer Фаска
|
||||
Change Space Изменить пространство
|
||||
Change to Current Layer Перенести на текущий слой
|
||||
Circle Круг
|
||||
Circumference Длина окружности
|
||||
Clear Очистить
|
||||
Clipboard Буфер обмена
|
||||
Close Закрыть
|
||||
Close All Закрыть все
|
||||
Closed Замкнутый
|
||||
Coincident Совпадение
|
||||
Collapse Tool Sets Свернуть наборы инструментов
|
||||
Collinear Коллинеарность
|
||||
Color Цвет
|
||||
Color 8 Цвет 8
|
||||
Color 9 Цвет 9
|
||||
Color Index Индекс цвета
|
||||
Color... Цвет...
|
||||
Command History Журнал команд
|
||||
Command Line Командная строка
|
||||
Command Line History Lines Строки журнала командной строки
|
||||
Command Reference Справочник команд
|
||||
Commits Коммиты
|
||||
Company Организация
|
||||
Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Автоматизированное проектирование и черчение: CAD-приложение с открытым кодом, независимо разработанное на Rust.
|
||||
Concentric Концентричность
|
||||
Cone Конус
|
||||
Constraint Bars Панели ограничений
|
||||
Constraint Settings Параметры ограничений
|
||||
Construction Line Вспомогательная прямая
|
||||
Contents Содержание
|
||||
Continue Продолжить
|
||||
Contributors Участники
|
||||
Control Vertices Управляющие вершины
|
||||
Copy Копировать
|
||||
Copy Layout Копировать лист
|
||||
Copy Nested Objects Копировать вложенные объекты
|
||||
Copy Selection Копировать выделение
|
||||
Copy with Base Point Копировать с базовой точкой
|
||||
Count Количество
|
||||
Create Block Создать блок
|
||||
Creates a user parameter Создает пользовательский параметр
|
||||
Ctrl+Enter to finish Ctrl+Enter для завершения
|
||||
Current layer Текущий слой
|
||||
Current selection Текущее выделение
|
||||
Current: {color} Текущий: {color}
|
||||
Cut Вырезать
|
||||
Cyan Голубой
|
||||
Cylinder Цилиндр
|
||||
DWG DWG
|
||||
DXF DXF
|
||||
Dark Темная
|
||||
Default По умолчанию
|
||||
Default Save Format Формат сохранения по умолчанию
|
||||
Define Attributes Определить атрибуты
|
||||
Define Attributes... Определить атрибуты...
|
||||
Defined width Заданная ширина
|
||||
Degree Градус
|
||||
Delete Удалить
|
||||
Delete Column Удалить столбец
|
||||
Delete Constraints Удалить ограничения
|
||||
Delete Dimension Style Удалить размерный стиль
|
||||
Delete Duplicate Objects Удалить дубликаты объектов
|
||||
Delete Layer Удалить слой
|
||||
Delete Layer State Удалить состояние слоев
|
||||
Delete Layout Удалить лист
|
||||
Delete Row Удалить строку
|
||||
Delete Text Style Удалить текстовый стиль
|
||||
Delta X Смещение X
|
||||
Delta Y Смещение Y
|
||||
Description Описание
|
||||
Deselect All Снять выделение со всего
|
||||
Diameter Диаметр
|
||||
Dim style Размерный стиль
|
||||
Dimension Размеры
|
||||
Dimension Space Расстояние между размерами
|
||||
Dimension Style Override Переопределение размерного стиля
|
||||
Dimension Style... Размерный стиль...
|
||||
Dimension Text Edit Редактировать размерный текст
|
||||
Dimension style Размерный стиль
|
||||
Dimensional Constraint Размерное ограничение
|
||||
Dimensional Constraint Parameters Параметры размерных ограничений
|
||||
Dimensional Constraints Размерные ограничения
|
||||
Disassociate Dimensions Разъединить размеры
|
||||
Display name Отображаемое имя
|
||||
Distance Расстояние
|
||||
Divide Поделить
|
||||
Do not plot (click: plot) Не выводить на печать (щелкните для печати)
|
||||
Donut Кольцо
|
||||
Double-click to edit the description Дважды щелкните, чтобы изменить описание
|
||||
Down Вниз
|
||||
Drafting Черчение
|
||||
Drafting Settings Режимы черчения
|
||||
Drafting Settings... Режимы черчения...
|
||||
Draw Рисование
|
||||
Draw Order Порядок отрисовки
|
||||
Drawing (DWG) Чертеж (DWG)
|
||||
Drawing (DXF) Чертеж (DXF)
|
||||
Drawing Limits Лимиты чертежа
|
||||
Drawing Utilities Утилиты чертежа
|
||||
Drawings (DWG, DXF) Чертежи (DWG, DXF)
|
||||
Dynamic Dimensions Динамические размеры
|
||||
Dynamic Input Динамический ввод
|
||||
Dynamic Input (F12) Динамический ввод (F12)
|
||||
E В
|
||||
Edit Редактирование
|
||||
Edit Text... Редактировать текст...
|
||||
Elevation Уровень
|
||||
Ellipse Эллипс
|
||||
Elliptical Arc Эллиптическая дуга
|
||||
Enable Dynamic Input (F12) Включить динамический ввод (F12)
|
||||
End Width Конечная ширина
|
||||
End X Конец X
|
||||
End Y Конец Y
|
||||
End angle Конечный угол
|
||||
Endpoint Конечная точка
|
||||
Enter Top, Zero or Bottom. Введите Top, Zero или Bottom.
|
||||
Enter a fit data option Введите параметр аппроксимации
|
||||
Enter a pattern name Введите имя шаблона
|
||||
Enter a vertex editing option Введите параметр редактирования вершины
|
||||
Enter an option Введите параметр
|
||||
Enter angle of xline Введите угол вспомогательной прямой
|
||||
Enter angle of xline (0) or Введите угол вспомогательной прямой (0) или
|
||||
Enter area of rectangle in current units Введите площадь прямоугольника в текущих единицах
|
||||
Enter attribute prompt Введите подсказку атрибута
|
||||
Enter attribute tag name Введите имя тега атрибута
|
||||
Enter attribute value Введите значение атрибута
|
||||
Enter block name Введите имя блока
|
||||
Enter constraint type Введите тип ограничения
|
||||
Enter continuity Введите тип непрерывности
|
||||
Enter default attribute value Введите значение атрибута по умолчанию
|
||||
Enter degree of spline Введите степень сплайна
|
||||
Enter delta angle Введите изменение угла
|
||||
Enter delta length or Введите изменение длины или
|
||||
Enter dimension text Введите размерный текст
|
||||
Enter dimension text (<> = measurement) Введите размерный текст (<> = измеренное значение)
|
||||
Enter dimension value or [name=expression] Введите размерное значение или [имя=выражение]
|
||||
Enter dimension variable name to override Введите имя размерной переменной для переопределения
|
||||
Enter justification type Введите тип выравнивания
|
||||
Enter leader text Введите текст выноски
|
||||
Enter mline scale Введите масштаб мультилинии
|
||||
Enter mode Введите режим
|
||||
Enter name of block to insert Введите имя блока для вставки
|
||||
Enter new text Введите новый текст
|
||||
Enter next point Введите следующую точку
|
||||
Enter number of columns Введите количество столбцов
|
||||
Enter number of data rows Введите количество строк данных
|
||||
Enter number of items Введите количество элементов
|
||||
Enter number of items along path Введите количество элементов вдоль траектории
|
||||
Enter number of sides Введите количество сторон
|
||||
Enter number of turns Введите количество витков
|
||||
Enter percentage length Введите длину в процентах
|
||||
Enter rectangle length Введите длину прямоугольника
|
||||
Enter rectangle width Введите ширину прямоугольника
|
||||
Enter scale factor Введите масштабный коэффициент
|
||||
Enter text Введите текст
|
||||
Enter text (use \P for new paragraphs) Введите текст (\P начинает новый абзац)
|
||||
Enter the number of segments Введите количество сегментов
|
||||
Enter twist direction of helix Введите направление закручивания спирали
|
||||
Entire drawing Весь чертеж
|
||||
Equal Равенство
|
||||
Erase Стереть
|
||||
Exclude from new selection set Исключить из нового набора выделения
|
||||
Explode Расчленить
|
||||
Export Экспортировать
|
||||
Export to PDF... Экспортировать в PDF...
|
||||
Expression Выражение
|
||||
Extend Удлинить
|
||||
Extension Продолжение
|
||||
Extents Границы
|
||||
Extrude Выдавить
|
||||
File Файл
|
||||
File Tab Вкладка файла
|
||||
Fillet Сопряжение
|
||||
Filter Фильтр
|
||||
Filters Фильтры
|
||||
Find and Replace Найти и заменить
|
||||
Find... Найти...
|
||||
First Первый
|
||||
First commit Первый коммит
|
||||
Fit Points Точки сглаживания
|
||||
Fix Фиксация
|
||||
Flatten Objects Спроецировать объекты на плоскость
|
||||
Format Формат
|
||||
Freehand От руки
|
||||
Freeze Заморозить
|
||||
Freeze Layer Заморозить слой
|
||||
Freeze in new viewports Заморозить в новых видовых экранах
|
||||
Freeze/Thaw Заморозить/разморозить
|
||||
Freeze/Thaw in all viewports Заморозить/разморозить во всех видовых экранах
|
||||
Freeze/Thaw in the current viewport Заморозить/разморозить в текущем видовом экране
|
||||
From От точки
|
||||
General Общие
|
||||
Geometric Center Геометрический центр
|
||||
Geometric Constraint Геометрическое ограничение
|
||||
Geometric Constraints Геометрические ограничения
|
||||
Geometry Геометрия
|
||||
Get Variable Получить переменную
|
||||
Get View Получить вид
|
||||
Global Глобально
|
||||
Global width Общая ширина
|
||||
Grab bag Список
|
||||
Gradient Градиент
|
||||
Gradient... Градиент...
|
||||
Green Зеленый
|
||||
Grid (F7) Сетка (F7)
|
||||
Grid Display Отображение сетки
|
||||
Grid On (F7) Включить сетку (F7)
|
||||
Grid spacing Шаг сетки
|
||||
Grip Mirror Зеркальное отражение ручками
|
||||
Grip Rotate Поворот ручками
|
||||
Grip Scale Масштабирование ручками
|
||||
Grip Stretch Растягивание ручками
|
||||
Group Сгруппировать
|
||||
Group Edit Редактировать группу
|
||||
Hatch Штриховка
|
||||
Hatch Edit Редактировать штриховку
|
||||
Hatch... Штриховка...
|
||||
Height Высота
|
||||
Height: {height} Высота: {height}
|
||||
Helix Спираль
|
||||
Help Справка
|
||||
Hide All Скрыть все
|
||||
Hide All Constraint Bars Скрыть все панели ограничений
|
||||
Hide All Dynamic Dimensions Скрыть все динамические размеры
|
||||
Hide Layer List Скрыть список слоев
|
||||
Hide Palettes Скрыть палитры
|
||||
Home Исходный вид
|
||||
Horizontal Горизонтальность
|
||||
HorizontalDistance ГоризонтальноеРасстояние
|
||||
How to apply: Способ применения:
|
||||
ID Point Координаты точки
|
||||
Import Импортировать
|
||||
In Приблизить
|
||||
In-window Menu Bar Строка меню в окне
|
||||
Include in new selection set Включить в новый набор выделения
|
||||
Increment angle Шаг угла
|
||||
Index Color Индексированный цвет
|
||||
Index color: {index} Индексированный цвет: {index}
|
||||
Inquiry Измерение
|
||||
Insert Вставка
|
||||
Insert Block Вставить блок
|
||||
Insert Column Вставить столбец
|
||||
Insert Row Вставить строку
|
||||
Insert this block Вставить этот блок
|
||||
Insertion Точка вставки
|
||||
Insertion X Точка вставки X
|
||||
Insertion Y Точка вставки Y
|
||||
Insertion Z Точка вставки Z
|
||||
Inspect Constraints Просмотреть ограничения
|
||||
Inspect Drawing Просмотреть чертеж
|
||||
Interface Language Язык интерфейса
|
||||
Interface Theme Тема интерфейса
|
||||
Intersect Пересечение
|
||||
Intersection Пересечение
|
||||
Isolate Изолировать
|
||||
Isolate Layer Изолировать слой
|
||||
Isometric Drafting Изометрическое черчение
|
||||
Join Объединить
|
||||
Join the ArtCraft Discord… Присоединяйтесь к Discord ArtCraft…
|
||||
Join us on Discord Присоединяйтесь к нам в Discord
|
||||
Language Язык
|
||||
Last Последний
|
||||
Last commit Последний коммит
|
||||
Layer Слой
|
||||
Layer Delete Удалить слой с объектами
|
||||
Layer Match Согласовать слой
|
||||
Layer Merge Объединить слои
|
||||
Layer Off Выключить слой
|
||||
Layer Properties Свойства слоя
|
||||
Layer Properties Manager Диспетчер свойств слоев
|
||||
Layer Properties Manager — current viewport Диспетчер свойств слоев — текущий видовой экран
|
||||
Layer State Состояние слоев
|
||||
Layer States Manager... Диспетчер состояний слоев...
|
||||
Layer Tools Инструменты слоев
|
||||
Layer contains objects (double-click to make current) Слой содержит объекты (дважды щелкните, чтобы сделать текущим)
|
||||
Layer is empty (double-click to make current) Слой пуст (дважды щелкните, чтобы сделать текущим)
|
||||
Layers Слои
|
||||
Layers Palette Палитра слоев
|
||||
Layers displayed: {shown} of {total}. Current layer: {layer} Показано слоев: {shown} из {total}. Текущий слой: {layer}
|
||||
Layout Лист
|
||||
Layout list Список листов
|
||||
Leader Выноска
|
||||
Left Слева
|
||||
Length Длина
|
||||
Length X Длина X
|
||||
Length Y Длина Y
|
||||
Lengthen Изменить длину
|
||||
Light Светлая
|
||||
Line Отрезок
|
||||
Line delta Изменение количества строк
|
||||
Linear Линейный
|
||||
Lines +/− Строки +/−
|
||||
Lines added Добавленные строки
|
||||
Lines deleted Удаленные строки
|
||||
Linetype Тип линии
|
||||
Linetype Scale Масштаб типа линии
|
||||
Linetype scale Масштаб типа линии
|
||||
Ltscale Масштаб типа линии
|
||||
Linetype... Тип линии...
|
||||
Lineweight Вес линии
|
||||
Lineweight... Вес линии...
|
||||
List Список
|
||||
List Blocks Список блоков
|
||||
List Dimension Styles Список размерных стилей
|
||||
List Groups Список групп
|
||||
List Layer States Список состояний слоев
|
||||
List Layouts Список листов
|
||||
List System Variables Список системных переменных
|
||||
List Text Styles Список текстовых стилей
|
||||
Load all Загрузить все
|
||||
Load... Загрузить...
|
||||
Loaded linetypes Загруженные типы линий
|
||||
Lock Заблокировать
|
||||
Lock Layer Заблокировать слой
|
||||
Lock/Unlock Заблокировать/разблокировать
|
||||
Loft Тело по сечениям
|
||||
MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Не аффилировано с Autodesk, Inc.
|
||||
MODEL МОДЕЛЬ
|
||||
MText Многострочный текст
|
||||
Magenta Пурпурный
|
||||
Major line every Основная линия каждые
|
||||
Make Current Сделать текущим
|
||||
Make Object's Layer Current Сделать слой объекта текущим
|
||||
Make... Создать...
|
||||
Match Layer Согласовать слой
|
||||
Match Properties Копирование свойств
|
||||
Measure Разметить
|
||||
Measure Distance Измерить расстояние
|
||||
Measure Geometry Измерить геометрию
|
||||
Merge Cells Объединить ячейки
|
||||
Merged PRs Принятые PR
|
||||
Mid Between 2 Points Середина между двумя точками
|
||||
Midpoint Середина
|
||||
Mirror Зеркальное отражение
|
||||
Model Модель
|
||||
Model Space (in viewport) Пространство модели (в видовом экране)
|
||||
Modeling Моделирование
|
||||
Models Модели
|
||||
Modify Модификация
|
||||
Move Переместить
|
||||
Multileader Мультивыноска
|
||||
Multileader Style... Стиль мультивыносок...
|
||||
Multileader style Стиль мультивыносок
|
||||
Multiline Мультилиния
|
||||
Multiline Text Многострочный текст
|
||||
Multiline Text... Многострочный текст...
|
||||
Multiple Разные типы
|
||||
Multiple Point Несколько точек
|
||||
My Мои
|
||||
My properties: not available yet Мои свойства: пока недоступно
|
||||
N С
|
||||
Name Название
|
||||
Name (A–Z) Название (А–Я)
|
||||
Nearest Ближайшая точка
|
||||
New Создать
|
||||
New (metric) Создать (метрический)
|
||||
New Drawing Новый чертеж
|
||||
New Drawing (⌘N) Новый чертеж (⌘N)
|
||||
New Drawing... Новый чертеж...
|
||||
New Layer Новый слой
|
||||
New Layout Новый лист
|
||||
New VP Freeze Заморозить в новых ВЭ
|
||||
New Viewports... Новые видовые экраны...
|
||||
New layout Новый лист
|
||||
New state name Название нового состояния
|
||||
No Нет
|
||||
No blocks defined in this drawing. В этом чертеже нет определенных блоков.
|
||||
No contributor data was built into this copy. В эту сборку не включены данные об участниках.
|
||||
No model credits were built into this copy. В эту сборку не включены данные о моделях.
|
||||
No saved layer states Нет сохраненных состояний слоев
|
||||
No selection Ничего не выделено
|
||||
Node Узел
|
||||
None Нет
|
||||
Not available Недоступно
|
||||
Not available yet Пока недоступно
|
||||
OK ОК
|
||||
Object Объект
|
||||
Object Snap Объектная привязка
|
||||
Object Snap (F3) Объектная привязка (F3)
|
||||
Object Snap On (F3) Включить объектную привязку (F3)
|
||||
Object Snap Tracking Отслеживание объектной привязки
|
||||
Object Snap Tracking (F11) Отслеживание объектной привязки (F11)
|
||||
Object type: Тип объекта:
|
||||
Off Выключено
|
||||
Offset Смещение
|
||||
On Включено
|
||||
On/Off Включить/выключить
|
||||
One of the Crafting Apps by the ArtCraft team and community. Одна из программ Crafting Apps от команды и сообщества ArtCraft.
|
||||
Open (⌘O) Открыть (⌘O)
|
||||
Open Sample Drawing Открыть образец чертежа
|
||||
Open sample drawing Открыть образец чертежа
|
||||
Open... Открыть...
|
||||
Operator: Оператор:
|
||||
Orbit Орбита
|
||||
Ordinate Ординатный
|
||||
Ortho (F8) Орто (F8)
|
||||
Ortho Mode Режим Орто
|
||||
Ortho Mode (F8) Режим Орто (F8)
|
||||
Osnap Settings... Параметры объектной привязки...
|
||||
Out Отдалить
|
||||
Override Переопределить
|
||||
PAPER ЛИСТ
|
||||
Percent Процент
|
||||
Polar (distance < angle) Полярные (расстояние < угол)
|
||||
PRs PR
|
||||
Page Setup Manager Диспетчер параметров листов
|
||||
Page Setup Manager... Диспетчер параметров листов...
|
||||
Pan Панорамирование
|
||||
Pan Down Панорамировать вниз
|
||||
Pan Left Панорамировать влево
|
||||
Pan Right Панорамировать вправо
|
||||
Pan Up Панорамировать вверх
|
||||
Paper Space Пространство листа
|
||||
Parallel Параллельность
|
||||
Parameters Manager Диспетчер параметров
|
||||
Parametric Параметризация
|
||||
Paste Вставить
|
||||
Paste as Block Вставить как блок
|
||||
Paste to Original Coordinates Вставить в исходные координаты
|
||||
Path Array Массив по траектории
|
||||
Pattern Шаблон
|
||||
Perpendicular Перпендикулярность
|
||||
Plot Печать чертежа
|
||||
Plot (click: do not plot) Печатать (щелкните, чтобы отключить печать)
|
||||
Plot Preview Предварительный просмотр печати
|
||||
Plot style Стиль печати
|
||||
Plot style attached to Стиль печати применен к
|
||||
Plot style table Таблица стилей печати
|
||||
Plot Style... Стиль печати...
|
||||
Plot Style Manager Диспетчер стилей печати
|
||||
Plot Style Manager... Диспетчер стилей печати...
|
||||
Plot Styles... Стили печати...
|
||||
New CTB Новая CTB
|
||||
New STB Новая STB
|
||||
New style Новый стиль
|
||||
Reset Panel Layout Сбросить расположение панелей
|
||||
Use for this layout Использовать для этого листа
|
||||
Display plot styles Показывать стили печати
|
||||
No plot style tables. Нет таблиц стилей печати.
|
||||
A built-in table: editing it keeps an edited copy in the drawing. Встроенная таблица: изменения сохраняются как ее копия в чертеже.
|
||||
Keep an editable copy of this table in the drawing Сохранить в чертеже копию этой таблицы для редактирования
|
||||
Layout {name}: {table} Лист {name}: {table}
|
||||
Grayscale Оттенки серого
|
||||
Screening Тонирование
|
||||
Use object linetype Тип линии объекта
|
||||
Use object lineweight Вес линии объекта
|
||||
Remove Удалить
|
||||
Add Добавить
|
||||
Open a drawing first. Сначала откройте чертеж.
|
||||
Plot table type Тип таблицы печати
|
||||
Color-dependent Зависимая от цвета
|
||||
Named Именованная
|
||||
Plus/minus Плюс/минус
|
||||
Point Точка
|
||||
Point Filters Фильтры точек
|
||||
Point Style... Стиль точек...
|
||||
Polar Array Полярный массив
|
||||
Polar Tracking Полярное отслеживание
|
||||
Polar Tracking (F10) Полярное отслеживание (F10)
|
||||
Polar Tracking On (F10) Включить полярное отслеживание (F10)
|
||||
Polar: {distance} < {angle}° Полярное: {distance} < {angle}°
|
||||
Polygon Многоугольник
|
||||
Polygonal Многоугольный
|
||||
Polyline Полилиния
|
||||
Position X Позиция X
|
||||
Position Y Позиция Y
|
||||
Position Z Позиция Z
|
||||
Previous Предыдущий
|
||||
Previous Layer Предыдущий слой
|
||||
Primitives Примитивы
|
||||
Print Печать
|
||||
Print... Печать...
|
||||
Properties Свойства
|
||||
Properties Inspector Инспектор свойств
|
||||
Properties: Свойства:
|
||||
Proxy Прокси-объект
|
||||
Purge Очистить неиспользуемое
|
||||
Pyramid Пирамида
|
||||
Quadrant Квадрант
|
||||
Query Entities Запрос объектов
|
||||
Quick Быстрый
|
||||
Quick Dimension Быстрый размер
|
||||
Quick Properties Быстрые свойства
|
||||
Quick Select Быстрый выбор
|
||||
Quick Select Properties Свойства быстрого выбора
|
||||
Quick Select... Быстрый выбор...
|
||||
QuickCalc Быстрокалькулятор
|
||||
Quit CADCraft Выйти из CADCraft
|
||||
Radius Радиус
|
||||
Raster Image Растровое изображение
|
||||
Ray Луч
|
||||
Read-only Только для чтения
|
||||
Real name Настоящее имя
|
||||
Realtime В реальном времени
|
||||
Reassociate Dimensions Восстановить ассоциативность размеров
|
||||
Recent Недавние
|
||||
Recover... Восстановить...
|
||||
Rectangle Прямоугольник
|
||||
Rectangular Прямоугольный
|
||||
Rectangular Array Прямоугольный массив
|
||||
Red Красный
|
||||
Redo Повторить
|
||||
Redo (⇧⌘Z) Повторить (⇧⌘Z)
|
||||
Redraw Перерисовать
|
||||
Regen Регенерация
|
||||
Regen All Регенерировать все
|
||||
Region Область
|
||||
Region/Mass Properties Свойства области/массы
|
||||
Relative Относительные
|
||||
Remove Leader Удалить выноску
|
||||
Remove the viewport override Удалить переопределение видового экрана
|
||||
Rename Dimension Style Переименовать размерный стиль
|
||||
Rename Layer Переименовать слой
|
||||
Rename Layer State Переименовать состояние слоев
|
||||
Rename Layout Переименовать лист
|
||||
Rename Text Style Переименовать текстовый стиль
|
||||
Rename... Переименовать...
|
||||
Repeat {command} Повторить {command}
|
||||
Requires a point (x,y, @dx,dy or @d<a). Требуется точка (x,y, @dx,dy или @d<a).
|
||||
Reset Palettes Сбросить палитры
|
||||
Restore Восстановить
|
||||
Restore Layer State Восстановить состояние слоев
|
||||
Reverse Изменить направление
|
||||
Reverse the order Изменить порядок на обратный
|
||||
Revision Cloud Облако
|
||||
Revolve Тело вращения
|
||||
Right Справа
|
||||
Rotate Повернуть
|
||||
Rotation Угол поворота
|
||||
S Ю
|
||||
Save Сохранить
|
||||
Save (⌘S) Сохранить (⌘S)
|
||||
Save As Сохранить как
|
||||
Save As... Сохранить как...
|
||||
Save Layer State Сохранить состояние слоев
|
||||
Save State Сохранить состояние
|
||||
Scale Масштаб
|
||||
Search for layer Поиск слоя
|
||||
Search for parameter Поиск параметра
|
||||
Second and next points: Вторая и следующие точки:
|
||||
Select Выбрать
|
||||
Select 2D polyline Выберите 2D-полилинию
|
||||
Select All Выделить все
|
||||
Select Objects Выбрать объекты
|
||||
Select a 2D polyline. Выберите 2D-полилинию.
|
||||
Select a block reference. Выберите вхождение блока.
|
||||
Select a circle or arc. Выберите круг или дугу.
|
||||
Select a closed polyline Выберите замкнутую полилинию
|
||||
Select a closed polyline, circle, ellipse or closed spline. Выберите замкнутую полилинию, круг, эллипс или замкнутый сплайн.
|
||||
Select a closed polyline. Выберите замкнутую полилинию.
|
||||
Select a curve as the path. Выберите кривую в качестве траектории.
|
||||
Select a dimension constraint type Выберите тип размерного ограничения
|
||||
Select a line object Выберите отрезок
|
||||
Select a line object. Выберите отрезок.
|
||||
Select a line or an arc. Выберите отрезок или дугу.
|
||||
Select a line or polyline segment Выберите отрезок или сегмент полилинии
|
||||
Select a line, arc or open polyline. Выберите отрезок, дугу или незамкнутую полилинию.
|
||||
Select a line, arc, circle, polyline, ellipse or spline as the path. Выберите отрезок, дугу, круг, полилинию, эллипс или сплайн в качестве траектории.
|
||||
Select a line, circle, arc or polyline segment. Выберите отрезок, круг, дугу или сегмент полилинии.
|
||||
Select a line. Выберите отрезок.
|
||||
Select a polyline. Выберите полилинию.
|
||||
Select a text, mtext, attribute definition or dimension. Выберите текст, многострочный текст, определение атрибута или размер.
|
||||
Select an annotation object Выберите аннотационный объект
|
||||
Select an arc or circle Выберите дугу или круг
|
||||
Select an arc. Выберите дугу.
|
||||
Select an object Выберите объект
|
||||
Select an object to change or Выберите объект для изменения или
|
||||
Select an object to measure or Выберите объект для измерения или
|
||||
Select an object. Выберите объект.
|
||||
Select an open curve. Выберите незамкнутую кривую.
|
||||
Select an open line, arc, polyline, ellipse arc or spline. Выберите незамкнутый отрезок, дугу, полилинию, эллиптическую дугу или сплайн.
|
||||
Select arc or circle Выберите дугу или круг
|
||||
Select arc or polyline arc segment Выберите дугу или дуговой сегмент полилинии
|
||||
Select arc style Выберите стиль дуги
|
||||
Select arc, circle, line Выберите дугу, круг, отрезок
|
||||
Select at least two objects to join. Выберите не менее двух объектов для объединения.
|
||||
Select circle or arc to add center mark Выберите круг или дугу для знака центра
|
||||
Select closed objects (regions, closed polylines, circles). Выберите замкнутые объекты (области, замкнутые полилинии, круги).
|
||||
Select first line Выберите первый отрезок
|
||||
Select first line or arc Выберите первый отрезок или дугу
|
||||
Select first object Выберите первый объект
|
||||
Select first object or Выберите первый объект или
|
||||
Select first point Выберите первую точку
|
||||
Select nested objects to copy or Выберите вложенные объекты для копирования или
|
||||
Select object Выберите объект
|
||||
Select object to dimension Выберите объект для нанесения размера
|
||||
Select object to divide Выберите объект для деления
|
||||
Select object to extend or shift-select to trim Выберите объект для удлинения или выберите с Shift для обрезки
|
||||
Select object to measure Выберите объект для разметки
|
||||
Select object to offset Выберите объект для смещения
|
||||
Select object to trim or shift-select to extend Выберите объект для обрезки или выберите с Shift для удлинения
|
||||
Select objects Выберите объекты
|
||||
Select objects (crossing window first corner) Выберите объекты (первый угол секущей рамки)
|
||||
Select objects to array. Выберите объекты для массива.
|
||||
Select objects to stretch by crossing-window or crossing-polygon... Выберите объекты для растягивания секущей рамкой или секущим многоугольником...
|
||||
Select objects: Выберите объекты:
|
||||
Select one polyline (Multiple is available through the JSON form). Выберите одну полилинию (Multiple доступно через JSON).
|
||||
Select path curve Выберите кривую траектории
|
||||
Select point or object Выберите точку или объект
|
||||
Select polyline or Выберите полилинию или
|
||||
Select second line Выберите второй отрезок
|
||||
Select second line or shift-select to apply corner Выберите второй отрезок или выберите с Shift, чтобы построить угол
|
||||
Select second object Выберите второй объект
|
||||
Select second object or shift-select to apply corner Выберите второй объект или выберите с Shift, чтобы построить угол
|
||||
Select second point Выберите вторую точку
|
||||
Select spline Выберите сплайн
|
||||
Select symmetry line Выберите ось симметрии
|
||||
Select two points Выберите две точки
|
||||
Selection Cycling Циклический выбор
|
||||
Send to Back На задний план
|
||||
Set Current Сделать текущим
|
||||
Set Current Dimension Style Сделать размерный стиль текущим
|
||||
Set Current Text Style Сделать текстовый стиль текущим
|
||||
Set Layer Properties Задать свойства слоя
|
||||
Set Properties Задать свойства
|
||||
Set Table Cell Задать ячейку таблицы
|
||||
Set Variable Задать переменную
|
||||
Set View Задать вид
|
||||
Share Поделиться
|
||||
Show Показать
|
||||
Show All Показать все
|
||||
Show All Constraint Bars Показать все панели ограничений
|
||||
Show All Dynamic Dimensions Показать все динамические размеры
|
||||
Show Layer List Показать список слоев
|
||||
Show/Hide Lineweight Показать/скрыть вес линий
|
||||
Show/Hide Transparency Показать/скрыть прозрачность
|
||||
Single Line Text Однострочный текст
|
||||
Single Point Одна точка
|
||||
Single... Один...
|
||||
Smooth Сглаживание
|
||||
Snap Привязка
|
||||
Snap Mode Шаговая привязка
|
||||
Snap Mode (F9) Шаговая привязка (F9)
|
||||
Snap On (F9) Включить шаговую привязку (F9)
|
||||
Snap X spacing Шаг привязки X
|
||||
Snap and Grid Шаговая привязка и сетка
|
||||
Solid Тело
|
||||
Sort Сортировка
|
||||
Specify a point Укажите точку
|
||||
Specify a scale for the hatch pattern Укажите масштаб шаблона штриховки
|
||||
Specify a second extension line origin Укажите начало второй выносной линии
|
||||
Specify an angle for the hatch pattern Укажите угол шаблона штриховки
|
||||
Specify angle end point Укажите конечную точку угла
|
||||
Specify angle of dimension line Укажите угол размерной линии
|
||||
Specify angle of dimension text Укажите угол размерного текста
|
||||
Specify angle start point Укажите начальную точку угла
|
||||
Specify angle vertex Укажите вершину угла
|
||||
Specify angle vertex point Укажите точку вершины угла
|
||||
Specify arc length dimension location Укажите положение размера длины дуги
|
||||
Specify axis endpoint of ellipse Укажите конечную точку оси эллипса
|
||||
Specify base diameter Укажите базовый диаметр
|
||||
Specify base point Укажите базовую точку
|
||||
Specify base point or Укажите базовую точку или
|
||||
Specify base point or displacement (drag with the middle mouse button for realtime pan) Укажите базовую точку или смещение (перетаскивайте средней кнопкой мыши для панорамирования)
|
||||
Specify base radius or Укажите базовый радиус или
|
||||
Specify center of donut Укажите центр кольца
|
||||
Specify center of ellipse Укажите центр эллипса
|
||||
Specify center of polygon Укажите центр многоугольника
|
||||
Specify center point for circle Укажите центр круга
|
||||
Specify center point of arc Укажите центр дуги
|
||||
Specify center point of array Укажите центр массива
|
||||
Specify center point of base Укажите центр основания
|
||||
Specify corner of viewport Укажите угол видового экрана
|
||||
Specify corner of window, enter a scale factor (nX or nXP) Укажите угол рамки, введите масштабный коэффициент (nX или nXP)
|
||||
Specify diameter of circle Укажите диаметр круга
|
||||
Specify dimension arc line location Укажите положение размерной дуги
|
||||
Specify dimension line location Укажите положение размерной линии
|
||||
Specify distance between turns Укажите расстояние между витками
|
||||
Specify distance to other axis Укажите расстояние до другой оси
|
||||
Specify end angle Укажите конечный угол
|
||||
Specify end point of arc Укажите конечную точку дуги
|
||||
Specify end point of arc (hold Ctrl to switch direction) Укажите конечную точку дуги (удерживайте Ctrl для смены направления)
|
||||
Specify ending width Укажите конечную ширину
|
||||
Specify ending width for next segment Укажите конечную ширину следующего сегмента
|
||||
Specify endpoint of arc Укажите конечную точку дуги
|
||||
Specify endpoint of axis Укажите конечную точку оси
|
||||
Specify endpoint of line Укажите конечную точку отрезка
|
||||
Specify feature location Укажите положение элемента
|
||||
Specify fillet radius Укажите радиус сопряжения
|
||||
Specify fillet radius for rectangles Укажите радиус сопряжения прямоугольников
|
||||
Specify first angle endpoint Укажите первую конечную точку угла
|
||||
Specify first chamfer distance Укажите первое расстояние фаски
|
||||
Specify first chamfer distance for rectangles Укажите первое расстояние фаски прямоугольников
|
||||
Specify first constraint point Укажите первую точку ограничения
|
||||
Specify first corner Укажите первый угол
|
||||
Specify first corner point Укажите первую угловую точку
|
||||
Specify first destination point Укажите первую целевую точку
|
||||
Specify first end point of circle's diameter Укажите первую конечную точку диаметра круга
|
||||
Specify first endpoint of edge Укажите первую конечную точку ребра
|
||||
Specify first extension line origin Укажите начало первой выносной линии
|
||||
Specify first point Укажите первую точку
|
||||
Specify first point of mirror line Укажите первую точку оси зеркального отражения
|
||||
Specify first point on circle Укажите первую точку на круге
|
||||
Specify first point on circle: _tan to Укажите первую точку на круге: _tan к
|
||||
Specify first source point Укажите первую исходную точку
|
||||
Specify height Укажите высоту
|
||||
Specify helix height or Укажите высоту спирали или
|
||||
Specify included angle Укажите угол между сторонами
|
||||
Specify insertion base point Укажите базовую точку вставки
|
||||
Specify insertion point Укажите точку вставки
|
||||
Specify inside diameter of donut Укажите внутренний диаметр кольца
|
||||
Specify leader arrowhead location Укажите положение стрелки выноски
|
||||
Specify leader endpoint Укажите конечную точку выноски
|
||||
Specify leader landing location Укажите положение полки выноски
|
||||
Specify length for rectangles Укажите длину прямоугольников
|
||||
Specify length of chord Укажите длину хорды
|
||||
Specify length of segment Укажите длину сегмента
|
||||
Specify line width for rectangles Укажите ширину линий прямоугольников
|
||||
Specify location for new vertex Укажите положение новой вершины
|
||||
Specify minimum length of arc Укажите минимальную длину дуги
|
||||
Specify new end point Укажите новую конечную точку
|
||||
Specify new location for marked vertex Укажите новое положение отмеченной вершины
|
||||
Specify new width for all segments Укажите новую ширину всех сегментов
|
||||
Specify next point Укажите следующую точку
|
||||
Specify offset distance Укажите расстояние смещения
|
||||
Specify offset distance or Укажите расстояние смещения или
|
||||
Specify opposite corner Укажите противоположный угол
|
||||
Specify other corner point Укажите другую угловую точку
|
||||
Specify other endpoint of axis Укажите другую конечную точку оси
|
||||
Specify outside diameter of donut Укажите внешний диаметр кольца
|
||||
Specify point Укажите точку
|
||||
Specify point on object for first tangent of circle Укажите точку на объекте для первой касательной круга
|
||||
Specify point on object for second tangent of circle Укажите точку на объекте для второй касательной круга
|
||||
Specify point on side to offset Укажите точку со стороны смещения
|
||||
Specify radius of arc Укажите радиус дуги
|
||||
Specify radius of circle Укажите радиус круга
|
||||
Specify rotation angle Укажите угол поворота
|
||||
Specify rotation angle of text Укажите угол поворота текста
|
||||
Specify rotation angle or Укажите угол поворота или
|
||||
Specify scale factor Укажите масштабный коэффициент
|
||||
Specify second angle endpoint Укажите вторую конечную точку угла
|
||||
Specify second break point Укажите вторую точку разрыва
|
||||
Specify second chamfer distance for rectangles Укажите второе расстояние фаски прямоугольников
|
||||
Specify second constraint point Укажите вторую точку ограничения
|
||||
Specify second destination point Укажите вторую целевую точку
|
||||
Specify second end point of circle's diameter Укажите вторую конечную точку диаметра круга
|
||||
Specify second endpoint of edge Укажите вторую конечную точку ребра
|
||||
Specify second extension line origin Укажите начало второй выносной линии
|
||||
Specify second point Укажите вторую точку
|
||||
Specify second point of arc Укажите вторую точку дуги
|
||||
Specify second point of mirror line Укажите вторую точку оси зеркального отражения
|
||||
Specify second point on circle Укажите вторую точку на круге
|
||||
Specify second point on circle: _tan to Укажите вторую точку на круге: _tan к
|
||||
Specify second point or <use first point as displacement> Укажите вторую точку или <использовать первую точку как смещение>
|
||||
Specify second source point Укажите вторую исходную точку
|
||||
Specify side to offset Укажите сторону смещения
|
||||
Specify start angle Укажите начальный угол
|
||||
Specify start point Укажите начальную точку
|
||||
Specify start point of arc Укажите начальную точку дуги
|
||||
Specify start point of polyline Укажите начальную точку полилинии
|
||||
Specify start point of text Укажите начальную точку текста
|
||||
Specify starting width Укажите начальную ширину
|
||||
Specify starting width for next segment Укажите начальную ширину следующего сегмента
|
||||
Specify tangent direction for the start point of arc Укажите направление касательной в начальной точке дуги
|
||||
Specify the elevation for rectangles Укажите уровень прямоугольников
|
||||
Specify thickness for rectangles Укажите высоту выдавливания прямоугольников
|
||||
Specify third point on circle Укажите третью точку на круге
|
||||
Specify third point on circle: _tan to Укажите третью точку на круге: _tan к
|
||||
Specify third source point or <continue> Укажите третью исходную точку или <продолжить>
|
||||
Specify through point Укажите точку, через которую проходит прямая
|
||||
Specify top diameter Укажите диаметр вершины
|
||||
Specify top radius or Укажите радиус вершины или
|
||||
Specify total angle Укажите полный угол
|
||||
Specify total length or Укажите общую длину или
|
||||
Specify width for rectangles Укажите ширину прямоугольников
|
||||
Sphere Сфера
|
||||
Spline Сплайн
|
||||
Stack (type 1/2 then select) Дробь (введите 1/2, затем выберите)
|
||||
Standard Стандартный
|
||||
Start Начало
|
||||
Start Width Начальная ширина
|
||||
Start X Начало X
|
||||
Start Y Начало Y
|
||||
Start angle Начальный угол
|
||||
Start, Center, Angle Начало, центр, угол
|
||||
Start, Center, End Начало, центр, конец
|
||||
Start, Center, Length Начало, центр, длина
|
||||
Start, End, Angle Начало, конец, угол
|
||||
Start, End, Direction Начало, конец, направление
|
||||
Start, End, Radius Начало, конец, радиус
|
||||
Status Состояние
|
||||
Status Bar Строка состояния
|
||||
Stretch Растянуть
|
||||
Style: Standard Стиль: Standard
|
||||
Subtract Вычесть
|
||||
Sweep Тело по траектории
|
||||
Switch Drawing Переключить чертеж
|
||||
Switch Layout Переключить лист
|
||||
Switch between model space in a viewport and paper space Переключить между пространством модели в видовом экране и пространством листа
|
||||
Symmetric Симметрия
|
||||
Synchronize Attributes Синхронизировать атрибуты
|
||||
Toggle PICKADD (off: each pick replaces the selection) Переключить PICKADD (выкл.: каждый выбор заменяет набор выделения)
|
||||
Toggle PICKADD (on: picks add to the selection) Переключить PICKADD (вкл.: выделение добавляется к набору)
|
||||
TOP СВЕРХУ
|
||||
Table Таблица
|
||||
Table Style... Стиль таблиц...
|
||||
Table style Стиль таблиц
|
||||
Table... Таблица...
|
||||
Tan, Tan, Radius Касательная, касательная, радиус
|
||||
Tan, Tan, Tan Касательная, касательная, касательная
|
||||
Tangent Касательность
|
||||
Text Текст
|
||||
Text Editor Текстовый редактор
|
||||
Text Height Высота текста
|
||||
Text Style... Текстовый стиль...
|
||||
Text height Высота текста
|
||||
Text override Переопределение текста
|
||||
Text style Текстовый стиль
|
||||
Thaw All Layers Разморозить все слои
|
||||
There is nothing here yet. Здесь пока ничего нет.
|
||||
Thickness Высота выдавливания
|
||||
Time Время
|
||||
To get started, create or open a drawing. Чтобы начать, создайте или откройте чертеж.
|
||||
Tool Bar Панель инструментов
|
||||
Tool Sets Наборы инструментов
|
||||
Tools Инструменты
|
||||
Torus Тор
|
||||
Trace Широкая линия
|
||||
Transparency Прозрачность
|
||||
Trim Обрезать
|
||||
True Color Цвет RGB
|
||||
Turn All Layers On Включить все слои
|
||||
Turn Off Layer Выключить слой
|
||||
Turn Quick Properties off (QPMODE) Выключить быстрые свойства (QPMODE)
|
||||
Type Тип
|
||||
Type a command Введите команду
|
||||
Type text; Enter starts a new paragraph Введите текст; Enter начинает новый абзац
|
||||
UCS Icon Значок ПСК
|
||||
Undo Отменить
|
||||
Undo (⌘Z) Отменить (⌘Z)
|
||||
Ungroup Разгруппировать
|
||||
Union Объединение
|
||||
Unisolate Layer Отменить изоляцию слоев
|
||||
Units... Единицы...
|
||||
Unlock Layer Разблокировать слой
|
||||
Unmerge Cells Разъединить ячейки
|
||||
Unsaved Layer State Несохраненное состояние слоев
|
||||
Up Вверх
|
||||
Update Обновить
|
||||
Use System Setting Как в системе
|
||||
User Пользователь
|
||||
User Parameters Пользовательские параметры
|
||||
Username Имя пользователя
|
||||
VP Color Цвет ВЭ
|
||||
VP Freeze Заморозка ВЭ
|
||||
Value Значение
|
||||
Value: Значение:
|
||||
Version Версия
|
||||
Version {version} Версия {version}
|
||||
Vertical Вертикальность
|
||||
VerticalDistance ВертикальноеРасстояние
|
||||
View Вид
|
||||
ViewCube Видовой куб
|
||||
Viewport Видовой экран
|
||||
Viewport Layer Freeze Заморозить слой в видовом экране
|
||||
Viewport Properties Свойства видового экрана
|
||||
Viewport {id} Видовой экран {id}
|
||||
Viewports Видовые экраны
|
||||
W З
|
||||
WCS ⌄ МСК ⌄
|
||||
White Белый
|
||||
Width factor Коэффициент ширины
|
||||
Window Окно
|
||||
Wipeout Маскировка
|
||||
Workspace Switching Переключение рабочих сред
|
||||
Write Block Записать блок в файл
|
||||
Xline Вспомогательная прямая
|
||||
Yellow Желтый
|
||||
Yes Да
|
||||
Zoom Показать
|
||||
Zoom All Показать все
|
||||
Zoom Extents Показать границы объектов
|
||||
Zoom In Увеличить
|
||||
Zoom Object Показать объект
|
||||
Zoom Out Уменьшить
|
||||
Zoom Previous Предыдущий масштаб
|
||||
Zoom Window Показать рамкой
|
||||
error ошибка
|
||||
{contributors} · {commits} {contributors} · {commits}
|
||||
{kind} (constraint {id}) {kind} (ограничение {id})
|
||||
{kind} constraint {name} = {expression} (double-click to edit) Ограничение «{kind}» {name} = {expression} (дважды щелкните для редактирования)
|
||||
{name}: {distance} < {angle}° {name}: {distance} < {angle}°
|
||||
ƒx New User Parameter ƒx Новый пользовательский параметр
|
||||
ΔLOC Δстрок
|
||||
−Bin −двоич.
|
||||
−LOC −строк
|
||||
⌘↩ to finish ⌘↩ для завершения
|
||||
@plural {n} commit {n} коммит|{n} коммита|{n} коммитов
|
||||
@plural {n} contributor {n} участник|{n} участника|{n} участников
|
||||
Drawing as bytes Чертеж как байты
|
||||
Services Службы
|
||||
Hide CADCraft Скрыть CADCraft
|
||||
Hide Others Скрыть остальные
|
||||
Accessibility Специальные возможности
|
||||
Show System Cursor Показывать системный курсор
|
||||
Import... Импорт...
|
||||
Export... Экспорт...
|
||||
Batch Publish... Пакетная публикация...
|
||||
Hatch Pattern Образец штриховки
|
||||
Search for pattern Поиск образца
|
||||
|
@@ -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."));
|
||||
});
|
||||
}
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user