diff --git a/Cargo.lock b/Cargo.lock index 00fe4ca454..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6386,6 +6386,7 @@ dependencies = [ "pollster 1.0.1", "raw-window-handle", "rfd", + "rustix 1.1.6", "serde_json", "socket2", "tempfile", @@ -6822,6 +6823,7 @@ dependencies = [ "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", + "unicode-bidi", "usvg 0.48.1", "vectorcraft-brush", "vectorcraft-color", 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/app.rs b/apps/vectorcraft/src/app.rs index 126b671877..dddaf0a91e 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -108,37 +108,14 @@ impl eframe::App for App { self.app.start_pending_update(); #[cfg(target_os = "macos")] super::mac_cursor::release(); + // Dropping the app can wait for the clipboard handles to let go: the windows leave the + // screen first (`quit_windows`). #[cfg(not(any(target_os = "macos", windows)))] - hide_before_teardown(self.winit_window.as_deref()); + super::quit_windows::before_teardown(self.winit_window.as_deref()); end_before_teardown(self.frames); } } -/// The main window as [`hide_before_teardown`] sees it. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) trait QuittingWindow { - fn hide(&self); -} - -#[cfg(not(any(target_os = "macos", windows)))] -impl QuittingWindow for winit::window::Window { - fn hide(&self) { - self.set_visible(false); - } -} - -/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits -/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and -/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland -/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and -/// cannot hide a Wayland window. -#[cfg(not(any(target_os = "macos", windows)))] -pub(crate) fn hide_before_teardown(window: Option<&W>) { - if let Some(window) = window { - window.hide(); - } -} - impl App { /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a /// focused, shown window whose windows had input waits for the renderer and the next frame, 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..c55785cfdf 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -60,6 +60,8 @@ mod portal_request; mod portal_save; mod prefs_dir; mod printing; +#[cfg(not(any(target_os = "macos", windows)))] +mod quit_windows; #[cfg(not(target_os = "macos"))] mod single_instance; #[cfg(windows)] @@ -90,8 +92,6 @@ mod titlebar; use app::bring_window_forward; use app::native_input_hook; use app::open_or_hold; -#[cfg(all(test, not(any(target_os = "macos", windows))))] -use app::{QuittingWindow, hide_before_teardown}; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -122,7 +122,7 @@ struct App { /// Keeps the custom title bar's window borderless (Windows, #1758). window_frame: Option, /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635), - /// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`). + /// and to take the window off screen on quit before the clipboard handles let go (`quit_windows`). winit_window: Option>, /// A saved or fitted geometry asked for maximize once the window is mapped (Linux). pending_maximize: bool, @@ -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 @@ -410,6 +418,10 @@ fn main() -> std::process::ExitCode { })); options }; + // Quitting takes the X11 windows of floating panels and documents off screen with the main + // window, so eframe's windows are recorded as it makes and drops them (`quit_windows`). + #[cfg(target_os = "linux")] + let options = eframe::NativeOptions { viewport_hooks: Some(std::sync::Arc::new(quit_windows::Hooks)), ..options }; #[cfg(feature = "wgpu")] let options = { let mut options = options; @@ -595,6 +607,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/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 6d1a8734b2..65970c87f7 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -242,7 +242,7 @@ fn parent(window: &winit::window::Window) -> (String, Option<()>) { /// (), made as rfd's `window_identifier` /// makes it. #[cfg(target_os = "linux")] -mod wayland { +pub(crate) mod wayland { use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::wl_registry::{self, WlRegistry}; use wayland_client::protocol::wl_surface::WlSurface; @@ -308,9 +308,10 @@ mod wayland { } } - /// `window`'s Wayland connection and surface, from its raw handles. + /// `window`'s Wayland connection and surface, from its raw handles. The caller holds `window` + /// while it uses them (`quit_windows` unmaps the main window's surface through them). #[allow(unsafe_code)] - fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { + pub(crate) fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { use wayland_backend::sys::client::{Backend, ObjectId}; use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle}; let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) = @@ -319,8 +320,8 @@ mod wayland { return None; }; // SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller - // holds it until the export is dropped); a backend made from a foreign display does not - // close it when dropped. + // holds it while it uses the connection, and the export holds it until it is dropped); a + // backend made from a foreign display does not close it when dropped. let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) }; let conn = Connection::from_backend(backend); // SAFETY: the pointer is the window's live `wl_surface` proxy on that display. diff --git a/apps/vectorcraft/src/quit_windows.rs b/apps/vectorcraft/src/quit_windows.rs new file mode 100644 index 0000000000..36497f4f4f --- /dev/null +++ b/apps/vectorcraft/src/quit_windows.rs @@ -0,0 +1,280 @@ +//! Takes the app's windows off screen when it quits on Linux and the BSDs, before eframe drops the +//! app. Dropping the app waits up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the +//! clipboard handles to let go, and eframe closes the windows only after that wait. +//! +//! The main window is hidden on X11 (XWayland included). winit cannot hide a Wayland window, so on +//! Linux a Wayland main window is unmapped instead: its surface gets no buffer, and the window stays +//! open with the connection the Wayland clipboard copies and reads on. On Linux the X11 windows of +//! floating panels and documents are recorded as eframe creates and drops them ([`Hooks`]), and +//! quitting unmaps them through an X11 connection of its own. On native Wayland, floating panels and +//! documents are normally drawn inside the main window. + +#[cfg(target_os = "linux")] +use std::collections::HashMap; +#[cfg(target_os = "linux")] +use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; + +#[cfg(target_os = "linux")] +use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + +/// The main window as quitting sees it. +pub(crate) trait QuittingWindow { + /// The window is a Wayland surface. + fn is_wayland(&self) -> bool; + /// Hides an X11 window. + fn hide(&self); + /// Takes a Wayland window off screen and keeps it open. + fn unmap(&self); +} + +impl QuittingWindow for winit::window::Window { + #[cfg(target_os = "linux")] + fn is_wayland(&self) -> bool { + matches!(self.window_handle().map(|h| h.as_raw()), Ok(RawWindowHandle::Wayland(_))) + } + + /// The BSDs have no Wayland unmap: their windows are hidden through winit. + #[cfg(not(target_os = "linux"))] + fn is_wayland(&self) -> bool { + false + } + + fn hide(&self) { + self.set_visible(false); + } + + #[cfg(target_os = "linux")] + fn unmap(&self) { + unmap_wayland(self); + } + + #[cfg(not(target_os = "linux"))] + fn unmap(&self) { + self.set_visible(false); + } +} + +/// Takes the main window and the floating windows `x11` lists off screen: a Wayland main window is +/// unmapped and any other main window hidden, and `unmap_x11` unmaps the floating windows. +pub(crate) fn off_screen(main: Option<&W>, x11: &[u32], unmap_x11: impl FnOnce(&[u32])) { + if let Some(window) = main { + if window.is_wayland() { + window.unmap(); + } else { + window.hide(); + } + } + if !x11.is_empty() { + unmap_x11(x11); + } +} + +/// [`off_screen`] with the running app's main window and the floating windows [`Hooks`] recorded. +/// Runs on quit, before eframe drops the app. +pub(crate) fn before_teardown(main: Option<&winit::window::Window>) { + #[cfg(target_os = "linux")] + off_screen(main, &CHILD_WINDOWS.x11(), unmap_x11); + #[cfg(not(target_os = "linux"))] + off_screen(main, &[], |_| {}); +} + +/// The X11 windows of floating panels and documents, by viewport, while eframe holds them. +#[cfg(target_os = "linux")] +#[derive(Default)] +pub(crate) struct ChildWindows(Mutex>); + +#[cfg(target_os = "linux")] +impl ChildWindows { + /// eframe made `viewport`'s window, with `handle`. The main window, and windows of other window + /// systems, are not recorded; a window made again for the same viewport replaces its record. + pub(crate) fn created(&self, viewport: egui::ViewportId, handle: Option) { + let mut windows = self.lock(); + match handle.and_then(x11_window) { + Some(id) if viewport != egui::ViewportId::ROOT => windows.insert(viewport, id), + _ => windows.remove(&viewport), + }; + } + + /// eframe dropped `viewport`'s window. + pub(crate) fn destroyed(&self, viewport: egui::ViewportId) { + self.lock().remove(&viewport); + } + + /// The recorded windows, in id order. + pub(crate) fn x11(&self) -> Vec { + let mut ids: Vec = self.lock().values().copied().collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// The X11 window id of `handle`, None for another window system. +#[cfg(target_os = "linux")] +fn x11_window(handle: RawWindowHandle) -> Option { + match handle { + RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok(), + RawWindowHandle::Xcb(h) => Some(h.window.get()), + _ => None, + } +} + +/// The running app's floating windows, which [`Hooks`] records. +#[cfg(target_os = "linux")] +static CHILD_WINDOWS: LazyLock = LazyLock::new(ChildWindows::default); + +/// Records the windows eframe creates and drops in [`CHILD_WINDOWS`]; set as +/// [`eframe::NativeOptions::viewport_hooks`]. +#[cfg(target_os = "linux")] +pub(crate) struct Hooks; + +#[cfg(target_os = "linux")] +impl eframe::ViewportHooks for Hooks { + fn window_created(&self, viewport: egui::ViewportId, window: &winit::window::Window) { + CHILD_WINDOWS.created(viewport, window.window_handle().ok().map(|h| h.as_raw())); + } + + fn window_destroyed(&self, viewport: egui::ViewportId) { + CHILD_WINDOWS.destroyed(viewport); + } +} + +/// Unmaps the X11 `windows` through a connection of its own, and waits until the server has done so. +/// An id whose window is gone gets an error, which is ignored. +#[cfg(target_os = "linux")] +fn unmap_x11(windows: &[u32]) { + use x11rb::protocol::xproto::ConnectionExt as _; + let conn = match x11rb::connect(None) { + Ok((conn, _)) => conn, + Err(e) => { + log::warn!("couldn't reach the X server to hide the floating windows on quit: {e}"); + return; + } + }; + for &window in windows { + if let Ok(cookie) = conn.unmap_window(window) { + cookie.ignore_error(); + } + } + // The server answers once it has handled the requests before this one. + if let Ok(cookie) = conn.get_input_focus() { + let _ = cookie.reply(); + } +} + +/// Attaches no buffer to `window`'s Wayland surface and commits it, which unmaps the window: the +/// compositor stops showing it, and the window and its connection stay open. +#[cfg(target_os = "linux")] +fn unmap_wayland(window: &winit::window::Window) { + let Some((conn, surface)) = super::portal_save::wayland::surface(window) else { return }; + surface.attach(None, 0, 0); + surface.commit(); + if let Err(e) = conn.flush() { + log::warn!("couldn't reach the compositor to hide the window on quit: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + /// A main window that records what quitting did to it. + struct Window<'a> { + wayland: bool, + done: &'a RefCell>, + } + + impl QuittingWindow for Window<'_> { + fn is_wayland(&self) -> bool { + self.wayland + } + fn hide(&self) { + self.done.borrow_mut().push("hide".into()); + } + fn unmap(&self) { + self.done.borrow_mut().push("unmap".into()); + } + } + + fn quit(wayland: Option, x11: &[u32]) -> Vec { + let done = RefCell::new(Vec::new()); + let window = wayland.map(|wayland| Window { wayland, done: &done }); + off_screen(window.as_ref(), x11, |ids| done.borrow_mut().push(format!("unmap x11 {ids:?}"))); + done.into_inner() + } + + /// winit cannot hide a Wayland window: quitting unmaps it, and never only asks winit to hide it. + #[test] + fn quitting_unmaps_a_wayland_main_window() { + assert_eq!(quit(Some(true), &[]), ["unmap"]); + } + + /// An X11 main window (XWayland included) is hidden, and the floating panels' and documents' X11 + /// windows are unmapped with it. + #[test] + fn quitting_hides_an_x11_main_window_and_its_floating_windows() { + assert_eq!(quit(Some(false), &[]), ["hide"]); + assert_eq!(quit(Some(false), &[7, 9]), ["hide", "unmap x11 [7, 9]"]); + } + + /// Without a main window (none was made), the floating windows still go, and with none at all + /// nothing is done. + #[test] + fn quitting_without_a_main_window() { + assert_eq!(quit(None, &[3]), ["unmap x11 [3]"]); + assert!(quit(None, &[]).is_empty()); + } + + #[cfg(target_os = "linux")] + fn xlib(id: std::os::raw::c_ulong) -> Option { + Some(RawWindowHandle::Xlib(winit::raw_window_handle::XlibWindowHandle::new(id))) + } + + #[cfg(target_os = "linux")] + fn viewport(name: &str) -> egui::ViewportId { + egui::ViewportId::from_hash_of(name) + } + + /// The floating windows eframe made are recorded until it drops them; the main window, which + /// quitting reaches through winit, is not. + #[cfg(target_os = "linux")] + #[test] + fn floating_x11_windows_are_recorded_while_they_exist() { + let windows = ChildWindows::default(); + windows.created(egui::ViewportId::ROOT, xlib(1)); + windows.created(viewport("Layers"), xlib(9)); + let xcb = winit::raw_window_handle::XcbWindowHandle::new(std::num::NonZeroU32::new(5).expect("not zero")); + windows.created(viewport("Untitled-2"), Some(RawWindowHandle::Xcb(xcb))); + windows.created(viewport("Color"), xlib(7)); + assert_eq!(windows.x11(), [5, 7, 9]); + windows.destroyed(viewport("Color")); + assert_eq!(windows.x11(), [5, 9]); + // A viewport that eframe has already dropped, or never made, is no error. + windows.destroyed(viewport("Color")); + windows.destroyed(viewport("Swatches")); + assert_eq!(windows.x11(), [5, 9]); + } + + /// A window made again for a viewport replaces its record, and one of another window system, or + /// one whose handle could not be read, removes it. + #[cfg(target_os = "linux")] + #[test] + fn a_window_made_again_replaces_its_record() { + let windows = ChildWindows::default(); + windows.created(viewport("Layers"), xlib(9)); + windows.created(viewport("Layers"), xlib(11)); + assert_eq!(windows.x11(), [11]); + let surface = std::ptr::NonNull::::dangling(); + let wayland = winit::raw_window_handle::WaylandWindowHandle::new(surface); + windows.created(viewport("Layers"), Some(RawWindowHandle::Wayland(wayland))); + assert!(windows.x11().is_empty()); + windows.created(viewport("Layers"), xlib(11)); + windows.created(viewport("Layers"), None); + assert!(windows.x11().is_empty()); + } +} diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 99ca880bc9..54fa01da03 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"] @@ -487,21 +515,3 @@ fn the_linux_desktop_entry_opens_every_readable_format() { } } } - -/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose -/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go. -#[cfg(not(any(target_os = "macos", windows)))] -#[test] -fn quitting_hides_the_window_before_the_clipboards_let_go() { - struct Window(std::cell::Cell); - impl QuittingWindow for Window { - fn hide(&self) { - self.0.set(self.0.get() + 1); - } - } - let window = Window(std::cell::Cell::new(0)); - hide_before_teardown(Some(&window)); - assert_eq!(window.0.get(), 1, "the window was not hidden"); - // Without a window (none was made), there is nothing to hide. - hide_before_teardown::(None); -} 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/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/doc/src/text.rs b/crates/doc/src/text.rs index 440c379283..c3c1464cfc 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -18,7 +18,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{ - CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 16b63e0809..ef3cf29dc9 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -44,6 +44,14 @@ pub struct CharStyle { /// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`]. #[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")] pub aki_right: f64, + /// Tsume (Character panel, East Asian options): the percentage of the blank space on each side + /// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50 + /// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A + /// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added + /// after it. Horizontal type only. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub tsume: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -440,6 +448,9 @@ fn hundred() -> f64 { /// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em. pub const MAX_AKI: f64 = 1000.0; +/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink. +pub const MAX_TSUME: f64 = 100.0; + /// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]). pub const WARICHU_LINES: std::ops::RangeInclusive = 1..=6; @@ -534,6 +545,7 @@ impl Default for CharStyle { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, @@ -588,7 +600,8 @@ impl CharStyle { /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right /// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to - /// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale + /// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the + /// same way. The Warichu Settings Lines and Scale /// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to /// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its /// own units. @@ -606,6 +619,7 @@ impl CharStyle { let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) }; self.aki_left = aki(self.aki_left); self.aki_right = aki(self.aki_right); + self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) }; self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end()); self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) }; self.warichu_scale = diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dd4002dd60..9af4d9c5dc 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -507,6 +507,7 @@ impl crate::Document { st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.aki_left = num("akiLeft").unwrap_or(st.aki_left); st.aki_right = num("akiRight").unwrap_or(st.aki_right); + st.tsume = num("tsume").unwrap_or(st.tsume); st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap); st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale); st.keep_dimensions_in_range(); @@ -532,6 +533,7 @@ impl crate::Document { ("tcyLeftRight", Some(st.tcy_left_right)), ("akiLeft", Some(st.aki_left)), ("akiRight", Some(st.aki_right)), + ("tsume", Some(st.tsume)), ("leading", st.leading), ]; for (k, v) in kept { diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index b8531b402d..26622fa115 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -357,7 +357,7 @@ impl ParaList { /// The style the marker is set in: `first` (the style of the paragraph's first character) /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough. + /// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -368,6 +368,7 @@ impl ParaList { kerning_before: 0.0, aki_left: 0.0, aki_right: 0.0, + tsume: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, warichu: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 0f474d2ed8..c3afafb6e1 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() { assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0))); } +/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a +/// value out of range from a file, in a run or a character style, is kept from 0 to 100. +#[test] +fn tsume_is_saved_only_when_set_and_kept_in_range() { + let st = CharStyle::default(); + assert_eq!(st.tsume, 0.0); + let saved = serde_json::to_value(&st).unwrap(); + assert!(saved.get("tsume").is_none()); + let read = serde_json::from_value::(saved).unwrap(); + assert_eq!(read.tsume, 0.0); + let set = CharStyle { tsume: 37.5, ..CharStyle::default() }; + let saved = serde_json::to_value(&set).unwrap(); + assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5)); + assert_eq!(serde_json::from_value::(saved).unwrap(), set); + for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] { + let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() }; + damaged.keep_dimensions_in_range(); + assert_eq!(damaged.tsume, kept, "{stored}"); + } + let mut d = crate::Document::new(100.0, 100.0); + for tsume in [500, -1] { + let attrs = serde_json::json!({ "tsume": tsume }); + d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() }); + } + d.keep_type_values_in_range(); + assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0))); +} + /// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its /// script's block; every system saves under its id, which reads back, and other names are refused. #[test] 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 7e42147443..2cffad6581 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, 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, outline_text, reshape, + has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; 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 2bcc026340..f3855f57ca 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/group.rs b/crates/effects/src/group.rs index bb2f605a91..4df0a105ce 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -661,8 +661,9 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live /// objects evaluated; a compound shape as its area, with the symbol instances among its members -/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its -/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the +/// as their symbols' art from `doc`; a clip group or clipping layer (`n` itself or one among its +/// members) as the region its clipping path clips to ([`crate::clip::clip_outline`]), so members +/// it hides add nothing. Images, other symbol instances and guides add nothing. A Repeat counts the /// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { @@ -690,8 +691,14 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul } } fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { - let clip = usize::from(n.clips()); - for c in n.children().into_iter().flatten().skip(clip) { + // A clip group shows its members only inside its clipping path: its outline is that + // region, not the clipped members' own shapes, which may reach far outside it (#7289). + if n.clips() { + let clip = n.children().and_then(|ch| ch.first()); + out.extend(clip.and_then(|c| crate::clip::clip_outline(c)).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule))); + return; + } + for c in n.children().into_iter().flatten() { push(doc, c, out); } } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c6e2a78c73..8e00718c01 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1757,3 +1757,38 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}"); } } + +/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among +/// its members, its clipping path, not the clipped art it hides outside that path (#7289). +#[test] +fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() { + let mut clip = square_at_100(); + clip.id = NodeId(2); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + // The clipped art reaches 100 pt past the clipping path on either side. + let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black()); + let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true }); + let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]); + let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + fill.effects.push(offset_10()); + g.appearance.items = vec![AppearanceItem::Fill(fill)]; + g.appearance.set_contents_at(1); + let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5; + let expected = Rect::new(90.0, 90.0, 210.0, 210.0); + + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert_eq!(ch.len(), 2, "the fill's art below the clip group"); + assert_eq!(ch[0].id, NodeId(10)); + let b = drawn_bounds(&ch[0]).unwrap(); + assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}"); + + // The clip group itself with that fill: the same outline. + let mut own = clipped; + own.appearance = g.appearance.clone(); + assert_eq!(member_shapes(None, &own).len(), 1); + let b = member_shapes(None, &own)[0].0.bounds().unwrap(); + assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}"); +} 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 8c829d9e0b..83ff68431f 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. @@ -709,12 +720,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/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..4b23c3af8c 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -370,7 +370,9 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) } -/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts +/// One object's surface of revolution about the axis of `b`, and each face it draws ([`Drawn`]), +/// back to front: by depth, and where two faces overlap, by their depths there +/// ([`crate::solids::surface_order`]). `copies` counts /// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face /// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the /// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): @@ -388,7 +390,7 @@ fn surface( blend: vectorcraft_doc::color::BlendMode, expand: bool, copies: &mut Copies, -) -> Option<(Vec, Node)> { +) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { @@ -397,9 +399,12 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } - let depths = facets.iter().map(|f| f.depth).collect(); + let order = crate::solids::surface_order(facets.iter().map(|f| (f.contours.first().map_or(&[][..], Vec::as_slice), f.plane, f.depth))); + let facets = in_order(facets, &order); + let drawn = + facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect(); let p = merged_params(&fx.id, &fx.params); - return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; @@ -407,6 +412,8 @@ fn surface( if copies.surfaces > MAX_PAINT_COPIES { return None; } + let order = crate::solids::surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + let faces = in_order(faces, &order); let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)); let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored); // Graphics past the limits are left out, and the surface draws without them and their paints' @@ -437,21 +444,28 @@ fn surface( && n.has_default_transparency()) .then(|| vectorcraft_three_d::visible_faces(&faces).ok()) .flatten(); - let faces = visible.unwrap_or_else(|| { - faces - .into_iter() - .map(|f| vectorcraft_three_d::VisibleFace { - path: f.path(), - brightness: f.brightness, - depth: f.depth, - gradient: f.gradient, - illumination: f.illumination, - light_gradient: f.light_gradient, - rgb_gradient: f.rgb_gradient, - }) - .collect() - }); - let depths: Vec = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect(); + let (faces, keys): (Vec, Vec) = match visible { + Some(visible) => { + let keys = visible.iter().map(|f| Drawn { depth: f.depth, points: vec![], plane: None }).collect(); + (visible, keys) + } + None => { + let keys = faces.iter().map(|f| Drawn { depth: f.depth, points: f.points.clone(), plane: f.plane }).collect(); + let faces = faces + .into_iter() + .map(|f| vectorcraft_three_d::VisibleFace { + path: f.path(), + brightness: f.brightness, + depth: f.depth, + gradient: f.gradient, + illumination: f.illumination, + light_gradient: f.light_gradient, + rgb_gradient: f.rgb_gradient, + }) + .collect(); + (faces, keys) + } + }; let children = faces .into_iter() .map(|face| { @@ -517,7 +531,21 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some((depths, group)) + Some((keys, group)) +} + +/// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the +/// drawing and its plane (none for a face trimmed to the part of it that shows). +struct Drawn { + depth: f64, + points: Vec, + plane: Option, +} + +/// `items` in `order`, a permutation of their indices. +fn in_order(items: Vec, order: &[usize]) -> Vec { + let mut slots: Vec> = items.into_iter().map(Some).collect(); + order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect() } /// A surface of one object about the bounds of `path`, built with options derived from `fx`. @@ -934,14 +962,17 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } else { let count = surfaces.len(); let mut faces = vec![]; - for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() { + for (member, (drawn, s, scopes)) in surfaces.into_iter().enumerate() { let (opacity, blend) = (s.opacity, s.blend); if let NodeKind::Group { children, .. } = s.kind { - faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face))); + faces.extend(drawn.into_iter().zip(children).map(|(drawn, face)| (drawn, member, opacity, blend, scopes, face))); } } - // A stable sort: faces at the same depth keep the members' order. - faces.sort_by(|a, b| a.0.total_cmp(&b.0)); + // A stable sort: faces at the same depth keep the members' order. Overlapping faces, of one + // member or of two, then go in the order of their depths where they overlap. + faces.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth)); + let order = crate::solids::surface_order(faces.iter().map(|(d, ..)| (d.points.as_slice(), d.plane, d.depth))); + let faces = in_order(faces, &order); if opaque { vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))] } else { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..f9a5a1abf6 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) }; } @@ -1261,6 +1261,35 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec { local_order(faces, order) } +/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to +/// front by depth: each face's polygon on the drawing, its plane and its depth. When two +/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair +/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there, +/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that +/// comes first in the given order of those whose faces before are drawn, so a face without a plane +/// or a convex polygon can also move. Otherwise the order given. +pub(crate) fn surface_order<'a>(faces: impl IntoIterator, f64)>) -> Vec { + let placed: Vec = faces + .into_iter() + .map(|(points, plane, depth)| { + let local = plane.is_some() && convex(points).is_some(); + // Every face counts as one of a stroke's on one shape, so each overlapping pair is compared. + Placed { + depth, + side: true, + away: false, + bounds: polygon_bounds(points), + member: 0, + paints: Paints::Other, + outline: if local { points.to_vec() } else { vec![] }, + plane: plane.filter(|_| local), + } + }) + .collect(); + let order = (0..placed.len()).collect(); + local_order(&placed, order) +} + /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` /// its 3D effect, about the bounds of all of them. pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { @@ -2039,31 +2068,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))?; @@ -2174,3 +2207,6 @@ mod tests_sources; #[cfg(test)] mod tests_shadow; + +#[cfg(test)] +mod tests_revolve_order; 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/solids/tests_revolve_order.rs b/crates/effects/src/solids/tests_revolve_order.rs new file mode 100644 index 0000000000..96650cf2f8 --- /dev/null +++ b/crates/effects/src/solids/tests_revolve_order.rs @@ -0,0 +1,183 @@ +use serde_json::json; + +use super::*; +use crate::revolve::{CLASSIC_REVOLVE, REVOLVE, revolve_art, revolve_options}; + +/// The first pair of faces `(polygon, plane)`, in the order given, drawn the wrong way round where +/// their polygons overlap: the earlier one nearer the viewer at the middle of the overlap. +fn misordered(faces: &[(Vec, Option)]) -> Option<(usize, usize)> { + for (i, (a, pa)) in faces.iter().enumerate() { + let Some(pa) = pa.filter(|_| convex(a).is_some()) else { continue }; + for (j, (b, pb)) in faces.iter().enumerate().skip(i + 1) { + let Some(pb) = pb else { continue }; + let Some(turn) = convex(b) else { continue }; + if !rects_overlap(polygon_bounds(a), polygon_bounds(b)) { + continue; + } + let Some(mid) = centroid(&clip_convex(a, b, turn)) else { continue }; + let (Some(da), Some(db)) = (pa.depth_at(mid), pb.depth_at(mid)) else { continue }; + if da - db > 1e-6 * (1.0 + da.abs().max(db.abs())) { + return Some((i, j)); + } + } + } + None +} + +/// An open, vase-like profile of straight edges. +fn vase() -> PathData { + let mut b = BezPath::new(); + b.move_to((30.0, 20.0)); + for p in [(90.0, 20.0), (55.0, 65.0), (95.0, 110.0), (30.0, 145.0)] { + b.line_to(p); + } + PathData::from_bezpath(&b) +} + +/// The `k`th of five closed 14 × 20 pt rectangles stepping down and away from the axis, as the +/// dashes of a dashed line's outline do. +fn dash(k: usize) -> PathData { + let (x, y) = (30.0 + 12.0 * k as f64, 20.0 + 26.0 * k as f64); + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + 14.0, y)); + b.line_to((x + 14.0, y + 20.0)); + b.line_to((x, y + 20.0)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// The five [`dash`]es as one compound path. +fn dashes() -> PathData { + PathData::new((0..5).flat_map(|k| dash(k).subpaths).collect()) +} + +/// A tilted three-quarter turn of [`vase`], whose faces sorted by their middles' depths draw a +/// nearer face under a farther one where the two overlap. +fn params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 24, "cap": true}) +} + +/// A three-quarter turn of [`dashes`], steeply tilted and in wide perspective, whose classic facets +/// sorted by their middles' depths draw a nearer facet under a farther one where the two overlap. +fn classic_params() -> Value { + json!({"angle": 270, "offset": 12, "rotationX": -60, "rotationY": 0, "perspectiveAngle": 80, "cap": true}) +} + +fn node(path: PathData) -> Node { + Node::path(NodeId(1), path, Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.8)), Paint::None, 0.0)) +} + +/// The faces of `faces` (polygon, plane and outline) in the order `art` draws their outlines. +fn drawn_order(art: &Node, faces: &[(Vec, Option, PathData)]) -> Vec<(Vec, Option)> { + let mut out = vec![]; + art.walk(&mut |n| { + if let Some(path) = n.path_data() + && let Some((points, plane, _)) = faces.iter().find(|(.., outline)| outline == path) + { + out.push((points.clone(), *plane)); + } + }); + out +} + +/// Of a tilted, revolved vase, a face sorted by the depth of its middle goes under a farther face +/// that it is in front of where the two overlap. The surface draws the nearer face last there, on +/// its own and as a member of a revolved group. +#[test] +fn a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last() { + let path = vase(); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(¶ms())).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut n = node(path.clone()); + n.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "an object"); + let mut g = Node::group(NodeId(9), vec![Arc::new(node(path))]); + g.appearance.effects.push(crate::new_effect(REVOLVE, ¶ms()).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None, "a group's member"); +} + +/// The faces of a group's members, sorted together by the depths of their middles, go in the order +/// of their depths where they overlap, those of two members as well as those of one. +#[test] +fn a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last() { + let p = json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 16, "cap": true}); + let members: Vec = (0..5).map(dash).collect(); + let b = dashes().bounds().unwrap(); + let mut faces: Vec<_> = members.iter().flat_map(|m| vectorcraft_three_d::revolve(m, b, revolve_options(&p)).unwrap()).collect(); + faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect(); + let mut g = Node::group(NodeId(9), members.into_iter().map(|m| Arc::new(node(m))).collect()); + g.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed); + assert_eq!(drawn.len(), faces.len()); + assert_eq!(misordered(&drawn), None); +} + +/// Revolve (Classic) draws the nearer of two overlapping facets last where they overlap, too. +#[test] +fn a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last() { + let path = dashes(); + // Revolve (Classic) sweeps its surface unshaded, with one facet for each cell. + let classic = vectorcraft_three_d::Revolve { + shade: false, + smooth_shading: false, + lights: None, + light_color: [1.0; 3], + ..revolve_options(&crate::merged_params(CLASSIC_REVOLVE, &classic_params())) + }; + let facets = vectorcraft_three_d::revolve_facets(&path, path.bounds().unwrap(), classic).unwrap(); + let by_depth: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane)).collect(); + assert!(misordered(&by_depth).is_some(), "the facets by depth are drawn the wrong way round somewhere"); + let keyed: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane, f.path())).collect(); + let mut n = node(path); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &classic_params()).unwrap()); + let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed); + assert_eq!(drawn.len(), facets.len()); + assert_eq!(misordered(&drawn), None); +} + +/// A surface whose faces by depth are already drawn the right way round wherever they overlap keeps +/// that order. +#[test] +fn a_surface_already_in_order_keeps_it() { + let path = vase(); + let p = json!({"angle": 360, "offset": 12, "rotationX": 0, "rotationY": 0, "segments": 24, "cap": true}); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&p)).unwrap(); + let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect(); + assert_eq!(misordered(&by_depth), None); + let order = surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth))); + assert_eq!(order, (0..faces.len()).collect::>()); +} + +/// Of two overlapping faces, the one nearer the viewer where they overlap goes after the other, +/// whatever their depths; a face without a plane, and one that overlaps neither, keep their places. +/// Two faces at the same depth there keep their order. +#[test] +fn surface_order_puts_the_nearer_face_where_they_overlap_last() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + // z = 10 - x: at the middle of its overlap with the next square (x 7.5) it is at z 2.5. + let r = std::f64::consts::FRAC_1_SQRT_2; + let sloped = Some(DepthPlane { normal: [r, 0.0, r], offset: 10.0 * r, center: Point::ZERO, eye: None }); + let flat = |z: f64| Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }); + let (a, b, apart) = (square(0.0), square(5.0), square(40.0)); + let faces = [(&a[..], sloped, 5.0), (&apart[..], flat(0.0), 1.0), (&b[..], flat(3.0), 3.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the flat face, nearer at the overlap, is already last"); + let faces = [(&b[..], flat(2.0), 2.0), (&apart[..], flat(0.0), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "the sloped face, nearer at the overlap, is already last"); + // The sloped face's middle (x 5) is at z 5, but the flat face at z 3 is nearer at the overlap. + let faces = [(&b[..], flat(3.0), 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![1, 2, 0], "the flat face is nearer at the overlap"); + let faces = [(&b[..], None, 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1, 2], "no plane"); + let faces = [(&b[..], flat(2.5), 3.0), (&a[..], sloped, 5.0)]; + assert_eq!(surface_order(faces), vec![0, 1], "the same depth there"); +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 627cc1be03..0023fc5b16 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1570,6 +1570,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 92ec32a617..a2d0316913 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/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..84fb121d0c 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -70,7 +70,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated}; -pub use export::{export_doc, export_source, unused_option_notes}; +pub use export::{export_doc, export_source, selection_doc, unused_option_notes}; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; @@ -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..a225bfcad9 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), @@ -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, ,