diff --git a/Cargo.lock b/Cargo.lock index dc08d4542e..476af0dc89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -978,11 +978,33 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "craft-codepage" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" + [[package]] name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" +[[package]] +name = "craft-dxf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "craft-codepage", + "encoding_rs", + "kurbo 0.13.1", + "log", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-eps" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1057,6 +1079,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "craft-svg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "craft-xml", + "flate2", + "kurbo 0.13.1", + "roxmltree 0.21.2", + "thiserror 2.0.21", + "usvg 0.48.1", + "vectorcraft-geom 0.18.0", + "vectorcraft-pathops 0.18.0", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1146,6 +1183,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -6340,6 +6386,7 @@ dependencies = [ "pollster 1.0.1", "raw-window-handle", "rfd", + "rustix 1.1.6", "serde_json", "socket2", "tempfile", @@ -6386,13 +6433,14 @@ dependencies = [ "serde_json", "vectorcraft-color", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", ] [[package]] name = "vectorcraft-cad" version = "0.24.1" dependencies = [ + "craft-dxf", "image", "kurbo 0.13.1", "serde_json", @@ -6400,8 +6448,8 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-text", ] @@ -6418,7 +6466,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-engine", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-mcp", "vectorcraft-render", "vectorcraft-svg", @@ -6455,7 +6503,7 @@ dependencies = [ "thiserror 2.0.21", "tiff", "vectorcraft-color", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "zune-jpeg 0.5.15", ] @@ -6470,8 +6518,8 @@ dependencies = [ "svgtypes 0.16.1", "vectorcraft-color", "vectorcraft-doc", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-plugins", "vectorcraft-text", "vectorcraft-three-d", @@ -6515,10 +6563,10 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-eps", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-js", "vectorcraft-metafile", - "vectorcraft-pathops", + "vectorcraft-pathops 0.24.1", "vectorcraft-pdf", "vectorcraft-plugins", "vectorcraft-render", @@ -6537,6 +6585,7 @@ dependencies = [ name = "vectorcraft-eps" version = "0.24.1" dependencies = [ + "craft-eps", "craft-pdf", "encoding_rs", "fax", @@ -6552,9 +6601,9 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-metafile", - "vectorcraft-pathops", + "vectorcraft-pathops 0.24.1", "vectorcraft-pdf", "vectorcraft-render", "vectorcraft-testkit", @@ -6573,10 +6622,20 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-testkit", ] +[[package]] +name = "vectorcraft-geom" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "vectorcraft-geom" version = "0.24.1" @@ -6611,7 +6670,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-engine", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", @@ -6628,10 +6687,21 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-text", ] +[[package]] +name = "vectorcraft-pathops" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "linesweeper", + "thiserror 2.0.21", + "vectorcraft-geom 0.18.0", +] + [[package]] name = "vectorcraft-pathops" version = "0.24.1" @@ -6640,7 +6710,7 @@ dependencies = [ "linesweeper", "proptest", "thiserror 2.0.21", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-testkit", ] @@ -6673,7 +6743,7 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-testkit", "vectorcraft-text", "weezl 0.2.1", @@ -6692,7 +6762,7 @@ dependencies = [ "thiserror 2.0.21", "vectorcraft-color", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "wasmi", "wat", "web-time", @@ -6717,7 +6787,7 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-plugins", "vectorcraft-testkit", "vectorcraft-text", @@ -6741,6 +6811,7 @@ name = "vectorcraft-svg" version = "0.24.1" dependencies = [ "craft-docpath", + "craft-svg", "flate2", "image", "kurbo 0.13.1", @@ -6757,8 +6828,8 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-testkit", "vectorcraft-text", ] @@ -6776,7 +6847,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-engine", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-render", "vectorcraft-svg", ] @@ -6796,7 +6867,7 @@ dependencies = [ "unicode-script", "unicode-segmentation", "vectorcraft-doc", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vello_cpu", "windows-registry", "write-fonts", @@ -6806,8 +6877,8 @@ dependencies = [ name = "vectorcraft-three-d" version = "0.24.1" dependencies = [ - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", ] [[package]] @@ -6822,8 +6893,8 @@ dependencies = [ "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", - "vectorcraft-geom", - "vectorcraft-pathops", + "vectorcraft-geom 0.24.1", + "vectorcraft-pathops 0.24.1", "vectorcraft-text", ] @@ -6836,7 +6907,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", ] [[package]] @@ -6865,7 +6936,7 @@ dependencies = [ "vectorcraft-engine", "vectorcraft-eps", "vectorcraft-format", - "vectorcraft-geom", + "vectorcraft-geom 0.24.1", "vectorcraft-pdf", "vectorcraft-plugins", "vectorcraft-render", diff --git a/Cargo.toml b/Cargo.toml index 5bed166e17..c7856d589e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,12 @@ craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", re # craft-update: app-specific pretend versions apply to startup and manual checks. # Native Affinity documents (craft-libs#914). craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } +# SVG parsing, limits, SVGZ and writer (craft-libs#778). +craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2", features = ["tree"] } +# EPS container, previews, ASCII85 and DSC comments (craft-libs#778). +craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } +# DXF tags, reader and writer (craft-libs#778, #926, #927). +craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/ROADMAP.md b/ROADMAP.md index fe038ec97a..346c532277 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,8 @@ gate: all six core workflows (path drawing and editing, paint and style, type, l production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). +> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged. + ## Headline numbers | | Value | Kind | @@ -96,6 +98,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta | Simplified Chinese | `zh-hans` | partial (messages in English) | 85% | | Spanish | `es` | full | 100% | | Hindi | `hi` | catalog complete; machine-assisted draft, native review pending | complete under the scanner | +| Gujarati | `gu` | 4,673 contributor-reviewed catalog rows; 11 corrections applied to the 387 additions | menus and dialogs; status/error messages partial | | Arabic | `ar` | none (no right-to-left interface) | 0% | | French | `fr` | full | 100% | | Portuguese (Brazil) | `pt-br` | full | 100% | @@ -218,6 +221,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | | 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. | @@ -226,6 +230,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. | | 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | +| 2026-11-12 | minor | Gujarati (`gu`): reviewed by a native speaker; zero/one singular plurals (CLDR); Gujarati craft-fonts faces join the UI fonts; strings added on main since branching translated (4,286 rows) | +| 2026-11-11 | minor | Gujarati (`gu`): 4,235-row machine-assisted interface catalog, one/other plurals, desktop Gujarati font fallback; messages not yet translated; native review pending | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | | 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 2da5f0083d..9a1b11e350 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -110,6 +110,10 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build # through winit and rfd. wayland-backend = { version = "0.3", features = ["client_system"] } +# The Wayland clipboard's pipes (src/clipboard.rs): `poll` and the nonblocking flag give up a read +# or send when the other app moves no data for a while. Apache-2.0 WITH LLVM-exception OR +# Apache-2.0 OR MIT, already in the build through the workspace. +rustix = { workspace = true, features = ["std", "event", "fs"] } # The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the # systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 77a96442c4..17b9327956 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -1790,15 +1790,17 @@ mod portable { /// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads /// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and /// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for -/// a paste to wait for off the UI thread, as arboard's are. +/// a paste to wait for off the UI thread, as arboard's are. A read or send whose pipe moves no data +/// for [`IDLE_WAIT`] fails and closes the pipe, which frees its worker or thread. #[cfg(target_os = "linux")] mod wayland { - use std::io::{Read, Write}; - use std::os::fd::AsFd as _; + use std::io::{ErrorKind, Read, Write}; + use std::os::fd::{AsFd, BorrowedFd}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc}; use std::time::{Duration, Instant}; + use rustix::event::{PollFd, PollFlags, Timespec}; use vectorcraft_engine::cmd::clipboard::names_a_picture; use vectorcraft_ui_egui::ClipboardRead; use wayland_client::backend::ObjectData; @@ -1827,6 +1829,11 @@ mod wayland { const MAX_SENDS: usize = 8; /// The most types of an offer that are kept (untrusted data). const MAX_TYPES: usize = 256; + /// How long a read or send waits for the other app to move a byte through the pipe before it + /// fails, VectorCraft's own limit; the wait starts again after each byte moved. + const IDLE_WAIT: Duration = LOAD_WAIT; + /// The most bytes a read takes from the pipe at a time. + const CHUNK: usize = 64 << 10; /// What a read gives: the bytes, or why there are none. type Got = Result, String>; @@ -1898,37 +1905,117 @@ mod wayland { Some(first) } - /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read. - fn read_capped(from: impl Read, cap: usize) -> Got { - let mut data = vec![]; - from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?; - if data.len() > cap { - return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20)); + /// The time `idle` from now; None when that is past the clock's range (no limit). + fn idle_until(idle: Duration) -> Option { + Instant::now().checked_add(idle) + } + + /// Makes `fd` nonblocking, its other flags kept. + fn nonblocking(fd: BorrowedFd<'_>) -> std::io::Result<()> { + let flags = rustix::fs::fcntl_getfl(fd)?; + rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK)?; + Ok(()) + } + + /// Waits until `fd` is ready for `events`, closed or failed (true), or `until` passes (false); + /// None waits without a limit. A wait a signal cuts short goes on until `until`. + fn ready(fd: BorrowedFd<'_>, events: PollFlags, until: Option) -> std::io::Result { + loop { + let left = until.map(|t| t.saturating_duration_since(Instant::now())); + let timeout = left.and_then(|d| Timespec::try_from(d).ok()); + let mut fds = [PollFd::new(&fd, events)]; + match rustix::event::poll(&mut fds, timeout.as_ref()) { + Ok(n) => return Ok(n > 0), + Err(rustix::io::Errno::INTR) => {} + Err(e) => return Err(e.into()), + } } - Ok(data) + } + + /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read and a + /// pipe that is left open with no byte coming for `idle`. A read that fails gives none of the + /// bytes it took, and closes `from`. + fn read_capped(mut from: impl Read + AsFd, cap: usize, idle: Duration) -> Got { + let failed = |e: std::io::Error| format!("couldn't read the clipboard's data: {e}"); + nonblocking(from.as_fd()).map_err(failed)?; + let (mut data, mut chunk) = (vec![], vec![0u8; CHUNK]); + let mut until = idle_until(idle); + loop { + match from.read(&mut chunk) { + Ok(0) => return Ok(data), + Ok(n) => { + let got = chunk.get(..n).ok_or_else(|| failed(ErrorKind::InvalidData.into()))?; + if data.len() + got.len() > cap { + return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20)); + } + data.extend_from_slice(got); + until = idle_until(idle); + } + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if !ready(from.as_fd(), PollFlags::IN, until).map_err(failed)? { + return Err("the app that copied stopped sending the clipboard's data".to_string()); + } + } + Err(e) => return Err(failed(e)), + } + } + } + + /// `data` written to `to`: an error when the app that pastes closes its end (the process + /// ignores `SIGPIPE`) or takes no byte for `idle`. + fn send(mut to: impl Write + AsFd, data: &[u8], idle: Duration) -> std::io::Result<()> { + nonblocking(to.as_fd())?; + let (mut sent, mut until) = (0, idle_until(idle)); + while let Some(rest) = data.get(sent..).filter(|rest| !rest.is_empty()) { + match to.write(rest) { + Ok(0) => return Err(ErrorKind::WriteZero.into()), + Ok(n) => { + sent += n; + until = idle_until(idle); + } + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if !ready(to.as_fd(), PollFlags::OUT, until)? { + return Err(ErrorKind::TimedOut.into()); + } + } + Err(e) => return Err(e), + } + } + Ok(()) } /// The threads that send a Copy's data to the apps that paste it, counted. - #[derive(Clone, Default)] - struct Sends(Arc); + #[derive(Clone)] + struct Sends { + running: Arc, + /// How long a send waits for the app that pastes to take a byte. + idle: Duration, + } + + impl Default for Sends { + fn default() -> Self { + Self { running: Arc::default(), idle: IDLE_WAIT } + } + } impl Sends { - /// `data` written to `to` on a thread of its own, which then closes `to`; an app that - /// closes its end early ends the write (the process ignores `SIGPIPE`). While - /// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false. - fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool { - if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS { - self.0.fetch_sub(1, Ordering::SeqCst); + /// `data` written to `to` on a thread of its own ([`send`]), which then closes `to`; an app + /// that closes its end early or takes no byte for the send's idle wait ends the write. + /// While [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false. + fn start(&self, to: impl Write + AsFd + Send + 'static, data: Arc<[u8]>) -> bool { + if self.running.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS { + self.running.fetch_sub(1, Ordering::SeqCst); return false; } - let count = self.0.clone(); - let send = move || { - let _ = to.write_all(&data); - drop(to); + let (count, idle) = (self.running.clone(), self.idle); + let work = move || { + let _ = send(to, &data, idle); count.fetch_sub(1, Ordering::SeqCst); }; - if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() { - self.0.fetch_sub(1, Ordering::SeqCst); + if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(work).is_err() { + self.running.fetch_sub(1, Ordering::SeqCst); return false; } true @@ -1937,7 +2024,7 @@ mod wayland { /// How many are being written. #[cfg(test)] fn running(&self) -> usize { - self.0.load(Ordering::SeqCst) + self.running.load(Ordering::SeqCst) } } @@ -2298,7 +2385,7 @@ mod wayland { impl Receiver { /// The clipboard's data of type `wire`, read until the app that copied closes the pipe, - /// [`MAX_BYTES`] at most. + /// [`MAX_BYTES`] at most; an error when that app sends no byte for [`IDLE_WAIT`]. fn receive(&self, wire: String) -> Got { let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?; let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?; @@ -2307,7 +2394,7 @@ mod wayland { // holds the only one, and the read ends when that app closes it. drop(to); self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?; - read_capped(from, MAX_BYTES) + read_capped(from, MAX_BYTES, IDLE_WAIT) } } @@ -2557,31 +2644,86 @@ mod wayland { assert_eq!(keyboard_serial(&modifiers(9), None), None); } + /// A wait no test reaches: the other end of each pipe below either moves data or closes. + const LONG: Duration = Duration::from_secs(600); + + /// A pipe holding `data`, its write end closed when `close`. + fn pipe_with(data: &[u8], close: bool) -> (std::io::PipeReader, Option) { + let (from, mut to) = std::io::pipe().unwrap(); + to.write_all(data).unwrap(); + (from, (!close).then_some(to)) + } + #[test] fn a_read_stops_at_its_limit() { assert_eq!(MAX_BYTES, 64 * 1024 * 1024); - assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec())); - assert_eq!(read_capped(&b""[..], 8), Ok(vec![])); - assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string())); - assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string())); - /// A pipe that breaks. - struct Broken; - impl Read for Broken { - fn read(&mut self, _: &mut [u8]) -> std::io::Result { - Err(std::io::ErrorKind::BrokenPipe.into()) - } + assert_eq!(IDLE_WAIT, LOAD_WAIT); + assert_eq!(read_capped(pipe_with(b"12345678", true).0, 8, Duration::ZERO), Ok(b"12345678".to_vec())); + assert_eq!(read_capped(pipe_with(b"", true).0, 8, Duration::ZERO), Ok(vec![])); + assert_eq!(read_capped(pipe_with(&[0u8; 9], true).0, 8, Duration::ZERO), Err("the clipboard's data is larger than 0 MiB".to_string())); + // More than a pipe holds, from an app that is still sending. + let (from, to) = std::io::pipe().unwrap(); + assert!(Sends::default().start(to, vec![0u8; (2 << 20) + 1].into())); + assert_eq!(read_capped(from, 2 << 20, LONG), Err("the clipboard's data is larger than 2 MiB".to_string())); + // A pipe that can't be read: the write end. + let (_, to) = std::io::pipe().unwrap(); + let unreadable = std::fs::File::from(std::os::fd::OwnedFd::from(to)); + assert!(read_capped(unreadable, 8, LONG).is_err_and(|e| e.starts_with("couldn't read the clipboard's data"))); + } + + #[test] + fn a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe() { + let stopped = Err("the app that copied stopped sending the clipboard's data".to_string()); + // Some bytes and then nothing, the pipe left open: none of them are pasted. + let (from, to) = pipe_with(b"part", false); + assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped); + // The read end is closed, so that app's next write fails. + let mut to = to.unwrap(); + assert_eq!(to.write(b"x").map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe)); + // Nothing at all, and data that fills the limit exactly with more never coming. + let (from, _held) = pipe_with(b"", false); + assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped); + let (from, _held) = pipe_with(b"12345678", false); + assert_eq!(read_capped(from, 8, Duration::ZERO), stopped); + // The next read gets its data. + assert_eq!(read_capped(pipe_with(b"words", true).0, MAX_BYTES, Duration::ZERO), Ok(b"words".to_vec())); + } + + #[test] + fn a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread() { + let data: Arc<[u8]> = vec![3u8; 300_000].into(); + // More than a pipe holds, to an app that never reads. + let (_held, to) = std::io::pipe().unwrap(); + assert_eq!(send(to, &data, Duration::ZERO).map_err(|e| e.kind()), Err(ErrorKind::TimedOut)); + // An app that closes its end before reading. + let (from, to) = std::io::pipe().unwrap(); + drop(from); + assert_eq!(send(to, &data, LONG).map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe)); + // A send thread to an app that does not read until the send gives up, its end still open. + let sends = Sends { idle: Duration::ZERO, ..Sends::default() }; + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, data.clone())); + while sends.running() > 0 { + std::thread::yield_now(); } - assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data"))); + // What the pipe held when the send gave up, then the end of the data: the send closed it. + let got = read_capped(from, MAX_BYTES, Duration::ZERO).map(|d| d.len()); + assert!(got.as_ref().is_ok_and(|&n| n > 0 && n < data.len()), "{got:?}"); + // The thread is free for the next paste. + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, b"words"[..].into())); + assert_eq!(read_capped(from, MAX_BYTES, LONG), Ok(b"words".to_vec())); } #[test] fn a_copys_data_is_sent_through_the_pipe_and_closed() { let sends = Sends::default(); + assert_eq!(sends.idle, IDLE_WAIT); let (from, to) = std::io::pipe().unwrap(); let data: Arc<[u8]> = vec![7u8; 300_000].into(); assert!(sends.start(to, data.clone())); // More than a pipe holds: the write ends once all of it is read, then the pipe closes. - assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..])); + assert_eq!(read_capped(from, MAX_BYTES, LONG).as_deref(), Ok(&data[..])); // An app that closes its end before reading ends the send. let (from, to) = std::io::pipe().unwrap(); drop(from); @@ -2593,9 +2735,9 @@ mod wayland { #[test] fn pastes_that_read_nothing_hold_a_few_sends_at_most() { - let sends = Sends::default(); + let sends = Sends { idle: LONG, ..Sends::default() }; let data: Arc<[u8]> = vec![1u8; 300_000].into(); - // Apps that ask for the data and never read it. + // Apps that ask for the data and never read it, within the sends' idle wait. let stalled: Vec<_> = (0..MAX_SENDS) .map(|_| { let (from, to) = std::io::pipe().unwrap(); @@ -2606,7 +2748,7 @@ mod wayland { // The next paste gets its pipe closed at once, with nothing written. let (from, to) = std::io::pipe().unwrap(); assert!(!sends.start(to, data.clone())); - assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![])); + assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), Ok(vec![])); // Once those apps close their pipes, the sends end and others are sent again. drop(stalled); while sends.running() > 0 { @@ -2614,7 +2756,7 @@ mod wayland { } let (from, to) = std::io::pipe().unwrap(); assert!(sends.start(to, data.clone())); - assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len())); + assert_eq!(read_capped(from, MAX_BYTES, LONG).map(|d| d.len()), Ok(data.len())); } /// What a clipboard holds, by type. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fdbec59bfc..3397ada7a5 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -373,11 +373,14 @@ fn main() -> std::process::ExitCode { // Display Server › X11 opens the window through XWayland instead, where file drops work (#1151), // and the other choices do so when a pen is attached, where the pen draws (#2028, #4967). #[cfg(target_os = "linux")] + let mut forced_to_x11 = false; + #[cfg(target_os = "linux")] let options = { let mut options = options; let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) { if xwayland::x11_libraries_found() { + forced_to_x11 = true; if pref != Some("x11") { log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)"); } else { @@ -393,6 +396,11 @@ fn main() -> std::process::ExitCode { } options }; + #[cfg(not(target_os = "linux"))] + let forced_to_x11 = false; + // Preferences › User Interface notes under System Title Bar when the desktop draws the frame + // with it on or off. + let desktop_keeps_frame = titlebar::desktop_keeps_frame(forced_to_x11); // With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end // is down; the message hook notes each pointer contact and whether it starts with the barrel // button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the @@ -595,6 +603,7 @@ fn main() -> std::process::ExitCode { app.custom_titlebar = custom_titlebar; app.caption_layout = caption_layout; app.desktop_frame = !custom_titlebar; + app.desktop_keeps_frame = desktop_keeps_frame; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 99ca880bc9..1994569c0c 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -346,6 +346,34 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { } } +/// Preferences says under System Title Bar that the desktop draws the frame with it on or off +/// exactly where the start frame without VectorCraft Title Bar on Every Desktop is the desktop's +/// with System Title Bar off as well as on and `VECTORCRAFT_TITLEBAR` is unset: Linux and the BSDs +/// only. +#[test] +fn the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it() { + use crate::titlebar::desktop_keeps_frame_from; + use craft_window::{Frame, Layout}; + let linux = cfg!(not(any(target_os = "macos", target_os = "windows"))); + for probed in [Frame::Desktop, Frame::App(Layout::default())] { + for forced_to_x11 in [false, true] { + let frame = super::start_frame(probed.clone(), None, forced_to_x11, false); + let either_way = !super::custom_titlebar_from(&frame, false) && !super::custom_titlebar_from(&frame, true); + assert_eq!(desktop_keeps_frame_from(&probed, None, forced_to_x11), linux && either_way, "{probed:?} {forced_to_x11}"); + } + } + // KDE, Sway and X11, and GNOME through XWayland, keep the desktop's frame; GNOME on Wayland does not. + assert_eq!(desktop_keeps_frame_from(&Frame::Desktop, None, false), linux); + assert_eq!(desktop_keeps_frame_from(&Frame::App(Layout::default()), None, true), linux); + assert!(!desktop_keeps_frame_from(&Frame::App(Layout::default()), None, false)); + // While `VECTORCRAFT_TITLEBAR` chooses the frame, System Title Bar does not apply and there is no note. + for (frame, overridden) in [(Frame::App(Layout::default()), Some(true)), (Frame::Desktop, Some(false))] { + for forced_to_x11 in [false, true] { + assert!(!desktop_keeps_frame_from(&frame, overridden, forced_to_x11), "{overridden:?} {forced_to_x11}"); + } + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 88dce26897..cb98fa2648 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -191,6 +191,25 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> ( (custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame)) } +/// Whether the desktop would draw this window's frame with System Title Bar on or off while +/// VectorCraft Title Bar on Every Desktop is off: `VECTORCRAFT_TITLEBAR` is not set to `app`, +/// `system` or `desktop` (`overridden` is `None`), and the probe chose +/// [`craft_window::Frame::Desktop`] or the window opens through XWayland (`forced_to_x11`) +/// ([`app_draws_frame`]). The saved VectorCraft Title Bar on Every Desktop is left out, so the value +/// stays `true` on such a desktop while that preference gives the app the frame. Always `false` on +/// macOS and Windows, where System Title Bar decides the frame by itself. +pub(crate) fn desktop_keeps_frame_from(probed: &craft_window::Frame, overridden: Option, forced_to_x11: bool) -> bool { + let probed_app = matches!(probed, craft_window::Frame::App(_)); + cfg!(not(any(target_os = "macos", target_os = "windows"))) && overridden.is_none() && !app_draws_frame(probed_app, None, forced_to_x11, false) +} + +/// [`desktop_keeps_frame_from`] for this start's probe, given whether the launch path opens the +/// window through XWayland. +pub(crate) fn desktop_keeps_frame(forced_to_x11: bool) -> bool { + let choice = probed_choice(); + desktop_keeps_frame_from(choice.frame(), choice.overridden(), forced_to_x11) +} + pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 2cf9f43c97..00edeb3113 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -114,51 +114,9 @@ fn strip_brushes(n: &mut Node) { } } -/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer. -pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { - let amount = amount.clamp(0.0, 1.0); - for it in &mut n.appearance.items { - let p = match it { - AppearanceItem::Fill(f) => &mut f.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Solid { color: c, .. } = p { - *p = Paint::solid(c.lerp(color, amount)); - } - } - if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - for p in [&mut r.style.fill, &mut r.style.stroke] { - if let Paint::Solid { color: c, .. } = p { - *p = Paint::solid(c.lerp(color, amount)); - } - } - } - } - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - tint_node(Arc::make_mut(c), color, amount); - } - } -} - -/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's -/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one -/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links. -pub fn stain(inst: &Node) -> Option<(Color, f32)> { - let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?; - Some((f.paint.color()?, f.opacity)) -} - -/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic -/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]). -pub fn instance_art(art: &Node, inst: &Node) -> Node { - let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst); - if let Some((c, amount)) = stain(inst) { - tint_node(&mut a, &c, amount); - } - a -} +/// The Symbol Stainer's mix, an instance's stain and its stained art, from +/// [`vectorcraft_doc::symbol_overrides`], which the effects that reshape an instance also read. +pub use vectorcraft_doc::symbol_overrides::{instance_art, stain, tint_node}; /// The solid colours of the fills and strokes under `nodes`, most used first (then in the order /// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 34f77ec929..e529e1d4e2 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -9,6 +9,7 @@ description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 workspace = true [dependencies] +craft-dxf = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } diff --git a/crates/cad/src/aci.rs b/crates/cad/src/aci.rs index 63fbb180f9..39818a78ef 100644 --- a/crates/cad/src/aci.rs +++ b/crates/cad/src/aci.rs @@ -1,50 +1,11 @@ -//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are -//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a -//! half-saturated tone, and 250–255 are greys. +//! The colour index palette is `craft_dxf::model::aci`; here are the indices each colour depth +//! of the writer may use. use crate::ColorDepth; -/// The nine standard colours (index 7 shows white on a dark background and black on paper). -const STANDARD: [[u8; 3]; 9] = - [[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]]; - -/// The brightness of the five shades of each hue. -const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15]; - /// The RGB of colour index `i` (1–255; anything else is index 7's white). pub fn aci_rgb(i: u8) -> [u8; 3] { - match i { - 1..=9 => STANDARD[usize::from(i - 1)], - 10..=249 => { - let hue = f64::from(i / 10 - 1) * 15.0; - let k = i % 10; - let v = SHADES[usize::from(k / 2)]; - let pastel = k % 2 == 1; - hue_rgb(hue).map(|f| { - let f = if pastel { f + (1.0 - f) / 2.0 } else { f }; - (255.0 * v * f).floor() as u8 - }) - } - 250..=255 => { - let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8; - [g; 3] - } - _ => STANDARD[6], - } -} - -/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels. -fn hue_rgb(hue: f64) -> [f64; 3] { - let h = hue.rem_euclid(360.0) / 60.0; - let x = 1.0 - (h % 2.0 - 1.0).abs(); - match h as u8 { - 0 => [1.0, x, 0.0], - 1 => [x, 1.0, 0.0], - 2 => [0.0, 1.0, x], - 3 => [0.0, x, 1.0], - 4 => [x, 0.0, 1.0], - _ => [1.0, 0.0, x], - } + craft_dxf::model::aci::rgb(i) } /// The indices a colour depth may use (true colour: all of them, for the fallback index). @@ -59,37 +20,13 @@ fn candidates(depth: ColorDepth) -> Box> { /// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index). /// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast. pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 { - let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::(); - candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7) + craft_dxf::model::aci::nearest(rgb, candidates(depth)) } #[cfg(test)] mod tests { use super::*; - #[test] - fn the_palette_matches_its_known_entries() { - let known: [(u8, [u8; 3]); 14] = [ - (1, [255, 0, 0]), - (10, [255, 0, 0]), - (11, [255, 127, 127]), - (12, [165, 0, 0]), - (13, [165, 82, 82]), - (17, [76, 38, 38]), - (19, [38, 19, 19]), - (20, [255, 63, 0]), - (21, [255, 159, 127]), - (30, [255, 127, 0]), - (140, [0, 191, 255]), - (250, [51, 51, 51]), - (252, [132, 132, 132]), - (255, [255, 255, 255]), - ]; - for (i, rgb) in known { - assert_eq!(aci_rgb(i), rgb, "index {i}"); - } - } - #[test] fn nearest_index_by_depth() { assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1); diff --git a/crates/cad/src/import.rs b/crates/cad/src/import.rs index f1f4104863..9216c57c29 100644 --- a/crates/cad/src/import.rs +++ b/crates/cad/src/import.rs @@ -1,4 +1,4 @@ -//! DXF import: an ASCII DXF drawing of any version as a document. +//! DXF import: a DXF drawing (ASCII or binary) of any version as a document. //! //! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N //! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred @@ -10,7 +10,8 @@ //! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve //! them. What can't come in is listed in the warnings. //! -//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts. +//! - `reader`: what the import uses of the drawing `craft_dxf::model` reads: header, tables, +//! blocks and layouts. //! - `entity`: entities to items (geometry and properties). //! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths. //! - `text`: text values to plain text. @@ -31,9 +32,6 @@ use crate::{DxfVersion, INSUNITS, SCALE_RANGE}; /// Letter, the box Fit to Artboard fills unless told otherwise (points). pub const FIT_TO: (f64, f64) = (612.0, 792.0); -/// Why a binary DXF can't be opened. -pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that"; - /// How a drawing comes in. #[derive(Clone, Debug, PartialEq)] pub struct ImportOptions { @@ -104,15 +102,20 @@ pub fn is_dxf(bytes: &[u8]) -> bool { false } -/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused). -fn text(bytes: &[u8]) -> Result, String> { - if bytes.starts_with(reader::BINARY_SENTINEL) { - return Err(BINARY.into()); - } +/// The tags of a DXF file and where it is damaged (DWG files are refused). +fn tags(bytes: &[u8]) -> Result<(Vec, Option), String> { if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") { - return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into()); + return Err("this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that".into()); } - Ok(reader::decode(bytes)) + let (tags, damaged) = craft_dxf::parse_recovering(bytes).map_err(|e| match e { + craft_dxf::DxfError::TooLarge { .. } => "the drawing is too large to open".to_string(), + _ if bytes.starts_with(reader::BINARY_SENTINEL) => "this binary DXF file is damaged or cut short".to_string(), + _ => "not a DXF drawing".to_string(), + })?; + if tags.is_empty() { + return Err("not a DXF drawing".into()); + } + Ok((tags, damaged.map(|line| format!("the file is damaged at line {line}: what follows is left out")))) } /// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it. @@ -161,9 +164,8 @@ fn units_name(code: i64) -> &'static str { /// Read what `bytes` holds: its version, units, layouts and layers. pub fn info(bytes: &[u8]) -> Result { - let text = text(bytes)?; - let (pairs, _) = reader::pairs(&text)?; - let d = reader::Drawing::read(&pairs); + let (tags, _) = tags(bytes)?; + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); if d.sections == 0 { return Err("not a DXF drawing".into()); } @@ -180,7 +182,7 @@ pub fn info(bytes: &[u8]) -> Result { }) } -/// Import an ASCII DXF drawing with `o`. +/// Import a DXF drawing with `o`. pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { let (w, h) = o.fit_to; if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) { @@ -191,9 +193,8 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result { { return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end())); } - let text = text(bytes)?; - let (pairs, damaged) = reader::pairs(&text)?; - let d = reader::Drawing::read(&pairs); + let (tags, damaged) = tags(bytes)?; + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); if d.sections == 0 { return Err("not a DXF drawing".into()); } diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index 59e1283cce..309a4ea228 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -7,9 +7,10 @@ use kurbo::{BezPath, PathEl}; use vectorcraft_doc::Justify; use vectorcraft_geom::{Affine, Point, Vec2}; +use craft_dxf::model::{self, EntityRecord, Groups, HatchEdge, HatchPath}; + use super::curve::{self, Vertex}; -use super::reader::{Groups, Pair, Record, groups, number}; -use crate::aci::aci_rgb; +use super::reader::{aci_paper, point}; /// How far below the baseline the lowest descender reaches, as a fraction of the cap height. const DESCENT: f64 = 0.3; @@ -25,21 +26,6 @@ const MAX_COORD: f64 = 1e12; /// factor 1, the 1 to 10,000% horizontal scale the Character panel allows. const SPAN_SCALE: (f64, f64) = (0.01, 100.0); -/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. -pub(crate) fn aci_paper(i: u64) -> [u8; 3] { - match u8::try_from(i) { - Ok(7) | Err(_) => [0; 3], - Ok(i) => aci_rgb(i), - } -} - -/// The true colour (group 420, `0xRRGGBB`) of an entity or layer. -pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> { - let v = g.int(420)?; - let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes(); - Some([r, gr, b]) -} - /// An entity's colour. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum Col { @@ -79,30 +65,30 @@ pub(crate) struct Props { } impl Props { - fn read(g: &[Pair<'_>]) -> Self { - let color = match (true_color(g), g.int(62)) { - (Some(rgb), _) => Col::Rgb(rgb), - (None, Some(0)) => Col::ByBlock, - (None, None | Some(256..)) => Col::ByLayer, - (None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())), + fn read(c: &model::Common) -> Self { + let color = match c.color { + model::Color::Rgb(rgb) => Col::Rgb(rgb), + model::Color::ByBlock => Col::ByBlock, + model::Color::ByLayer => Col::ByLayer, + model::Color::Index(i) => Col::Rgb(aci_paper(i)), }; - let lineweight = match g.int(370) { - Some(-2) => Lw::ByBlock, - Some(-3) => Lw::Default, - Some(v @ 0..=211) => Lw::Hundredths(v as i32), + let lineweight = match c.lineweight { + model::Lineweight::ByBlock => Lw::ByBlock, + model::Lineweight::Default => Lw::Default, + model::Lineweight::Hundredths(v @ 0..=211) => Lw::Hundredths(i32::from(v)), _ => Lw::ByLayer, }; - let alpha = match g.int(440) { - Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0), - Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock, - _ => Alpha::ByLayer, + let alpha = match c.transparency { + model::Transparency::Alpha(a) => Alpha::Opacity(f32::from(a) / 255.0), + model::Transparency::ByBlock => Alpha::ByBlock, + model::Transparency::ByLayer => Alpha::ByLayer, }; Self { - layer: g.name(8).unwrap_or("0").to_string(), + layer: c.layer.clone(), color, lineweight, - linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(), - ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0), + linetype: c.linetype.as_deref().unwrap_or("BYLAYER").to_uppercase(), + ltscale: c.ltscale, alpha, } } @@ -209,90 +195,87 @@ impl Converter { } /// The items of `records` (an entity section or a block), in drawing order. - pub fn items(&mut self, records: &[Record<'_>]) -> Vec { + pub fn items(&mut self, records: &[EntityRecord<'_>]) -> Vec { let mut out = Vec::new(); - for group in groups(records) { - let Some(head) = group.first() else { continue }; + for e in records { + let m = model::Entity::read(e); // Invisible entities. - if head.g.int(60) == Some(1) { + if !m.common.visible { continue; } - match self.geom(head, group) { + match self.geom(e, &m) { Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY), - Some(geom) => out.push(Item { props: Props::read(head.g), geom }), - None if is_known(head.kind) => {} - None => self.skip(head.kind), + Some(geom) => out.push(Item { props: Props::read(&m.common), geom }), + None if is_known(e.kind) => {} + None => self.skip(e.kind), } } out } - fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option { - let g = head.g; + fn geom(&mut self, e: &EntityRecord<'_>, m: &model::Entity) -> Option { + let g = e.groups(); let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 }); - match head.kind { - "LINE" => { + let ocs = ocs(m.common.extrusion); + match &m.kind { + model::EntityKind::Line { start, end } => { let mut bp = BezPath::new(); - bp.move_to(g.point(10)); - bp.line_to(g.point(11)); + bp.move_to(point(*start)); + bp.line_to(point(*end)); path(bp) } - "CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))), - "ARC" => { - let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0)); - let sweep = ccw_sweep(start, end); - path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g))) + model::EntityKind::Circle { center, radius } => path(transformed(curve::arc(point(*center), *radius, 0.0, std::f64::consts::TAU)?, ocs)), + model::EntityKind::Arc { center, radius, start_angle, end_angle } => { + let sweep = ccw_sweep(*start_angle, *end_angle); + path(transformed(curve::arc(point(*center), *radius, start_angle.to_radians(), sweep.to_radians())?, ocs)) } - "ELLIPSE" => path(ellipse(g)?), - "LWPOLYLINE" => lwpolyline(g), - "POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])), - "SPLINE" => path(spline(g)?), - "SOLID" | "TRACE" => { - let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; + model::EntityKind::Ellipse { center, major, ratio, start, end } => { + path(ellipse(m.common.extrusion, *center, *major, *ratio, *start, *end)?) + } + model::EntityKind::LwPolyline(p) => lwpolyline(p, ocs), + model::EntityKind::Polyline(p) => self.polyline(p, ocs), + model::EntityKind::Spline(s) => path(spline(s)?), + model::EntityKind::Solid { corners } | model::EntityKind::Trace { corners } => { + let [a, b, c, d] = corners.map(point); // The corners zigzag: the outline runs 1, 2, 4, 3. - Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 }) + Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs), fill: true, width: 0.0 }) } - "3DFACE" => { - let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))]; - path(polygon(&[a, b, c, d])) - } - "HATCH" => self.hatch(g), - "TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text), - "MTEXT" => mtext(g).map(Geom::Text), - "INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])), + model::EntityKind::Face3d { corners, .. } => path(polygon(&corners.map(point))), + model::EntityKind::Hatch(h) => self.hatch(h, ocs), + model::EntityKind::Text(t) => text(t, &g, false, ocs).map(Geom::Text), + model::EntityKind::MText(t) => mtext(t, e.tags).map(Geom::Text), + model::EntityKind::Insert(ins) => self.insert(ins, &e.followers, ocs), // A dimension draws the anonymous block of its lines, arrows and text. - "DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })), + model::EntityKind::Dimension(d) => { + Some(Geom::Insert(InsertItem { block: d.block.as_ref()?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })) + } _ => None, } } - fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { - let flags = g.int_or(70, 0); - if flags & (16 | 64) != 0 { + fn polyline(&mut self, p: &model::Polyline, ocs: Affine) -> Option { + if p.is_mesh() || p.is_polyface() { self.warn(MESHES); return None; } // Spline frame control points and polyface faces aren't on the line. - let verts: Vec = rest - .iter() - .filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0) - .map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) }) - .collect(); - let bp = curve::polyline(&verts, flags & 1 != 0)?; + let verts: Vec = + p.vertices.iter().filter(|v| v.flags & (16 | 128) == 0).map(|v| Vertex { p: point(v.location), bulge: v.bulge }).collect(); + let bp = curve::polyline(&verts, p.closed())?; // 3D polylines are in world coordinates; 2D ones in their plane's. - let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) }; - Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) }) + let bp = if p.is_3d() { bp } else { transformed(bp, ocs) }; + Some(Geom::Path { bp, fill: false, width: p.start_width.max(0.0) }) } - fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option { - let block = g.name(2)?.to_uppercase(); - let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0)); + fn insert(&mut self, ins: &model::Insert, followers: &[model::Record<'_>], ocs: Affine) -> Option { + let block = Some(ins.block.trim()).filter(|b| !b.is_empty())?.to_uppercase(); + let [sx, sy, _] = ins.scale; if sx.abs() < 1e-12 || sy.abs() < 1e-12 { return None; } - let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians()); - let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1)); - let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0)); + let place = ocs * Affine::translate(point(ins.insert).to_vec2()) * Affine::rotate(ins.rotation.to_radians()); + let (cols, rows) = (ins.columns, ins.rows); + let (cs, rs) = (ins.column_spacing, ins.row_spacing); let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS { self.warn(BIG_ARRAY); (cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows)) @@ -312,27 +295,41 @@ impl Converter { .flat_map(|r| (0..cols).map(move |c| (r, c))) .map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy)) .collect(); - let attribs = self.items(rest); + let attribs = self.attribs(followers); Some(Geom::Insert(InsertItem { block, cells, attribs })) } - fn hatch(&mut self, g: &[Pair<'_>]) -> Option { - let start = g.iter().position(|p| p.code == 91)?; - let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize; - let mut c = Cursor { g, i: start + 1 }; + /// An insert's attributes as text. + fn attribs(&mut self, followers: &[model::Record<'_>]) -> Vec { + let mut out = Vec::new(); + for r in followers { + let c = model::Common::read(r.tags); + if !c.visible { + continue; + } + let a = model::Attrib::read("ATTRIB", r.tags); + match text(&a.text, &r.groups(), true, ocs(c.extrusion)) { + Some(t) if !in_range(&Geom::Text(t.clone())) => self.warn(FAR_AWAY), + Some(t) => out.push(Item { props: Props::read(&c), geom: Geom::Text(t) }), + None => {} + } + } + out + } + + fn hatch(&mut self, h: &model::Hatch, ocs: Affine) -> Option { let mut bp = BezPath::new(); - for _ in 0..count { - let Some(path) = boundary(&mut c) else { break }; + for l in &h.loops { + let Some(path) = boundary(&l.path) else { break }; bp.extend(path); } if bp.elements().is_empty() { return None; } - let bp = transformed(bp, ocs(g)); - let solid = g.int_or(70, 0) == 1; - if g.int_or(450, 0) == 1 { + let bp = transformed(bp, ocs); + if h.gradient.is_some() { self.warn(GRADIENT_HATCH); - } else if !solid { + } else if !h.solid { self.warn(PATTERN_HATCH); return Some(Geom::Path { bp, fill: false, width: 0.0 }); } @@ -401,217 +398,104 @@ fn polygon(pts: &[Point]) -> BezPath { bp } -/// The extrusion direction (210, 220, 230) of a planar entity, normalised. -fn extrusion(g: &[Pair<'_>]) -> [f64; 3] { - let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)]; - let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); - if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] } -} - fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } -fn unit(v: [f64; 3]) -> [f64; 3] { - let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - if len > 1e-12 { v.map(|x| x / len) } else { v } -} - /// A planar entity's coordinate system seen from above: its x and y axes in world space by the /// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it /// mirrors x). -fn ocs(g: &[Pair<'_>]) -> Affine { - let n = extrusion(g); - if n[2] > 1.0 - 1e-12 { +fn ocs(extrusion: model::Point) -> Affine { + let [ax, ay, _] = model::ocs_axes(extrusion); + if ax == [1.0, 0.0, 0.0] && ay == [0.0, 1.0, 0.0] { return Affine::IDENTITY; } - let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) }); - let ay = unit(cross(n, ax)); Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0]) } -fn ellipse(g: &[Pair<'_>]) -> Option { - let centre = g.point(10); - let major = g.point(11).to_vec2(); - let ratio = g.num_or(40, 1.0); +fn ellipse(extrusion: model::Point, centre: model::Point, major: model::Point, ratio: f64, start: f64, end: f64) -> Option { + let centre = point(centre); + let major = point(major).to_vec2(); if ratio <= 0.0 || major.hypot() < 1e-12 { return None; } // The minor axis is the major one turned a quarter about the extrusion direction. - let n = extrusion(g); + let n = model::ocs_axes(extrusion)[2]; let m = cross(n, [major.x, major.y, 0.0]); let minor = Vec2::new(m[0], m[1]) * ratio; - let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU)); let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians(); curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep) } -fn lwpolyline(g: &[Pair<'_>]) -> Option { - let mut verts: Vec = Vec::new(); - let (mut widths, mut uniform) = (None, true); - for p in g { - match p.code { - 10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }), - 20 => verts.last_mut()?.p.y = number(p.value)?, - 42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0), - 40 | 41 => { - let w = number(p.value).unwrap_or(0.0); - uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12); - widths = Some(w); - } - _ => {} - } - } - let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g)); - let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0); +fn lwpolyline(p: &model::LwPolyline, ocs: Affine) -> Option { + let verts: Vec = p.vertices.iter().map(|v| Vertex { p: Point::new(v.x, v.y), bulge: v.bulge }).collect(); + let bp = transformed(curve::polyline(&verts, p.closed)?, ocs); + // One width for every vertex is the line's width. + let widths: Vec = p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]).collect(); + let uniform = widths.first().filter(|w| widths.iter().all(|x| (x - *w).abs() < 1e-12)).copied(); + let width = Some(p.const_width).filter(|w| *w > 0.0).or(uniform).unwrap_or(0.0).max(0.0); Some(Geom::Path { bp, fill: false, width }) } -/// The points at `code` (x) and `code + 10` (y), in order. -fn points(g: &[Pair<'_>], code: i32) -> Option> { - let mut out: Vec = Vec::new(); - for p in g { - if p.code == code { - out.push(Point::new(number(p.value)?, 0.0)); - } else if p.code == code + 10 { - out.last_mut()?.y = number(p.value)?; - } - } - Some(out) -} - -fn spline(g: &[Pair<'_>]) -> Option { - let flags = g.int_or(70, 0); - let ctrl = points(g, 10)?; - let degree = usize::try_from(g.int_or(71, 3)).ok()?; - let knots: Vec = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::>()?; - let weights: Vec = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect(); - let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice()); - curve::nurbs(degree, &knots, &ctrl, weights) +fn spline(s: &model::Spline) -> Option { + let ctrl: Vec = s.control.iter().map(|p| point(*p)).collect(); + let degree = usize::try_from(s.degree).ok()?; + let weights = (s.flags & 4 != 0 && s.weights.len() == ctrl.len()).then_some(s.weights.as_slice()); + curve::nurbs(degree, &s.knots, &ctrl, weights) .map(|mut path| { - if flags & 1 != 0 { + if s.flags & 1 != 0 { path.close_path(); } path }) - .or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0)) -} - -/// Reads a hatch's boundary groups in order. -struct Cursor<'g, 'a> { - g: &'g [Pair<'a>], - i: usize, -} - -impl Cursor<'_, '_> { - /// The next group's value when it has `code` (consumed). - fn opt(&mut self, code: i32) -> Option<&str> { - let p = self.g.get(self.i).filter(|p| p.code == code)?; - self.i += 1; - Some(p.value) - } - fn num(&mut self, code: i32) -> Option { - self.opt(code).and_then(number) - } - fn count(&mut self, code: i32) -> Option { - self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize) - } - fn point(&mut self, code: i32) -> Option { - Some(Point::new(self.num(code)?, self.num(code + 10)?)) - } + .or_else(|| curve::through(&s.fit.iter().map(|p| point(*p)).collect::>(), s.flags & 1 != 0)) } /// One hatch boundary loop, closed. -fn boundary(c: &mut Cursor<'_, '_>) -> Option { - let flags = c.num(92)? as i64; - let mut bp = BezPath::new(); - if flags & 2 != 0 { - let bulges = c.num(72).unwrap_or(0.0) != 0.0; - let _closed = c.opt(73); - let n = c.count(93)?; - let mut verts = Vec::with_capacity(n.min(4096)); - for _ in 0..n { - let p = c.point(10)?; - let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 }; - verts.push(Vertex { p, bulge }); +fn boundary(path: &HatchPath) -> Option { + let xy = |p: &[f64; 2]| Point::new(p[0], p[1]); + match path { + HatchPath::Polyline { vertices, .. } => { + let verts: Vec = vertices.iter().map(|[x, y, b]| Vertex { p: Point::new(*x, *y), bulge: *b }).collect(); + curve::polyline(&verts, true) } - bp = curve::polyline(&verts, true)?; - } else { - let n = c.count(93)?; - for _ in 0..n { - let edge = edge(c)?; - append(&mut bp, &edge); - } - if bp.elements().is_empty() { - return None; - } - bp.close_path(); - } - // The boundary objects it was made from. - if let Some(n) = c.count(97) { - for _ in 0..n { - if c.opt(330).is_none() { - break; - } - } - } - Some(bp) -} - -/// One edge of a hatch boundary. -fn edge(c: &mut Cursor<'_, '_>) -> Option { - match c.num(72)? as i64 { - 1 => { + HatchPath::Edges(edges) => { let mut bp = BezPath::new(); - bp.move_to(c.point(10)?); - bp.line_to(c.point(11)?); + for e in edges { + let seg = match e { + HatchEdge::Line { start, end } => { + let mut bp = BezPath::new(); + bp.move_to(xy(start)); + bp.line_to(xy(end)); + Some(bp) + } + HatchEdge::Arc { center, radius, start_angle, end_angle, ccw } => { + let sweep = ccw_sweep(*start_angle, *end_angle); + // Clockwise arcs have their angles measured clockwise. + let (start, sweep) = if *ccw { (*start_angle, sweep) } else { (-start_angle, -sweep) }; + curve::arc(xy(center), *radius, start.to_radians(), sweep.to_radians()) + } + HatchEdge::Ellipse { center, major, ratio, start_angle, end_angle, ccw } => { + let (centre, major) = (xy(center), xy(major).to_vec2()); + let minor = Vec2::new(-major.y, major.x) * *ratio * if *ccw { 1.0 } else { -1.0 }; + let sweep = ccw_sweep(*start_angle, *end_angle).to_radians(); + curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start_angle.to_radians(), sweep) + } + HatchEdge::Spline { degree, rational, knots, control, weights, fit, .. } => { + let ctrl: Vec = control.iter().map(xy).collect(); + let degree = usize::try_from(*degree).ok()?; + let weights = (*rational && weights.len() == ctrl.len()).then_some(weights.as_slice()); + curve::nurbs(degree, knots, &ctrl, weights).or_else(|| curve::through(&fit.iter().map(xy).collect::>(), false)) + } + }; + append(&mut bp, &seg?); + } + if bp.elements().is_empty() { + return None; + } + bp.close_path(); Some(bp) } - 2 => { - let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?); - let ccw = c.num(73).unwrap_or(1.0) != 0.0; - let sweep = ccw_sweep(start, end); - // Clockwise arcs have their angles measured clockwise. - let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) }; - curve::arc(centre, r, start.to_radians(), sweep.to_radians()) - } - 3 => { - let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?); - let ccw = c.num(73).unwrap_or(1.0) != 0.0; - let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 }; - let sweep = ccw_sweep(start, end).to_radians(); - curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep) - } - 4 => { - let degree = c.count(94)?; - let rational = c.num(73).unwrap_or(0.0) != 0.0; - let _periodic = c.opt(74); - let (nk, nc) = (c.count(95)?, c.count(96)?); - let knots: Vec = (0..nk).map(|_| c.num(40)).collect::>()?; - let mut ctrl = Vec::with_capacity(nc.min(4096)); - let mut weights = Vec::with_capacity(nc.min(4096)); - for _ in 0..nc { - ctrl.push(c.point(10)?); - weights.push(c.num(42).unwrap_or(1.0)); - } - // Fit data (DXF 2010 and later): a count, then that many points. Older files have none, - // and the 97 that may follow is the boundary's count of source objects (then their - // handles), so it is only the fit count when it is 0 or points follow. - let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) { - (Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11), - _ => false, - }; - let fit: Vec = match fit_count.then(|| c.count(97)).flatten() { - Some(n) => (0..n).map(|_| c.point(11)).collect::>()?, - None => vec![], - }; - // Start and end tangents. - for code in [12, 13] { - c.point(code); - } - curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false)) - } - _ => None, } } @@ -643,18 +527,21 @@ fn baseline(v: i64, height: f64) -> f64 { } } -fn text(g: &[Pair<'_>], attrib: bool) -> Option { - if attrib && g.int_or(70, 0) & 1 != 0 { +/// TEXT, or an ATTRIB's text. The string is read from group 1 as written, so that the caret and +/// percent codes are turned into text once, by `super::text`. +fn text(t: &model::Text, g: &Groups<'_>, attrib: bool, ocs: Affine) -> Option { + if attrib && g.i_or(70, 0) & 1 != 0 { return None; } - let s = super::text::plain(g.raw(1)?); + let s = super::text::plain(&g.s(1)?); if s.trim().is_empty() { return None; } - let height = g.num(40).filter(|h| *h > 0.0)?; - let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0)); - let (p1, p2) = (g.point(10), g.point_opt(11)); - let mut angle = g.num_or(50, 0.0).to_radians(); + let height = Some(t.height).filter(|h| *h > 0.0)?; + // An ATTRIB's 73 is its field length: its vertical alignment is 74. + let (h, v) = (t.halign, if attrib { g.i_or(74, 0) } else { t.valign }); + let (p1, p2) = (point(t.insert), t.align.map(point)); + let mut angle = t.rotation.to_radians(); let mut span = None; // Aligned and fit text run from the first point to the second. let anchor = match (h, p2) { @@ -674,33 +561,36 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option { _ => Justify::Left, }; let v = if h == 4 { 2 } else { v }; - let flags = g.int_or(71, 0); + let flags = t.generation; let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 }); - let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height))); + let xf = ocs * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height))); Some(TextItem { text: s, height, xf, - width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0), + width: Some(t.width_factor).filter(|w| *w > 0.0).unwrap_or(1.0), justify, - style: g.name(7).unwrap_or("STANDARD").to_uppercase(), + style: t.style.to_uppercase(), family: None, leading: None, span, }) } -fn mtext(g: &[Pair<'_>]) -> Option { - // The text comes in 250-character chunks (3) before the last (1). - let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect(); +/// MTEXT. The contents are read from groups 3 and 1 as written, as for [`text`]. +fn mtext(t: &model::MText, tags: &[craft_dxf::Tag]) -> Option { + // The text comes in 250-character chunks (3) before the last (1), up to an embedded object. + let (own, _) = model::text::split_embedded(tags); + let g = Groups(own); + let raw: String = own.iter().filter(|t| t.code == 3).map(craft_dxf::Tag::str).chain(g.s(1)).collect(); let (s, family) = super::text::mtext(&raw); if s.trim().is_empty() { return None; } - let height = g.num(40).filter(|h| *h > 0.0)?; - let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0); + let height = Some(t.height).filter(|h| *h > 0.0)?; + let leading = height * LINE_SPACING * Some(t.line_spacing).filter(|f| *f > 0.0).unwrap_or(1.0); let lines = s.lines().count().max(1) as f64; - let attach = g.int_or(71, 1).clamp(1, 9) - 1; + let attach = i64::from(t.attach) - 1; let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default(); // The first baseline below the attachment point: under the top, around the middle, above // the bottom of the lines. @@ -710,18 +600,6 @@ fn mtext(g: &[Pair<'_>]) -> Option { 1 => total / 2.0 - height, _ => (lines - 1.0) * leading, }; - let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12); - let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2()); - let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first)); - Some(TextItem { - text: s, - height, - xf, - width: 1.0, - justify, - style: g.name(7).unwrap_or("STANDARD").to_uppercase(), - family, - leading: Some(leading), - span: None, - }) + let xf = Affine::translate(point(t.insert).to_vec2()) * Affine::rotate(t.angle()) * Affine::translate((0.0, first)); + Some(TextItem { text: s, height, xf, width: 1.0, justify, style: t.style.to_uppercase(), family, leading: Some(leading), span: None }) } diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs index 0da950ead1..762cfdb721 100644 --- a/crates/cad/src/import/reader.rs +++ b/crates/cad/src/import/reader.rs @@ -1,156 +1,14 @@ -//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its -//! sections (header variables; the layer, linetype, text style and block record tables; the -//! blocks; the entities; the layouts). +//! What the import uses of a drawing, from `craft_dxf::model`: header variables; the layer, +//! linetype and text style tables; the blocks; the entities; the layouts. -use std::borrow::Cow; use std::collections::HashMap; +use craft_dxf::model::{self, EntityRecord}; use vectorcraft_geom::Point; -/// The most group-code pairs a drawing may hold (about 300 MB of DXF). -const MAX_PAIRS: usize = 20_000_000; - /// The first bytes of a binary DXF file (its format's signature). pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00"; -/// One group: its code and its value as written. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Pair<'a> { - pub code: i32, - pub value: &'a str, -} - -/// Values of a run of pairs by group code. -pub(crate) trait Groups<'a> { - /// The value of the first `code`, as written. - fn raw(&self, code: i32) -> Option<&'a str>; - /// The first `code` as a name (trimmed, never empty). - fn name(&self, code: i32) -> Option<&'a str> { - self.raw(code).map(str::trim).filter(|s| !s.is_empty()) - } - /// The first `code` as a finite number. - fn num(&self, code: i32) -> Option { - self.raw(code).and_then(number) - } - fn num_or(&self, code: i32, default: f64) -> f64 { - self.num(code).unwrap_or(default) - } - /// The first `code` as a whole number. - fn int(&self, code: i32) -> Option { - self.raw(code).and_then(integer) - } - fn int_or(&self, code: i32, default: i64) -> i64 { - self.int(code).unwrap_or(default) - } - /// The point at `code` (x) and `code + 10` (y); 0 for what is missing. - fn point(&self, code: i32) -> Point { - Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0)) - } - /// [`Groups::point`] when its x is given. - fn point_opt(&self, code: i32) -> Option { - self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0))) - } -} - -impl<'a> Groups<'a> for [Pair<'a>] { - fn raw(&self, code: i32) -> Option<&'a str> { - self.iter().find(|p| p.code == code).map(|p| p.value) - } -} - -/// A finite number (`1e999`, `NaN` and junk are none). -pub(crate) fn number(s: &str) -> Option { - s.trim().parse::().ok().filter(|v| v.is_finite()) -} - -/// A whole number; some writers put integers as `1.0`. -pub(crate) fn integer(s: &str) -> Option { - let s = s.trim(); - s.parse::().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64)) -} - -/// One object: its type (the value of its `0` group) and the groups up to the next object. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Record<'a> { - pub kind: &'a str, - pub g: &'a [Pair<'a>], -} - -/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions -/// write (a byte order mark is dropped). -pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> { - let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); - match std::str::from_utf8(bytes) { - Ok(s) => Cow::Borrowed(s), - Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()), - } -} - -/// A byte of the Windows Western code page. -fn windows_1252(b: u8) -> char { - const HIGH: [char; 32] = [ - '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', - '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ', - ]; - match b { - 0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'), - _ => char::from(b), - } -} - -/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the -/// reading: what came before is kept and the second value says where the file is damaged. -pub(crate) fn pairs(text: &str) -> Result<(Vec>, Option), String> { - let mut out = Vec::new(); - let mut lines = text.lines().enumerate(); - let mut damaged = None; - while let Some((n, code)) = lines.next() { - let c = code.trim(); - if c.is_empty() && out.is_empty() { - continue; - } - let Ok(code) = c.parse::() else { - damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1)); - break; - }; - let value = lines.next().map_or("", |(_, v)| v); - if code == 0 && value.trim() == "EOF" { - break; - } - if out.len() >= MAX_PAIRS { - return Err("the drawing is too large to open".into()); - } - out.push(Pair { code, value }); - } - if out.is_empty() { - return Err("not a DXF drawing".into()); - } - Ok((out, damaged)) -} - -/// The objects in `pairs`: each from a `0` group up to the next. -fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec> { - let mut out = Vec::new(); - let mut start: Option = None; - for (i, p) in pairs.iter().enumerate() { - if p.code == 0 { - if let Some(s) = start { - out.extend(record(pairs, s, i)); - } - start = Some(i); - } - } - if let Some(s) = start { - out.extend(record(pairs, s, pairs.len())); - } - out -} - -fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option> { - let kind = pairs.get(start)?.value.trim(); - Some(Record { kind, g: pairs.get(start + 1..end)? }) -} - /// The header variables the import reads. #[derive(Clone, Debug, Default)] pub(crate) struct Header { @@ -186,7 +44,7 @@ pub(crate) struct LayerDef { pub(crate) struct BlockDef<'a> { pub name: String, pub base: Point, - pub records: Vec>, + pub records: Vec>, /// A reference to another drawing (its art isn't in this file). pub xref: bool, } @@ -218,97 +76,51 @@ pub(crate) struct Drawing<'a> { /// Blocks by upper-case name. pub blocks: HashMap>, /// The ENTITIES section. - pub entities: Vec>, + pub entities: Vec>, /// Model space first, then the paper layouts in tab order. pub layouts: Vec, /// How many sections the file has (none: not a drawing). pub sections: usize, } -impl<'a> Drawing<'a> { - /// Read the sections of `pairs`. - pub fn read(pairs: &'a [Pair<'a>]) -> Self { - let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() }; - let all = records(pairs); - let mut section: Option<&str> = None; - let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]); - for r in all { - match (r.kind, section) { - ("SECTION", _) => { - d.sections += 1; - section = r.g.name(2); - if section == Some("HEADER") { - d.header = header(r.g); - } - } - ("ENDSEC", _) => section = None, - (_, Some("TABLES")) => tables.push(r), - (_, Some("BLOCKS")) => blocks.push(r), - (_, Some("ENTITIES")) => d.entities.push(r), - (_, Some("OBJECTS")) => objects.push(r), - _ => {} - } - } - let mut block_records = HashMap::new(); - let mut table = ""; - for r in &tables { - match r.kind { - "TABLE" => table = r.g.name(2).unwrap_or(""), - "LAYER" if table == "LAYER" => { - if let Some(l) = layer(r.g) { - d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len()); - d.layers.push(l); - } - } - "LTYPE" if table == "LTYPE" => { - if let Some(name) = r.g.name(2) { - let pattern: Vec = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect(); - d.linetypes.insert(name.to_uppercase(), pattern); - } - } - "STYLE" if table == "STYLE" => { - if let Some(name) = r.g.name(2) { - d.styles.insert(name.to_uppercase(), font_family(r.g)); - } - } - "BLOCK_RECORD" if table == "BLOCK_RECORD" => { - if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) { - block_records.insert(h.to_uppercase(), name.to_uppercase()); - } - } - _ => {} - } - } - d.read_blocks(&blocks, &mut block_records); - d.layouts = layouts(&objects, &block_records, &d.entities); - d - } +/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black. +pub(crate) fn aci_paper(i: u8) -> [u8; 3] { + if i == 7 { [0; 3] } else { model::aci::rgb(i) } +} - fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap) { - let mut current: Option> = None; - for r in records { - match r.kind { - "BLOCK" => { - let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string(); - // R13+ blocks name their block record: layouts find their block through it. - if let Some(owner) = r.g.name(330) { - block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase()); - } - let xref = r.g.int_or(70, 0) & 4 != 0; - current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref }); - } - "ENDBLK" => { - if let Some(b) = current.take().filter(|b| !b.name.is_empty()) { - self.blocks.insert(b.name.to_uppercase(), b); - } - } - _ => { - if let Some(b) = &mut current { - b.records.push(*r); - } - } - } +impl<'a> Drawing<'a> { + /// What the import uses of `doc`. + pub fn read(doc: model::Document<'a>) -> Self { + let h = &doc.header; + let header = Header { + acadver: h.acadver().unwrap_or_default(), + insunits: h.insunits(), + measurement: h.measurement(), + ltscale: h.ltscale(), + lwdefault: i32::from(h.lwdefault()), + }; + let mut d = Drawing { header, sections: doc.sections, ..Drawing::default() }; + for l in doc.layers() { + d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len()); + d.layers.push(layer(l)); } + for lt in doc.linetypes() { + d.linetypes.insert(lt.name.to_uppercase(), lt.pattern.iter().map(|e| e.length).collect()); + } + for st in doc.text_styles().into_iter().filter(|s| !s.name.is_empty()) { + d.styles.insert(st.name.to_uppercase(), st.family().unwrap_or_default()); + } + let block_names = doc.block_names(); + d.layouts = layouts(&doc, &block_names); + for b in doc.blocks { + if b.name.is_empty() { + continue; + } + let def = BlockDef { name: b.name.to_string(), base: point(b.base()), xref: b.flags() & 4 != 0, records: b.entities }; + d.blocks.insert(b.name.to_uppercase(), def); + } + d.entities = doc.entities; + d } /// The layout named `name` (any case; `model` is model space). @@ -317,7 +129,7 @@ impl<'a> Drawing<'a> { } /// The entity records of `layout`. - pub fn layout_records(&self, layout: &LayoutDef) -> Vec> { + pub fn layout_records(&self, layout: &LayoutDef) -> Vec> { let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default(); // The active paper layout keeps its art in the ENTITIES section, flagged paper space. let (block_name, paper) = match &layout.block { @@ -325,7 +137,7 @@ impl<'a> Drawing<'a> { Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true), Some(b) => return block(b), }; - let mut out: Vec> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect(); + let mut out: Vec> = self.entities.iter().filter(|e| e.paper_space() == paper).cloned().collect(); out.extend(block(block_name)); out } @@ -336,111 +148,47 @@ impl<'a> Drawing<'a> { } } -/// Is an entity (its first record) in paper space? -fn in_paper_space(group: &[Record<'_>]) -> bool { - group.first().is_some_and(|r| r.g.int(67) == Some(1)) +pub(crate) fn point(p: model::Point) -> Point { + Point::new(p[0], p[1]) } -/// The entities of `records`, each with the records that belong to it (a polyline's vertices, -/// an insert's attributes, and the SEQEND closing them). -pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator]> { - let mut i = 0; - std::iter::from_fn(move || { - let start = i; - let head = records.get(start)?; - i += 1; - if matches!(head.kind, "POLYLINE" | "INSERT") { - while let Some(r) = records.get(i) { - match r.kind { - "VERTEX" | "ATTRIB" => i += 1, - "SEQEND" => { - i += 1; - break; - } - _ => break, - } - } - } - records.get(start..i) - }) -} - -fn header(g: &[Pair<'_>]) -> Header { - let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }; - let mut var = ""; - for p in g { - if p.code == 9 { - var = p.value.trim(); - continue; - } - match (var, p.code) { - ("$ACADVER", 1) => h.acadver = p.value.trim().to_string(), - ("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0), - ("$MEASUREMENT", 70) => h.measurement = integer(p.value), - ("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0), - ("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25), - _ => {} - } - } - h -} - -fn layer(g: &[Pair<'_>]) -> Option { - let name = g.name(2)?.to_string(); - let aci = g.int_or(62, 7); - let flags = g.int_or(70, 0); - let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs())); - Some(LayerDef { +fn layer(l: model::Layer) -> LayerDef { + let rgb = match l.color { + model::Color::Rgb(rgb) => rgb, + model::Color::Index(i) => aci_paper(i), + model::Color::ByLayer | model::Color::ByBlock => aci_paper(7), + }; + LayerDef { // CAD apps never plot this layer of dimension points. - plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"), - name, + plot: l.plot && !l.name.eq_ignore_ascii_case("Defpoints"), rgb, - hidden: aci < 0 || flags & 1 != 0, - locked: flags & 4 != 0, - linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(), - lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3), - }) -} - -/// A text style's font family: the one 2000-and-later files name, else the font file's name -/// (TrueType); CAD fonts (`.shx`) have none. -fn font_family(g: &[Pair<'_>]) -> String { - if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD") - && let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000)) - { - return f.to_string(); - } - let file = g.name(3).unwrap_or(""); - let path = std::path::Path::new(file); - let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - match ext.as_str() { - "ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), - _ => String::new(), + hidden: !l.on || l.frozen, + locked: l.locked, + linetype: l.linetype.to_uppercase(), + lineweight: i32::try_from(l.lineweight.to_dxf()).unwrap_or(-3), + name: l.name, } } -/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout -/// objects but with paper-space art has one paper layout. /// The most bytes of layout and block names the layouts keep together. Each layout keeps its own /// copy of its block's name, so many layouts naming one long block name multiplied it; real /// drawings have a few dozen layouts with short names. const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20; -fn layouts(objects: &[Record<'_>], block_records: &HashMap, entities: &[Record<'_>]) -> Vec { +/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout +/// objects but with paper-space art has one paper layout. +fn layouts(doc: &model::Document<'_>, block_names: &HashMap) -> Vec { let mut paper: Vec<(i64, LayoutDef)> = vec![]; let mut seen = std::collections::HashSet::new(); let mut name_bytes = 0usize; - for r in objects.iter().filter(|r| r.kind == "LAYOUT") { - // The layout's own groups follow its subclass marker. - let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]); - let Some(name) = own.name(1) else { continue }; - let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())); - if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) { + for l in doc.layouts() { + let block = l.block_record.and_then(|h| block_names.get(&h)).map(|n| n.to_uppercase()); + if l.name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) { continue; } let Some(block) = block else { continue }; - let key = name.to_ascii_lowercase(); - let cost = name.len() + block.len(); + let key = l.name.to_ascii_lowercase(); + let cost = l.name.len() + block.len(); // A layout past the allowance is skipped alone: a later short one is still offered, and a // skipped one reserves neither its name nor its bytes. if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES { @@ -448,22 +196,11 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap, enti } seen.insert(key); name_bytes += cost; - paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) })); + paper.push((l.tab_order, LayoutDef { name: l.name, block: Some(block) })); } paper.sort_by_key(|(order, _)| *order); - if paper.is_empty() && groups(entities).any(in_paper_space) { + if paper.is_empty() && doc.entities.iter().any(EntityRecord::paper_space) { paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) })); } std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect() } - -#[cfg(test)] -mod tests { - use super::integer; - - #[test] - fn integer_rejects_fractional_float_values() { - assert_eq!(integer("1.0"), Some(1)); - assert_eq!(integer("1.9"), None); - } -} diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index d28a48553d..67ceef03a0 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -997,7 +997,12 @@ fn binary_dwg_damaged_and_foreign_files() { binary.extend([0u8; 40]); assert!(is_dxf(&binary)); let e = import(&binary, &ImportOptions::default()).unwrap_err(); - assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}"); + assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}"); + // A binary DXF opens as its ASCII form does. + let ascii = entities(LINE); + let tags = craft_dxf::parse(&ascii).unwrap(); + let r = open(&craft_dxf::write_binary(&tags, true), &points()); + assert_eq!(leaves(&r.document).len(), 1); let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err(); assert!(e.contains("DWG"), "{e}"); for junk in [&b""[..], b"hello", b"", b"999\nnote\n"] { @@ -1166,9 +1171,8 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() { let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")]; let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect(); let bytes = entities(&e); - let text = reader::decode(&bytes); - let (pairs, _) = reader::pairs(&text).unwrap(); - let d = reader::Drawing::read(&pairs); + let tags = craft_dxf::parse(&bytes).unwrap(); + let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags)); let model = d.layout("Model").or(d.layouts.first()).unwrap(); let mut conv = entity::Converter::default(); let items = conv.items(&d.layout_records(model)); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 1443829806..66a6a36baa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,5 +1,5 @@ -//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an -//! HP-GL/2 plot file reader and writer. +//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and a reader on +//! `craft_dxf::model`, and an HP-GL/2 plot file reader and writer. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -11,7 +11,7 @@ //! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns, //! clipping) is approximated or left out, and each such loss comes back as a warning. //! -//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. +//! [`import()`] reads a DXF drawing (ASCII or binary, any version) into a document: see [`import`]. //! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 4860e4989a..ea3c962053 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -683,7 +683,7 @@ impl<'a> Scene<'a> { { // Symbols holding instances of symbols are written in full for each instance, // empty or not: every instance's art counts against one budget for the export. - let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); let count = art.count_with_masks() as u64; // Counted as `place_symbol_art_unclipped` places it: no clip groups. let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc)); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 96c037a4a7..a14af8d929 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1395,6 +1395,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } +/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it. +#[test] +fn a_stained_instance_is_written_in_its_stained_color() { + let mut d = doc_with(|d| { + let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) }); + n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into(); + vec![n] + }); + let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) }); + let out = export(&d, &opts(&d)).unwrap(); + let colors: Vec> = entities(&out).iter().map(|e| get(e, 420).map(str::to_string)).collect(); + assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 43074a4e80..d561663524 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -363,6 +363,14 @@ pub struct SeriesPaint { /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub blend: Option, + /// A gradient in [`Self::fill`] is placed relative to the unit box (0, 0)–(1, 1), and each mark takes it on its own + /// geometric bounds. False: the gradient is in document coordinates, and every mark takes it unchanged. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub fill_unit_box: bool, + /// A gradient in [`Self::stroke`] is placed relative to the unit box, as [`Self::fill_unit_box`], and each mark takes + /// it on its stroke's box (the geometric bounds grown by half the weight). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_unit_box: bool, } /// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index e524d6ca37..a2ff76d83f 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -3,6 +3,8 @@ //! square to it: scaling by a side handle stretches it along its own width or height. Path //! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0. +use std::sync::Arc; + use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt}; use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes}; @@ -84,7 +86,35 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 { } } +/// The turn (counter-clockwise degrees) from the boxes of art placed by a symbol instance's +/// transform `xf` to the instance's own box at `angle`: 0 while the box turns with the transform, +/// otherwise the change Reset Bounding Box made to it. Under a mirroring `xf` the instance's box can +/// stand 180° from the one `xf` gives in one step ([`transformed_angle`] keeps the angle nearer the +/// one before at each step); the turn is then 180°, so the art's boxes match the instance's. +pub fn placed_turn(angle: f64, xf: Affine) -> f64 { + let turn = normalize_deg(angle - transformed_angle(0.0, xf)); + if turn.is_finite() && turn.abs() > EPS_DEG { turn } else { 0.0 } +} + impl Node { + /// Turns the box of this object, and of every object that turns with it ([`Node::transform`]: + /// its children and the art of a linked opacity mask), by `by` degrees counter-clockwise. + /// Layers have no box. + pub fn turn_boxes(&mut self, by: f64) { + if !self.is_layer() { + let a = normalize_deg(self.bbox_angle + by); + self.bbox_angle = if a.abs() <= EPS_DEG { 0.0 } else { a }; + } + for c in self.children_mut().into_iter().flatten() { + Arc::make_mut(c).turn_boxes(by); + } + if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + Arc::make_mut(&mut m.art).turn_boxes(by); + } + } + /// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes), /// without changing it, symbol instances measured by their art from `symbols` /// ([`Node::geometric_bounds_with`]). @@ -185,6 +215,44 @@ mod tests { assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0); } + #[test] + fn placed_turn_is_what_reset_bounding_box_took_off_the_instance() { + let xf = Affine::translate((40.0, 10.0)) * rot(20.0) * Affine::scale_non_uniform(2.0, 0.5); + // The box turns with the transform: no turn. + assert_eq!(placed_turn(transformed_angle(0.0, xf), xf), 0.0); + assert_eq!(placed_turn(0.0, Affine::scale(3.0)), 0.0); + // Reset to 0: the placed art turns back by the 20°. + assert!(close(placed_turn(0.0, xf), -20.0)); + // Mirrored: turned 170° and then flipped, the instance's box stands 180° from the box the + // whole transform gives in one step, and the art turns the half turn to match it. + let flip = Affine::scale_non_uniform(1.0, -1.0); + let flipped = flip * rot(170.0); + let stepwise = transformed_angle(transformed_angle(0.0, rot(170.0)), flip); + assert!(close(stepwise, -170.0) && close(transformed_angle(0.0, flipped), 10.0)); + assert!(close(placed_turn(stepwise, flipped).abs(), 180.0)); + assert!(close(normalize_deg(placed_turn(0.0, flipped) + transformed_angle(0.0, flipped)), 0.0)); + // Degenerate and non-finite transforms turn nothing. + assert_eq!(placed_turn(0.0, Affine::scale(0.0)), 0.0); + assert_eq!(placed_turn(f64::NAN, xf), 0.0); + } + + #[test] + fn turn_boxes_turns_children_and_linked_mask_art_but_not_layers() { + let mut d = Document::new(500.0, 500.0); + let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(a.clone())]); + g.bbox_angle = 170.0; + g.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(a.clone(), true))); + let mut layer = (*d.layers[0]).clone(); + layer.children_mut().unwrap().push(Arc::new(g)); + layer.turn_boxes(20.0); + assert_eq!(layer.bbox_angle, 0.0); + let g = &layer.children().unwrap()[0]; + assert!(close(g.bbox_angle, -170.0), "wraps to (-180, 180]"); + assert!(close(g.children().unwrap()[0].bbox_angle, 20.0)); + assert!(close(g.extras().mask.as_ref().unwrap().art.bbox_angle, 20.0)); + } + #[test] fn oriented_bounds_hug_a_rotated_rectangle() { let mut d = Document::new(500.0, 500.0); diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 53f84e869c..3c8c1b324d 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -614,6 +614,8 @@ impl NineSliceMap { /// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of /// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed /// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`]. + /// The clip groups and clip paths take the angle of the cell map that places them. The group + /// and each copy keep `n`'s box angle, turned by the map that places them. /// /// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own /// effects, after its last other visible effect, go on that group with its opacity, blending @@ -647,11 +649,15 @@ impl NineSliceMap { area.transform(local); let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false }); clip.appearance = Appearance::default().into(); + clip.bbox_angle = crate::orient::transformed_angle(0.0, local); let copy = transformed(n, local * self.natural); - cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }))); + let mut cell = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }); + cell.bbox_angle = crate::orient::transformed_angle(0.0, local); + cells.push(Arc::new(cell)); } } let mut out = Node::group(n.id, cells); + out.bbox_angle = crate::orient::transformed_angle(n.bbox_angle, center); out.visible = n.visible; out.locked = n.locked; out.name = n.name.clone(); diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs index 33f0fd7695..241c61fba8 100644 --- a/crates/doc/src/symbol_overrides.rs +++ b/crates/doc/src/symbol_overrides.rs @@ -2,14 +2,15 @@ //! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays //! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops //! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and -//! applied wherever an instance is drawn or exported ([`dynamic_art`]). +//! applied wherever an instance is drawn or exported ([`dynamic_art`]), and an instance's fill +//! stains the art after them ([`instance_art`]). use std::sync::Arc; use serde::{Deserialize, Serialize}; -use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; -use crate::{Node, NodeKind}; +use crate::{AppearanceItem, Node, NodeKind}; /// Deepest object an override may name: groups nested this deep in the symbol's art. pub const MAX_PATH_DEPTH: usize = 64; @@ -150,6 +151,52 @@ pub fn dynamic_art(art: &Node, inst: &Node) -> Node { a } +/// Mix every solid paint under `n` towards `color` by `amount` (0..1): the Symbol Stainer. +pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { + let amount = amount.clamp(0.0, 1.0); + for it in &mut n.appearance.items { + let p = match it { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Solid { color: c, .. } = p { + *p = Paint::solid(c.lerp(color, amount)); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + for p in [&mut r.style.fill, &mut r.style.stroke] { + if let Paint::Solid { color: c, .. } = p { + *p = Paint::solid(c.lerp(color, amount)); + } + } + } + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + tint_node(Arc::make_mut(c), color, amount); + } + } +} + +/// The stain instance `inst` puts on its symbol's art: its visible fill color and that fill's +/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one +/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links. +pub fn stain(inst: &Node) -> Option<(Color, f32)> { + let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?; + Some((f.paint.color()?, f.opacity)) +} + +/// A symbol's art for instance `inst`, as the canvas draws it: with the instance's own appearance +/// changes to it ([`dynamic_art`]), stained by the instance's fill ([`stain`]). +pub fn instance_art(art: &Node, inst: &Node) -> Node { + let mut a = dynamic_art(art, inst); + if let Some((c, amount)) = stain(inst) { + tint_node(&mut a, &c, amount); + } + a +} + /// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest. pub fn retain_parts(overrides: &mut Vec, art: &Node) { overrides.retain(|o| part(art, &o.path).is_some()); @@ -159,7 +206,6 @@ pub fn retain_parts(overrides: &mut Vec, art: &Node) { mod tests { use super::*; use crate::{Appearance, NodeId}; - use vectorcraft_color::Color; use vectorcraft_geom::{Rect, shapes}; fn square(id: u64) -> Arc { diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index 4193b085ef..1b9ac80452 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -523,13 +523,14 @@ fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) { /// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in /// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art` -/// when given, otherwise the symbol's art with the instance's own appearance changes. +/// when given, otherwise the symbol's art with the instance's own appearance changes, stained by +/// the instance's fill ([`vectorcraft_doc::symbol_overrides::instance_art`]). pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { let symbol = symbol_art - .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))); + .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n))); art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index fe1f355c9a..adbdf20b48 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, + has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, }; pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -58,6 +58,7 @@ pub fn needs_bake(n: &Node) -> bool { || has_geometry(&n.appearance.effects) || n.projection().is_some() || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) + || has_instance_row_raster(n) || n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c))) } @@ -142,15 +143,19 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } // Type, images, symbol instances and live objects: reshaped through their outlines, and so are // type whose own fills or strokes have geometry effects and images whose own fills or strokes - // paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays - // type, moved to each place. - if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) { + // paint a shape ([`has_item_geometry`]), and symbol instances whose active fill or stroke has a + // raster effect, which the fills and strokes of their art then carry + // ([`has_instance_row_raster`]). Type that Transform effects only move and copy stays type, + // moved to each place. + if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n)) { if let Some(m) = moved_type_group(n) { return Some(bake_pieces(d, m)); } + // A symbol instance's art as the canvas draws it: with its own appearance changes, stained + // by its fill. let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => { - d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)) + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n)) } _ => None, }; diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 6d1cb35ebd..d2ba694149 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -59,6 +59,46 @@ fn object_effects_become_geometry_and_raster_effects_stay() { assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); } +/// The fill colors of the paths under the layers of `d`, in paint order. +fn path_fills(d: &Document) -> Vec { + let mut out = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::Path { .. }) + && let Some(c) = n.appearance.fill_paint().color() + { + out.push(c); + } + }); + out +} + +/// A symbol instance half stained red is baked in its stained colors, as the canvas draws it: +/// moved by a Transform effect, and recolored by a color adjustment (Invert Colors) after the +/// stain. +#[test] +fn a_stained_instance_is_baked_in_its_stained_colors() { + let blue = Color::rgb8(30, 90, 220); + let art = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0)); + let mut inst = Node::new(NodeId(50), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: vectorcraft_geom::Affine::translate((20.0, 20.0)) }); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + inst.appearance.items.retain(|i| i.is_fill()); + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut inst.appearance.items[0] { + f.opacity = 0.5; + } + let stained = vectorcraft_doc::symbol_overrides::instance_art(&art, &inst).appearance.fill_paint().color().unwrap(); + assert_ne!(stained.to_hex(), blue.to_hex()); + let [r, g, b] = stained.to_rgb(); + let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b); + for (id, params, want) in [("distort.transform", json!({"moveH": 30.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] { + let mut n = inst.clone(); + n.appearance.effects.push(fx(id, params)); + let mut d = doc_with(n); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art.clone()) }); + let fills = path_fills(&bake_document(&d).unwrap()); + assert_eq!(fills.iter().map(Color::to_hex).collect::>(), [want.to_hex()], "{id}"); + } +} + #[test] fn a_baked_transform_moves_a_placed_gradient_with_the_path() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6316a7a273..c18a31d842 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,9 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, - has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, + has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, + reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index 2c02f886c0..5a878d42d3 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -23,15 +23,22 @@ pub fn text_outliner() -> Outliner<'static> { /// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance, /// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None` /// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and -/// Pathfinder operations the symbol instances among their members. +/// Pathfinder operations the symbol instances among their members. The group around the art takes +/// the instance's box angle, and the art's boxes turn with it, as Break Link turns them. pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option { match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { let doc = doc?; let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - let art = doc.place_symbol_art(symbol, &art, *xf)?; - Some(Node::group(n.id, vec![Arc::new(art)])) + let mut art = doc.place_symbol_art(symbol, &art, *xf)?; + let turn = vectorcraft_doc::orient::placed_turn(n.bbox_angle, *xf); + if turn != 0.0 { + art.turn_boxes(turn); + } + let mut out = Node::group(n.id, vec![Arc::new(art)]); + out.bbox_angle = n.bbox_angle; + Some(out) } _ => outline_text(n), } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 78d9ee522a..ee7d3f8322 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -162,15 +162,26 @@ fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> { n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0) } -/// The visible geometry effects in `fx`, in order. -fn geometry_of(fx: &[Effect]) -> Vec { - fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect() +/// Is `n` a symbol instance whose active fill or stroke has a visible raster effect (a shadow, glow, +/// blur, feather or other painted effect, [`crate::is_raster`])? Its art takes that effect on each +/// fill or stroke in it that paints ([`add_instance_row_effects`]), and draws it there. +pub fn has_instance_row_raster(n: &Node) -> bool { + let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && crate::is_raster(&e.id)); + matches!(n.kind, NodeKind::SymbolInstance { .. }) + && (active_fill(n).is_some_and(|f| raster(&f.effects)) || active_stroke(n).is_some_and(|s| raster(&s.effects))) } -/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active -/// fill added after the effects of every fill in it that paints, and those of its active stroke -/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each -/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are +/// The visible geometry and raster effects in `fx`, in order: those a symbol instance's active fill +/// or stroke passes on to its art. +fn row_effects_of(fx: &[Effect]) -> Vec { + fx.iter().filter(|e| e.visible && (is_geometry(&e.id) || crate::is_raster(&e.id))).cloned().collect() +} + +/// `art`, the art symbol instance `n` places, with the visible geometry and raster effects of +/// `n`'s active fill added after the effects of every fill in it that paints, and those of its +/// active stroke after the effects of every stroke in it that paints ([`active_fill`], +/// [`active_stroke`]): a Drop Shadow on the instance's fill shadows each filled shape in the art. +/// Each fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are /// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw. /// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol /// instance in the art takes the effects on its own active fill or stroke, or on a new fill or @@ -180,8 +191,8 @@ fn geometry_of(fx: &[Effect]) -> Vec { /// instance in the art takes them. fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) { let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return }; - let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects)); - let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects)); + let fill = active_fill(n).map_or(vec![], |f| row_effects_of(&f.effects)); + let stroke = active_stroke(n).map_or(vec![], |s| row_effects_of(&s.effects)); if !fill.is_empty() || !stroke.is_empty() { add_row_effects(doc, symbol, art, &fill, &stroke); } @@ -526,8 +537,8 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by /// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as /// the art its members draw when the instance has geometry effects of its own -/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the -/// instance's active fill and stroke ([`add_instance_row_effects`]). +/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry and raster +/// effects of the instance's active fill and stroke ([`add_instance_row_effects`]). /// `None` for other kinds (images, paths, containers), and for an instance whose art placed /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. @@ -719,12 +730,14 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping /// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object /// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective -/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, -/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is -/// always reshaped. The fills and strokes in the art whose own Transform effects reach their -/// appearance become objects of their own ([`transform_items`]). +/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts +/// or a symbol instance whose active fill or stroke has a raster effect +/// ([`has_instance_row_raster`]), or has no art (a symbol instance without `symbol_art`). A path +/// with [`has_transform_art`] is always reshaped. The fills and strokes in the art whose own +/// Transform effects reach their appearance become objects of their own ([`transform_items`]). pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { - if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) { + let own = !geometry_effects(n).is_empty() || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n); + if !has_transform_art(n) && (!own || !needs_outline(n)) { return None; } as_art(doc, n, symbol_art) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..ac9de02cad 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; use vectorcraft_pathops::BoolOp; -use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -656,7 +656,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { #[cfg(test)] thread_local! { - /// How many solids [`built_solids`] has built on this thread, for the tests. + /// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests. static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -2039,31 +2039,35 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); - // The solids the art draws, past the limits of the object's solids together as it is - // ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped. - let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); - // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel - // solid and its mesh are built as the loop reaches them. + // Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel + // solid is built once, as the loop reaches it: its facets decide whether the art draws it, as + // [`built_solids`] does, and its faces before projection make its mesh. let mut inflated = (fx.id == INFLATE).then(|| { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view) }); let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; - for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let mut kept = 0usize; + for (item, member, area, rule, _, _) in &parts { let item: &AppearanceItem = item; - if !drawn? { - // A stroke the art leaves out is left out of the meshes too; Inflate's next puff - // still belongs to the next part. - if let Some(built) = inflated.as_mut() { - built.next(); - } - continue; - } - let mesh = match inflated.as_mut() { - Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), - None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + let built = match inflated.as_mut() { + Some(each) => each.next().unwrap_or_else(|| Ok(MeshedSolid::default())), + None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view), }; - match mesh { + // A stroke the art leaves out is left out of the meshes too. The faces of the solids kept + // count together against the object's limit. + let solid = match built { + Ok(solid) => solid, + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue, + Err(e) => return Err(e), + }; + kept = kept.saturating_add(solid.facets.len()); + if kept > MAX_OBJECT_FACES { + return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()); + } + match solid.mesh() { Ok(mesh) => { let (paint, opacity, _) = paint_of(item, *member); keep(&mut out, mesh, mesh_color(&paint, opacity))?; diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index aeefafbc77..a9de7cd7f9 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -208,3 +208,151 @@ fn a_gradient_takes_the_average_of_its_stops() { let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY }; assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5])); } + +/// The meshes of Extrude & Bevel or Inflate `fx` on `shapes`, each built apart from the facets that +/// decide which solids are drawn ([`built_solids`], then [`vectorcraft_three_d::extrude_mesh`] or +/// [`vectorcraft_three_d::inflate_meshes`]), for comparing with [`meshes_for`]. +fn meshes_built_apart(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result, String> { + let p = crate::merged_params(&fx.id, &fx.params); + let view = view_options(&p); + let (parts, bounds) = solid_parts(shapes, union_bounds(shapes).unwrap()); + let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); + let mut inflated = (fx.id == INFLATE).then(|| { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + }); + let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; + let mut out = vec![]; + for ((item, opacity, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let item: &AppearanceItem = item; + if !drawn? { + if let Some(built) = inflated.as_mut() { + built.next(); + } + continue; + } + let mesh = match inflated.as_mut() { + Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), + None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + }; + match mesh { + Ok(mesh) => { + let (paint, opacity, _) = paint_of(item, *opacity); + keep_mesh(&mut out, total, SolidMesh { mesh, color: mesh_color(&paint, opacity) }, limit)?; + } + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// The bits of every number in `meshes`, for comparing two exports exactly. +type MeshBits = Vec<([u32; 4], Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>)>; + +fn mesh_bits(meshes: &[SolidMesh]) -> MeshBits { + meshes + .iter() + .map(|m| { + let (positions, normals) = (&m.mesh.positions, &m.mesh.normals); + ( + m.color.map(f32::to_bits), + positions.iter().map(|p| p.map(f64::to_bits)).collect(), + normals.iter().map(|n| n.map(f64::to_bits)).collect(), + m.mesh.triangles.clone(), + ) + }) + .collect() +} + +/// A five-pointed star about `(x, 150)` with curved edges, filled red and stroked black. +fn star_node(id: u64, x: f64) -> Node { + let mut b = BezPath::new(); + for k in 0..10 { + let (s, c) = (k as f64 * std::f64::consts::PI / 5.0).sin_cos(); + let r = if k % 2 == 0 { 60.0 } else { 25.0 }; + let p = (x + r * c, 150.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 4.0, p.1), (p.0, p.1 + 4.0), p) } + } + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 6.0)) +} + +/// The shapes `meshes_for` builds from: one node's, or each of several. +fn shapes_of(nodes: &[&Node]) -> Vec { + nodes + .iter() + .map(|n| Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }) + .collect() +} + +/// Building each Extrude & Bevel and Inflate solid once exports the same meshes, to the bit, as +/// building its facets and its mesh apart, within and past the triangle budget. +#[test] +fn one_build_exports_the_meshes_built_apart() { + let mut second_stroke = square_node(3, 400.0); + if let Some(stroke) = second_stroke.appearance.items.get(1).cloned() { + second_stroke.appearance.items.push(stroke); + } + let (star, square, other) = (star_node(1, 100.0), square_node(2, 250.0), star_node(4, 600.0)); + let cases = [ + (EXTRUDE, json!({})), + (EXTRUDE, json!({ "bevel": "rounded", "bevelWidth": 6.0, "bevelHeight": 5.0, "twist": 60.0, "taper": 70.0 })), + (EXTRUDE, json!({ "bevel": "cove", "bevelInside": true, "bevelBothSides": true, "cap": false, "perspectiveAngle": 50.0 })), + (EXTRUDE, json!({ "depth": 0.0 })), + (CLASSIC_EXTRUDE, json!({ "bevel": "classic", "bevelHeight": 8.0 })), + (INFLATE, json!({})), + (INFLATE, json!({ "depth": 30.0, "bothSides": true, "volume": 80.0 })), + (INFLATE, json!({ "depth": 40.0, "twist": 360.0, "taper": 70.0, "cap": false })), + (INFLATE, json!({ "depth": 0.1, "twist": 0.1, "bothSides": true })), + ]; + let sets: [Vec<&Node>; 3] = [vec![&star], vec![&second_stroke], vec![&star, &square, &other]]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + for nodes in &sets { + let shapes = shapes_of(nodes); + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, MAX_MESH_TRIANGLES).unwrap_or_else(|e| panic!("{id} {params}: {e}")); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, MAX_MESH_TRIANGLES).unwrap(); + assert!(!reference.is_empty()); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id} {params}"); + assert_eq!(once, apart); + // Past the budget, at the same mesh, with the same count. + let limit = once - 1; + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, limit); + assert!(built.is_err(), "{id} {params}"); + assert_eq!(built.err(), meshes_built_apart(&effect, &shapes, &mut apart, limit).err(), "{id} {params}"); + assert_eq!(once, apart, "{id} {params}"); + } + } +} + +/// A stroke whose solid is past the limits is left out of the export and the fill's mesh exports, +/// as when the facets and the mesh are built apart. +#[test] +fn a_stroke_past_the_limits_is_left_out_of_the_export() { + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) + }) + .collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + let n = Node::path(NodeId(1), path, Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 2.0)); + for id in [EXTRUDE, INFLATE] { + // Without caps: a cap of 40,000 corners takes more work to cut into triangles than an + // export allows. + let effect = fx(id, json!({ "cap": false })); + let shapes = shapes_of(&[&n]); + // The fill's mesh alone has more triangles than an export takes. + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, usize::MAX).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(built.len(), 1, "{id}: the fill's mesh alone"); + assert!(close(built[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}"); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, usize::MAX).unwrap(); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id}"); + } +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 5c04cb5deb..c43d383c19 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1571,6 +1571,27 @@ fn outline_object_is_a_geometry_effect_that_keeps_the_path() { assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b)); } +/// A symbol instance given to Pathfinder and compound shapes as its art: the group takes the +/// instance's box angle and the art turns with it, turned with the instance or reset to 0. +#[test] +fn outline_object_turns_an_instances_art_with_its_box() { + use std::sync::Arc; + + use vectorcraft_doc::orient::transformed_angle; + use vectorcraft_doc::{Appearance, Document, NodeId, Symbol}; + let mut d = Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: false, name: "s".into(), art: Arc::new(Node::path(NodeId(0), square(), Appearance::default())) }); + let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(0.35); + for box_angle in [transformed_angle(0.0, xf), 0.0] { + let mut inst = Node::new(NodeId(3), NodeKind::SymbolInstance { symbol: "s".into(), xf }); + inst.bbox_angle = box_angle; + let out = outline_object(Some(&d), &inst).unwrap(); + let mut angles = vec![]; + out.walk(&mut |c| angles.push(c.bbox_angle)); + assert!(angles.len() == 2 && angles.iter().all(|a| close(*a, box_angle, 1e-9)), "{box_angle}: {angles:?}"); + } +} + #[test] fn convert_to_shape_sizes() { let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap(); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 94d8add68b..19695fd4ff 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -440,6 +440,15 @@ pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), } } +/// The whole number of 0 or more that `p` gives for `key`. A missing key or null is "missing integer +/// `{key}`"; any other value, such as `"3"`, `2.5`, `-1` or `true`, is "`{key}` must be a whole +/// number", as [`object_param`] words it. +pub(crate) fn u64_req(p: &Value, key: &str, cmd: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Err(bad(cmd, format!("missing integer `{key}`"))), + Some(v) => v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), + } +} /// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other /// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}". pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result { @@ -550,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result usize { usize::try_from(v).unwrap_or(usize::MAX) } +/// The whole number of 0 or more that `p` gives for `key`, read with [`index`], or `None` when `p` +/// leaves it out or gives null. Any other value, such as `"3"`, `2.5`, `-1` or `true`, is an error +/// under `cmd`: "`{key}` must be a whole number". +pub(crate) fn index_opt(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), + } +} /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { checked_nodes_param(s, p, key, command, false) diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index f98880e204..a72075d233 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -713,6 +713,71 @@ fn corner_commands_refuse_bad_params() { } } +#[test] +fn corner_commands_refuse_an_unknown_item_key() { + let mut s = session(); + let id = square(&mut s); + let i = id.0; + let set = json!({"items": [{"id": i, "anchor": [0]}], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &set).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}"); + let set_late = json!({"items": [{"id": i, "anchors": [0]}, {"id": i, "radius": 3}], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &set_late).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[1].radius`; the command takes: items[1].id, items[1].anchors"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerSet", &[set, set_late]); + s.execute("addon.pathtools.cornerSet", &json!({"radius": 10})).unwrap(); + let remove = json!({"items": [{"id": i, "anchor": [0]}]}); + let e = s.execute("addon.pathtools.cornerRemove", &remove).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerRemove", &[remove]); + assert_eq!(drawn(&s, id, 0), 10.0); +} + +#[test] +fn corner_items_take_their_documented_keys() { + let mut s = session(); + let id = square(&mut s); + let i = id.0; + let r = s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i, "anchors": [0]}], "radius": 5})).unwrap(); + assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), 5.0, 0.0)); + // A member without `anchors` takes the request's. + s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i}], "anchors": [2], "radius": 6})).unwrap(); + assert_eq!(drawn(&s, id, 2), 6.0); + let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i, "anchors": [0]}]})).unwrap(); + assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 2)), (json!(1), 0.0, 6.0)); + // A null `ids` beside `items` counts as not given. + s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i}], "ids": null})).unwrap(); + assert_eq!(drawn(&s, id, 2), 0.0); +} + +#[test] +fn corner_commands_refuse_items_with_ids() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + let both = json!({"items": [{"id": id.0, "anchors": [0]}], "ids": [other.0], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &both).unwrap_err().to_string(); + assert!(e.contains("give `items` or `ids`, not both"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerSet", &[both]); + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "radius": 5})).unwrap(); + let both = json!({"items": [{"id": id.0}], "ids": [id.0]}); + let e = s.execute("addon.pathtools.cornerRemove", &both).unwrap_err().to_string(); + assert!(e.contains("give `items` or `ids`, not both"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerRemove", &[both]); +} + +#[test] +fn saved_corner_steps_with_older_item_keys_still_play() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + let saved = json!({"items": [{"id": id.0, "anchors": [0], "old": 1}], "ids": [other.0], "radius": 5}); + s.replay("addon.pathtools.cornerSet", &saved).unwrap(); + assert_eq!((drawn(&s, id, 0), drawn(&s, id, 1)), (5.0, 0.0)); + s.replay("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "old": 1}]})).unwrap(); + assert_eq!(drawn(&s, id, 0), 0.0); +} + /// A closed five-pointed star about (200, 150): its points at the even indices 0, 2… (the odd /// numbers counting from 1), its inner corners at the odd ones. fn star_path(s: &mut Session) -> NodeId { diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index 87549e3694..7fc935546e 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -9,6 +9,8 @@ #[cfg(test)] mod tests; +use std::ops::RangeInclusive; + use serde_json::{Value, json}; use vectorcraft_doc::{LiveShape, NodeId, NodeKind}; use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes}; @@ -16,9 +18,25 @@ use vectorcraft_tools::addons::box3d::CustomShape; use super::super::create::insert_look; use super::super::{ - CommandSpec, Result, Session, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, str_param, targets, + CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, number_or, str_param, + targets, }; +/// The sides of a polygon and the points of a star: whole numbers in this range. +const COUNTS: RangeInclusive = 3.0..=1000.0; +/// `rounded` and `randomized` of a polygon or star. +const ROUNDING: RangeInclusive = -10.0..=10.0; +/// A star's inner radius as a fraction of its outer radius. +const SPOKE_RATIO: RangeInclusive = 0.01..=1.0; +/// A spiral's `turns`. +const TURNS: RangeInclusive = 0.01..=1024.0; +/// A spiral's `divergence`. +const DIVERGENCE: RangeInclusive = 0.0..=1000.0; +/// A spiral's `innerRadius`, as a fraction of its radius. +const INNER_RADIUS: RangeInclusive = 0.0..=0.999; +/// The `arcMode` names; `slice` and `open` are also read as `pie` and `arc`. +const ARC_MODES: [&str; 4] = ["whole", "pie", "chord", "arc"]; + pub fn specs() -> Vec { vec![ cmd!( @@ -26,7 +44,7 @@ pub fn specs() -> Vec { "Rectangle", [], None, - "{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", + "{x, y, width, height: pt (0 or more), radius?: pt (0 or more), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rectangle ), @@ -35,7 +53,7 @@ pub fn specs() -> Vec { "Ellipse", [], None, - "{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", + "{x, y, width, height: pt (0 or more), startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, ellipse ), @@ -44,7 +62,7 @@ pub fn specs() -> Vec { "Polygon", [], None, - "{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}", + "{cx, cy, radius: pt (0 or more), sides=6: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}", has_doc, polygon ), @@ -53,7 +71,7 @@ pub fn specs() -> Vec { "Star", [], None, - "{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}", + "{cx, cy, radius1: pt (0 or more), radius2?: pt (0.01 to 1 times radius1; default half of it), points=5: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}", has_doc, star ), @@ -62,7 +80,7 @@ pub fn specs() -> Vec { "Spiral", [], None, - "{cx, cy, radius, turns=3, divergence=1, innerRadius=0, clockwise=true} → {id}", + "{cx, cy, radius: pt (0 or more), turns=3: 0.01 to 1024, divergence=1: 0 to 1000, innerRadius=0: 0 to 0.999 of radius, clockwise=true} → {id}", has_doc, spiral ), @@ -71,7 +89,7 @@ pub fn specs() -> Vec { "3D Box", [], None, - "{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}", + "{x, y, width, height: pt (0 or more), angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}", has_doc, box3d ), @@ -80,33 +98,96 @@ pub fn specs() -> Vec { "Configure Shape", [], None, - "{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}", + "{ids?, radius?: pt (0 or more; fitted to half each rectangle's shorter side), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?: a whole number 3 to 1000, isStar?, spokeRatio?: 0.01 to 1, rounded?, randomized?: -10 to 10, turns?: 0.01 to 1024, divergence?: 0 to 1000, innerRadius?: 0 to 0.999, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} an omitted or null value keeps each shape's setting → {updated}", has_selection, configure ), ] } -fn parse_arc_params(p: &Value) -> ((f64, f64), shapes::EllipseMode) { +/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. A number +/// outside `range` is an error that names `key` and the range. +fn ranged(p: &Value, key: &str, default: f64, range: &RangeInclusive, cmd: &str) -> Result { + let x = number_or(p, key, default, cmd)?; + if !range.contains(&x) { + let (min, max) = (range.start(), range.end()); + return Err(bad(cmd, format!("`{key}` must be between {min} and {max} (got {x})"))); + } + Ok(x) +} + +/// The number of sides or points `p` gives for `key`, or `default` when `p` leaves it out or gives +/// null. A fraction or a number outside [`COUNTS`] is an error. +fn count(p: &Value, key: &str, default: u32, cmd: &str) -> Result { + let x = number_or(p, key, f64::from(default), cmd)?; + if x.fract() != 0.0 || !COUNTS.contains(&x) { + let (min, max) = (COUNTS.start(), COUNTS.end()); + return Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})"))); + } + Ok(x as u32) +} + +/// The size `p` gives for `key`: a number that is not negative. +fn size(p: &Value, key: &str, cmd: &str) -> Result { + not_negative(f64_req(p, key, cmd)?, key, cmd) +} + +/// The size `p` gives for `key`, or `default` when `p` leaves it out or gives null. +fn size_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result { + not_negative(number_or(p, key, default, cmd)?, key, cmd) +} + +fn not_negative(x: f64, key: &str, cmd: &str) -> Result { + if x < 0.0 { + return Err(bad(cmd, format!("`{key}` must be a number not below 0"))); + } + Ok(x) +} + +/// The four corner radii `p` gives as `radii` ([tl, tr, br, bl]), or `None` when `p` leaves it out +/// or gives null. Anything but four numbers that are not negative is an error. +fn radii_param(p: &Value, cmd: &str) -> Result> { + let key = "radii"; + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let four = v.as_array().filter(|a| a.len() == 4).and_then(|a| { + let mut out = [0.0; 4]; + for (slot, item) in out.iter_mut().zip(a) { + *slot = item.as_f64().filter(|r| *r >= 0.0)?; + } + Some(out) + }); + four.map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be four numbers not below 0: [tl, tr, br, bl]"))) + } + } +} + +/// The mode `p` gives as `arcMode`, or `None` when `p` leaves it out or gives null. Any other +/// value than one of [`ARC_MODES`] (or `slice` or `open`) is an error. +fn arc_mode(p: &Value, cmd: &str) -> Result> { + let key = "arcMode"; + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(parse_mode).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be one of {}", ARC_MODES.join(", ")))), + } +} + +fn parse_arc_params(p: &Value, cmd: &str) -> Result<((f64, f64), shapes::EllipseMode)> { let start = f64_or(p, "startAngle", 0.0); let end = f64_or(p, "endAngle", 360.0); - let mode = match str_param(p, "arcMode") { - Some("pie" | "slice") => shapes::EllipseMode::Slice, - Some("chord") => shapes::EllipseMode::Chord, - Some("arc" | "open") => shapes::EllipseMode::Arc, - _ => shapes::EllipseMode::Whole, - }; + let mode = arc_mode(p, cmd)?.unwrap_or(shapes::EllipseMode::Whole); let span = (end - start).rem_euclid(360.0); let pie = if mode == shapes::EllipseMode::Whole && (span < 1e-6 || (span - 360.0).abs() < 1e-6) { (0.0, 360.0) } else { (start, end) }; - (pie, mode) + Ok((pie, mode)) } fn rect_of(p: &Value, c: &str) -> Result { let x = f64_req(p, "x", c)?; let y = f64_req(p, "y", c)?; - let w = f64_req(p, "width", c)?; - let h = f64_req(p, "height", c)?; - Ok(Rect::new(x, y, x + w.abs(), y + h.abs()).abs()) + let w = size(p, "width", c)?; + let h = size(p, "height", c)?; + Ok(Rect::new(x, y, x + w, y + h)) } fn rect_xf(r: Rect, rot: f64) -> Affine { @@ -141,18 +222,9 @@ fn rectangle(s: &mut Session, p: &Value) -> Result { let c = "addon.shapes.rectangle"; check_numbers(p, &["radius", "startAngle", "endAngle", "rotation"], c)?; let r = rect_of(p, c)?; - let radius = f64_or(p, "radius", 0.0).max(0.0); - let mut radii = [radius; 4]; - if let Some(arr) = p.get("radii").and_then(Value::as_array) - && arr.len() == 4 - { - for (i, item) in arr.iter().enumerate() { - if let Some(val) = item.as_f64().filter(|n| n.is_finite()) { - radii[i] = val.max(0.0); - } - } - } - let (pie, arc_mode) = parse_arc_params(p); + let radius = size_or(p, "radius", 0.0, c)?; + let radii = radii_param(p, c)?.unwrap_or([radius; 4]); + let (pie, arc_mode) = parse_arc_params(p, c)?; let xf = rect_xf(r, f64_or(p, "rotation", 0.0)); let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf }; let cs = CustomShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf, pie, arc_mode }; @@ -164,7 +236,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result { let c = "addon.shapes.ellipse"; check_numbers(p, &["startAngle", "endAngle", "rotation"], c)?; let r = rect_of(p, c)?; - let (pie, arc_mode) = parse_arc_params(p); + let (pie, arc_mode) = parse_arc_params(p, c)?; let xf = rect_xf(r, f64_or(p, "rotation", 0.0)); let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie, xf }; let cs = CustomShape::Ellipse { w: r.width(), h: r.height(), pie, arc_mode, xf }; @@ -174,13 +246,13 @@ fn ellipse(s: &mut Session, p: &Value) -> Result { fn polygon(s: &mut Session, p: &Value) -> Result { let c = "addon.shapes.polygon"; let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?); - let r = f64_req(p, "radius", c)?.abs(); + let r = size(p, "radius", c)?; check_numbers(p, &["sides", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?; - let sides = (f64_or(p, "sides", 6.0).round() as i64).clamp(3, 1000) as u32; + let sides = count(p, "sides", 6, c)?; let rot = f64_or(p, "rotation", 0.0); - let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0); - let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0); - let (pie, arc_mode) = parse_arc_params(p); + let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?; + let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?; + let (pie, arc_mode) = parse_arc_params(p, c)?; let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot); let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false }; let cs = CustomShape::Polygon { @@ -202,16 +274,17 @@ fn polygon(s: &mut Session, p: &Value) -> Result { fn star(s: &mut Session, p: &Value) -> Result { let c = "addon.shapes.star"; let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?); - let r1 = f64_req(p, "radius1", c)?.abs(); + let r1 = size(p, "radius1", c)?; check_numbers(p, &["radius2", "points", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?; - let r2 = f64_or(p, "radius2", r1 / 2.0).abs(); - let points = (f64_or(p, "points", 5.0).round() as i64).clamp(3, 1000) as u32; + // The inner radius is a fraction of the outer one, in `SPOKE_RATIO`. + let r2 = ranged(p, "radius2", r1 / 2.0, &(r1 * SPOKE_RATIO.start()..=r1 * SPOKE_RATIO.end()), c)?; + let points = count(p, "points", 5, c)?; let rot = f64_or(p, "rotation", 0.0); - let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0); - let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0); - let (pie, arc_mode) = parse_arc_params(p); + let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?; + let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?; + let (pie, arc_mode) = parse_arc_params(p, c)?; let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot); - let ratio = if r1 > 1e-9 { (r2 / r1).clamp(0.01, 1.0) } else { 0.5 }; + let ratio = if r1 > 1e-9 { (r2 / r1).clamp(*SPOKE_RATIO.start(), *SPOKE_RATIO.end()) } else { 0.5 }; let cs = CustomShape::Polygon { radius: r1, sides: points, @@ -231,12 +304,12 @@ fn star(s: &mut Session, p: &Value) -> Result { fn spiral(s: &mut Session, p: &Value) -> Result { let c = "addon.shapes.spiral"; let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?); - let r = f64_req(p, "radius", c)?.abs(); + let r = size(p, "radius", c)?; check_numbers(p, &["turns", "divergence", "innerRadius"], c)?; check_switches(p, &["clockwise"], c)?; - let turns = f64_or(p, "turns", 3.0).clamp(0.01, 1024.0); - let divergence = f64_or(p, "divergence", 1.0).clamp(0.0, 1000.0); - let inner_radius = f64_or(p, "innerRadius", 0.0).clamp(0.0, 0.999); + let turns = ranged(p, "turns", 3.0, &TURNS, c)?; + let divergence = ranged(p, "divergence", 1.0, &DIVERGENCE, c)?; + let inner_radius = ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?; let clockwise = bool_or(p, "clockwise", true); let cs = CustomShape::Spiral { center, radius: r, turns, divergence, inner_radius, clockwise }; insert_custom(s, "Spiral", None, cs) @@ -299,24 +372,54 @@ fn configure(s: &mut Session, p: &Value) -> Result { c, )?; check_switches(p, &["isStar", "parallelX", "parallelY", "parallelZ", "reset"], c)?; + check_configure(p, c)?; let ids = targets(s, p)?; let reset = bool_or(p, "reset", false); let spiral_hint = p.get("turns").is_some() || p.get("divergence").is_some() || p.get("innerRadius").is_some(); + // The shapes the params change, worked out first so that a call that changes nothing records + // no undo step. + let changes: Vec<(NodeId, CustomShape)> = { + let doc = &s.doc()?.doc; + ids.into_iter() + .filter_map(|id| { + let n = doc.node(id)?; + let before = CustomShape::read_for_update(n, spiral_hint)?; + let mut cs = before.clone(); + let stored = CustomShape::read(n).is_some(); + (apply_shape_params(&mut cs, p, reset) && (cs != before || !stored)).then_some((id, cs)) + }) + .collect() + }; + if changes.is_empty() { + return Ok(json!({ "updated": 0 })); + } let updated = s.edit("Live Shape", |d, _| { let mut count = 0usize; - for id in ids { - let Some(n) = d.node_mut(id) else { continue }; - let Some(mut cs) = CustomShape::read_for_update(n, spiral_hint) else { continue }; - if apply_shape_params(&mut cs, p, reset) { - cs.write_to_node(n); - count += 1; - } + for (id, cs) in &changes { + let Some(n) = d.node_mut(*id) else { continue }; + cs.write_to_node(n); + count += 1; } Ok(count) })?; Ok(json!({ "updated": updated })) } +/// Error unless every bounded value `p` gives is in its range, before any shape changes. +fn check_configure(p: &Value, c: &str) -> Result<()> { + size_or(p, "radius", 0.0, c)?; + radii_param(p, c)?; + arc_mode(p, c)?; + count(p, "sides", 6, c)?; + ranged(p, "spokeRatio", 0.5, &SPOKE_RATIO, c)?; + ranged(p, "rounded", 0.0, &ROUNDING, c)?; + ranged(p, "randomized", 0.0, &ROUNDING, c)?; + ranged(p, "turns", 3.0, &TURNS, c)?; + ranged(p, "divergence", 1.0, &DIVERGENCE, c)?; + ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?; + Ok(()) +} + fn num_param(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()) } @@ -331,8 +434,8 @@ fn apply_arc_params(p: &Value, pie: &mut (f64, f64), arc_mode: &mut shapes::Elli pie.1 = a1.rem_euclid(360.0); changed = true; } - if let Some(m) = str_param(p, "arcMode") { - *arc_mode = parse_mode(m); + if let Some(m) = str_param(p, "arcMode").and_then(parse_mode) { + *arc_mode = m; if *arc_mode == shapes::EllipseMode::Whole { *pie = (0.0, 360.0); } else if (pie.1 - pie.0).abs() < 1e-6 || ((pie.1 - pie.0).abs() - 360.0).abs() < 1e-6 { @@ -351,19 +454,14 @@ fn apply_rect_params(p: &Value, reset: bool, max_r: f64, radii: &mut [f64; 4], p *arc_mode = shapes::EllipseMode::Whole; changed = true; } + // A radius larger than half the rectangle's shorter side is fitted to that size. if let Some(r) = num_param(p, "radius") { *radii = [r.clamp(0.0, max_r); 4]; changed = true; } - if let Some(arr) = p.get("radii").and_then(Value::as_array) - && arr.len() == 4 - { - for (i, v) in arr.iter().enumerate() { - if let Some(r) = v.as_f64().filter(|x| x.is_finite()) { - radii[i] = r.clamp(0.0, max_r); - changed = true; - } - } + if let Ok(Some(four)) = radii_param(p, "addon.shapes.configure") { + *radii = four.map(|r| r.clamp(0.0, max_r)); + changed = true; } changed | apply_arc_params(p, pie, arc_mode) } @@ -378,8 +476,9 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut } changed = true; } - if let Some(n_sides) = p.get("sides").and_then(Value::as_u64) { - *sides = (n_sides as u32).clamp(3, 1000); + // `check_configure` has refused values outside their ranges. + if let Some(n_sides) = num_param(p, "sides") { + *sides = n_sides as u32; changed = true; } if let Some(star) = p.get("isStar").and_then(Value::as_bool) { @@ -387,15 +486,15 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut changed = true; } if let Some(sr) = num_param(p, "spokeRatio") { - *spoke_ratio = Some(sr.clamp(0.01, 1.0)); + *spoke_ratio = Some(sr); changed = true; } if let Some(rnd) = num_param(p, "rounded") { - *rounded = rnd.clamp(-10.0, 10.0); + *rounded = rnd; changed = true; } if let Some(rand) = num_param(p, "randomized") { - *randomized = rand.clamp(-10.0, 10.0); + *randomized = rand; changed = true; } changed @@ -429,15 +528,15 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool { changed = true; } if let Some(t) = num_param(p, "turns") { - *turns = t.clamp(0.01, 1024.0); + *turns = t; changed = true; } if let Some(div) = num_param(p, "divergence") { - *divergence = div.clamp(0.0, 1000.0); + *divergence = div; changed = true; } if let Some(ir) = num_param(p, "innerRadius") { - *inner_radius = ir.clamp(0.0, 0.999); + *inner_radius = ir; changed = true; } changed @@ -470,11 +569,12 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool { } } -fn parse_mode(m: &str) -> shapes::EllipseMode { +fn parse_mode(m: &str) -> Option { match m { - "pie" | "slice" => shapes::EllipseMode::Slice, - "chord" => shapes::EllipseMode::Chord, - "arc" | "open" => shapes::EllipseMode::Arc, - _ => shapes::EllipseMode::Whole, + "whole" => Some(shapes::EllipseMode::Whole), + "pie" | "slice" => Some(shapes::EllipseMode::Slice), + "chord" => Some(shapes::EllipseMode::Chord), + "arc" | "open" => Some(shapes::EllipseMode::Arc), + _ => None, } } diff --git a/crates/engine/src/cmd/addons/shapes/tests.rs b/crates/engine/src/cmd/addons/shapes/tests.rs index 793ae6074c..bc5f1359e2 100644 --- a/crates/engine/src/cmd/addons/shapes/tests.rs +++ b/crates/engine/src/cmd/addons/shapes/tests.rs @@ -135,3 +135,169 @@ fn addon_shapes_commands_and_format_roundtrip_preserve_paths() { assert_eq!(CustomShape::read(a), CustomShape::read(b)); } } + +fn id_of(s: &mut Session, cmd: &str, p: serde_json::Value) -> NodeId { + NodeId(s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))["id"].as_u64().unwrap()) +} + +fn shape_of(s: &Session, id: NodeId) -> CustomShape { + CustomShape::read(s.doc().unwrap().doc.node(id).unwrap()).unwrap() +} + +/// Runs `cmd` with `p`, which must fail with `msg` and leave the document and its history as they +/// were. +fn refused(s: &mut Session, cmd: &str, p: serde_json::Value, msg: &str) { + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute(cmd, &p).map(|v| v.to_string()).unwrap_err().to_string(); + assert!(e.contains(msg), "{cmd} {p}: {e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {p}"); + assert_eq!(vectorcraft_format::save(&s.doc().unwrap().doc, false), saved, "{cmd} {p}"); +} + +#[test] +fn polygon_and_star_refuse_counts_that_are_not_whole_numbers_from_3_to_1000() { + let mut s = new_session(); + for n in [json!(2.5), json!(2), json!(0), json!(-6), json!(1000.5), json!(5000), json!(4_294_967_299u64)] { + let x = n.as_f64().unwrap(); + let p = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n}); + refused(&mut s, "addon.shapes.polygon", p, &format!("`sides` must be a whole number between 3 and 1000 (got {x})")); + let p = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n}); + refused(&mut s, "addon.shapes.star", p, &format!("`points` must be a whole number between 3 and 1000 (got {x})")); + } + refused(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": "6"}), "`sides` must be a number"); + refused(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": [5]}), "`points` must be a number"); + + // The ends of the range are taken as given, and an omitted or null count is the default. + for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(7.0), 7), (json!(null), 6)] { + let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n})); + assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: None, .. } if sides == want), "{n}"); + } + let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0})); + assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides: 6, .. })); + for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(null), 5)] { + let id = id_of(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n})); + assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: Some(_), .. } if sides == want), "{n}"); + } +} + +#[test] +fn shapes_refuse_numbers_outside_their_ranges() { + let mut s = new_session(); + let poly = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0}); + let star = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0}); + let spiral = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0}); + let with = |base: &serde_json::Value, key: &str, v: f64| { + let mut p = base.clone(); + p[key] = json!(v); + p + }; + for (cmd, base, key, bad, good) in [ + ("addon.shapes.polygon", &poly, "rounded", [-10.5, 10.5], [-10.0, 10.0]), + ("addon.shapes.polygon", &poly, "randomized", [-11.0, 10.01], [-10.0, 10.0]), + ("addon.shapes.star", &star, "rounded", [-10.5, 10.5], [-10.0, 10.0]), + ("addon.shapes.star", &star, "randomized", [-11.0, 10.01], [-10.0, 10.0]), + ("addon.shapes.star", &star, "radius2", [0.05, 11.0], [0.1, 10.0]), + ("addon.shapes.spiral", &spiral, "turns", [0.0, 1024.5], [0.01, 1024.0]), + ("addon.shapes.spiral", &spiral, "divergence", [-0.1, 1000.5], [0.0, 1000.0]), + ("addon.shapes.spiral", &spiral, "innerRadius", [-0.1, 1.0], [0.0, 0.999]), + ] { + let (min, max) = (good[0], good[1]); + for x in bad { + refused(&mut s, cmd, with(base, key, x), &format!("`{key}` must be between {min} and {max} (got {x})")); + } + for x in good { + s.execute(cmd, &with(base, key, x)).unwrap_or_else(|e| panic!("{cmd} {key} {x}: {e}")); + } + } + + // Sizes are not negative. + for (cmd, p, key) in [ + ("addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"), + ("addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": -10.0}), "radius1"), + ("addon.shapes.spiral", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"), + ("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"), + ("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": 10.0, "radius": -1.0}), "radius"), + ("addon.shapes.ellipse", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": -10.0}), "height"), + ("addon.shapes.box3d", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"), + ] { + refused(&mut s, cmd, p, &format!("`{key}` must be a number not below 0")); + } + + // Corner radii are four numbers that are not negative. + for radii in [json!([1.0, 2.0, 3.0]), json!([1.0, 2.0, 3.0, -4.0]), json!([1.0, 2.0, 3.0, "4"]), json!(4.0)] { + let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radii": radii}); + refused(&mut s, "addon.shapes.rectangle", p, "`radii` must be four numbers not below 0: [tl, tr, br, bl]"); + } + + // An arc mode is one of the names the commands take. + for mode in [json!("slices"), json!(""), json!(3)] { + let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode}); + refused(&mut s, "addon.shapes.rectangle", p.clone(), "`arcMode` must be one of whole, pie, chord, arc"); + refused(&mut s, "addon.shapes.ellipse", p, "`arcMode` must be one of whole, pie, chord, arc"); + } + for mode in ["whole", "pie", "slice", "chord", "arc", "open"] { + s.execute("addon.shapes.ellipse", &json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode})).unwrap(); + } +} + +#[test] +fn configure_refuses_values_outside_their_ranges_before_changing_any_shape() { + let mut s = new_session(); + let rect = id_of(&mut s, "addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radius": 4.0})); + let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5})); + let spiral = id_of(&mut s, "addon.shapes.spiral", json!({"cx": 60.0, "cy": 60.0, "radius": 45.0})); + let ids = json!([rect.0, poly.0, spiral.0]); + let cfg = |key: &str, v: serde_json::Value| json!({"ids": ids.clone(), key: v}); + let c = "addon.shapes.configure"; + for n in [json!(2.5), json!(2), json!(-1), json!(5000), json!(4_294_967_299u64)] { + let x = n.as_f64().unwrap(); + refused(&mut s, c, cfg("sides", n), &format!("`sides` must be a whole number between 3 and 1000 (got {x})")); + } + for (key, bad, min, max) in [ + ("spokeRatio", [0.0, 1.5], 0.01, 1.0), + ("rounded", [-10.5, 11.0], -10.0, 10.0), + ("randomized", [-11.0, 10.5], -10.0, 10.0), + ("turns", [0.0, 2000.0], 0.01, 1024.0), + ("divergence", [-1.0, 1000.5], 0.0, 1000.0), + ("innerRadius", [-0.5, 1.0], 0.0, 0.999), + ] { + for x in bad { + refused(&mut s, c, cfg(key, json!(x)), &format!("`{key}` must be between {min} and {max} (got {x})")); + } + } + refused(&mut s, c, cfg("radius", json!(-1.0)), "`radius` must be a number not below 0"); + for radii in [json!([1.0, 2.0]), json!([1.0, 2.0, 3.0, -1.0]), json!("4")] { + refused(&mut s, c, cfg("radii", radii), "`radii` must be four numbers not below 0: [tl, tr, br, bl]"); + } + refused(&mut s, c, cfg("arcMode", json!("bogus")), "`arcMode` must be one of whole, pie, chord, arc"); + // A valid value next to a refused one changes no shape either. + refused(&mut s, c, json!({"ids": ids.clone(), "sides": 8, "turns": 0.0}), "`turns` must be between 0.01 and 1024 (got 0)"); + + // In range, the values are taken as given; a radius larger than the rectangle takes is fitted to it. + s.execute(c, &json!({"ids": [poly.0], "sides": 1000, "rounded": -10.0, "randomized": 10.0})).unwrap(); + assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 1000, rounded, randomized, .. } if rounded == -10.0 && randomized == 10.0)); + s.execute(c, &json!({"ids": [poly.0], "sides": 3})).unwrap(); + assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 3, .. })); + s.execute(c, &json!({"ids": [spiral.0], "turns": 1024.0, "divergence": 0.0, "innerRadius": 0.999})).unwrap(); + assert!( + matches!(shape_of(&s, spiral), CustomShape::Spiral { turns, divergence, inner_radius, .. } if turns == 1024.0 && divergence == 0.0 && inner_radius == 0.999) + ); + s.execute(c, &json!({"ids": [rect.0], "radius": 1000.0})).unwrap(); + assert!(matches!(shape_of(&s, rect), CustomShape::Rectangle { radii, .. } if radii == [40.0; 4])); +} + +#[test] +fn configure_records_no_undo_step_when_nothing_changes() { + let mut s = new_session(); + let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5, "rounded": 0.5})); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 5, "rounded": 0.5})).unwrap(); + assert_eq!(r["updated"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 6})).unwrap(); + assert_eq!(r["updated"], 1); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 6, .. })); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 02bc111746..bed4b33b76 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -36,7 +36,7 @@ use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use super::{ CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets, - object_param, ok, paint_targets, str_param, switch_or, targets, + object_param, ok, paint_targets, str_param, switch_or, targets, u64_req, }; use crate::EngineError; @@ -604,9 +604,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } -/// The whole number of 0 or more that `key` holds, read with [`index`]. +/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`]. pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) + u64_req(p, key, cmd).map(index) } fn set_active_item(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 1ebde5c026..e30e6efcdc 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), @@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result { } q.insert("assets".into(), json!(all)); } - export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e)) + export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e)) } fn settings_set(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 50c63b59cd..32b0f9fcd0 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -226,7 +226,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}", has_selection, symbol_break_link ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 2fb31c5f6b..1bfb8edea7 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { /// The plain art of instance `inst` in document space (fresh ids), with the instance's own /// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice -/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it -/// when the art has a mask of its own. +/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after +/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on +/// the art, or on a group around it when the art has a mask of its own; a group added around the +/// art takes the instance's box angle. fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; @@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { if let Some((color, amount)) = brush::stain(inst) { brush::tint_node(&mut art, &color, amount); } - let art = d.place_symbol_art(symbol, &art, *xf)?; + let mut art = d.place_symbol_art(symbol, &art, *xf)?; + let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf); + if turn != 0.0 { + art.turn_boxes(turn); + } let mut art = d.reid(&art); if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { if !matches!(art.kind, NodeKind::Group { .. }) { let id = d.alloc_id(); art = Node::group(id, vec![Arc::new(art)]); + art.bbox_angle = inst.bbox_angle; } art.opacity *= inst.opacity; art.blend = inst.blend; @@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { if art.extras().mask.is_some() { let id = d.alloc_id(); art = Node::group(id, vec![Arc::new(art)]); + art.bbox_angle = inst.bbox_angle; } art.extras_mut().mask = Some(mask.clone()); } diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index bdb238ce83..f457712415 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -127,6 +127,17 @@ fn node_refs(n: &Node, f: Visit) { if let Some(cm) = Document::color_mask_of(m) { f(Ref::Name(Res::Image, &cm.key)); } + // A graph keeps paints and style names for its series and labels, which a redraw gives them. + if let Some(g) = m.extras().graph.as_deref() { + vectorcraft_doc::swatches::graph_paints(m).for_each(|p| paint_refs(p, f)); + let l = &g.label_styles; + for s in [&l.values, &l.categories, &l.legend].into_iter().flatten().filter_map(|st| st.style_name.as_ref()) { + f(Ref::Name(Res::CharStyle, s)); + } + for s in [&l.values_para, &l.categories_para, &l.legend_para].into_iter().flatten().filter_map(|pa| pa.style_name.as_ref()) { + f(Ref::Name(Res::ParaStyle, s)); + } + } match &m.kind { NodeKind::Text(t) => { for r in &t.runs { @@ -671,6 +682,27 @@ impl Relink { self.name(Res::Image, &mut cm.key); self.name(Res::Image, &mut cm.from); } + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + for s in &mut g.series_paints { + s.fill.iter_mut().chain(s.stroke.iter_mut()).for_each(|p| self.paint(p)); + } + let l = &mut g.label_styles; + for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { + self.paint(&mut st.fill); + self.paint(&mut st.stroke); + if let Some(s) = &mut st.style_name { + self.name(Res::CharStyle, s); + } + } + for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() { + if let Some(s) = &mut pa.style_name { + self.name(Res::ParaStyle, s); + } + } + for r in [&mut l.values_rows, &mut l.categories_rows, &mut l.legend_rows].into_iter().flatten() { + r.paint.fill.iter_mut().chain(r.paint.stroke.iter_mut()).for_each(|p| self.paint(p)); + } + } match &mut n.kind { NodeKind::Text(t) => { for r in &mut t.runs { diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs index 0ca376b243..fd8967f3fa 100644 --- a/crates/engine/src/cmd/draw2/common.rs +++ b/crates/engine/src/cmd/draw2/common.rs @@ -231,9 +231,9 @@ pub(super) fn within_input_bound(q: Point) -> bool { q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT } -/// The whole number of 0 or more that `key` holds, read with [`index`]. +/// The whole number of 0 or more that `key` holds, read with [`u64_req`](crate::cmd::u64_req) and [`index`]. pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) + crate::cmd::u64_req(p, key, cmd).map(index) } /// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 31e184e195..0439aaa081 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -11,7 +11,9 @@ use super::{ // ---------- Pencil / Paintbrush ---------- /// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay -/// corners) and simplify it to `tol`. +/// corners) and simplify it to `tol`. A closed stroke is fitted as the open one is and closed by a +/// straight segment from its end back to its start: fitting round the gap would curve that segment +/// on from both ends, past them and back. fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE * 0.3); let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) }; @@ -41,15 +43,26 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { (kept[i], corner) }) .collect(); - let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed); + let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false); if sp.anchors.len() < 3 { - return sp; + // Two anchors closed would run back over the line itself. + return SubPath::new(sp.anchors, closed && n > 2); } let fitted = po::simplify_with( &PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() }, ); - fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) + let mut anchors = fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp).anchors; + if closed { + // No handle on either side of the closing segment. + if let Some(l) = anchors.last_mut() { + l.h_out = l.p; + } + if let Some(f) = anchors.first_mut() { + f.h_in = f.p; + } + } + SubPath::new(anchors, closed) } /// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 990ac1a1c6..9ecb5c3984 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -11,7 +11,7 @@ use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or, - object_param, ok, point_param, str_param, switch_or, + object_param, ok, point_param, str_param, switch_or, u64_req, }; use crate::{Clipboard, EngineError, SourceLayer, inspect}; @@ -249,7 +249,10 @@ fn file_close(s: &mut Session, p: &Value) -> Result { } fn doc_activate(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?; + let i = match p.get("index") { + None | Some(Value::Null) => return Err(bad("document.activate", "missing index")), + Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.activate", "`index` must be a whole number"))?, + }; if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) } } @@ -262,7 +265,10 @@ fn doc_move(s: &mut Session, p: &Value) -> Result { if from >= n { return Err(bad("document.move", "no such document")); } - let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?; + let to = match p.get("to") { + None | Some(Value::Null) => return Err(bad("document.move", "missing `to`: a non-negative integer")), + Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`to` must be a whole number"))?, + }; let to = to.min(n.saturating_sub(1)); if !s.move_document(from, to) { return Err(bad("document.move", "no such document")); @@ -562,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result { Ok(json!({ "units": next.label() })) } -/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given. +/// The `artboard` param of the ruler origin commands: an artboard index, 0 when left out or null. A +/// value that is not a whole number is "`artboard` must be a whole number"; one past the artboards +/// is "index out of range". fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { - let index = match p.get("artboard") { - None => 0, - Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?, - }; + let index = super::index_opt(p, "artboard", c)?.unwrap_or(0); if index >= s.doc()?.doc.artboards.len() { return Err(bad(c, "index out of range")); } @@ -577,14 +582,15 @@ fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { fn ruler_origin(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; // A document without artboards measures from its global zero point. - let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; + let index = + if d.artboards.is_empty() && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; Ok(json!(s.doc()?.doc.ruler_origin_for(index))) } fn set_ruler_origin(s: &mut Session, p: &Value) -> Result { const C: &str = "document.setRulerOrigin"; let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? }; + let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, C)? }; change_ruler_origin(s, index, Some(origin), "Ruler Origin") } @@ -1190,8 +1196,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { /// Document ids scope node ids: two tabs may contain objects with the same node id. fn drop_selection(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.dropSelection"; - let key = "sourceDocument"; - let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?; + let uid = u64_req(p, "sourceDocument", CMD)?; let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates"))); let grab = point("grab")?; let at = point("at")?; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 6ee3905d95..65672f8b6b 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -865,8 +865,8 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several /// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with /// fills, strokes or effects of its own or such members, other objects with effects, type with -/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry -/// effect ([`effects::has_item_geometry`])? +/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry or +/// raster effect ([`effects::has_item_geometry`], [`effects::has_instance_row_raster`])? pub(crate) fn expandable(n: &Node) -> bool { expandable_except(n, &[]) } @@ -883,7 +883,9 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), - NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + effects::has_item_geometry(n) || effects::has_instance_row_raster(n) || !n.appearance.effects.is_empty() + } _ => !n.appearance.effects.is_empty(), } } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index d20c6c46b2..1ff1897c85 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads, leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 28e12c115d..03736c6022 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,