diff --git a/Cargo.lock b/Cargo.lock index 00fe4ca454..476af0dc89 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", diff --git a/ROADMAP.md b/ROADMAP.md index fe038ec97a..346c532277 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,8 @@ gate: all six core workflows (path drawing and editing, paint and style, type, l production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). +> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged. + ## Headline numbers | | Value | Kind | @@ -96,6 +98,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta | Simplified Chinese | `zh-hans` | partial (messages in English) | 85% | | Spanish | `es` | full | 100% | | Hindi | `hi` | catalog complete; machine-assisted draft, native review pending | complete under the scanner | +| Gujarati | `gu` | 4,673 contributor-reviewed catalog rows; 11 corrections applied to the 387 additions | menus and dialogs; status/error messages partial | | Arabic | `ar` | none (no right-to-left interface) | 0% | | French | `fr` | full | 100% | | Portuguese (Brazil) | `pt-br` | full | 100% | @@ -218,6 +221,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | | 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | | 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. | @@ -226,6 +230,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. | | 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | +| 2026-11-12 | minor | Gujarati (`gu`): reviewed by a native speaker; zero/one singular plurals (CLDR); Gujarati craft-fonts faces join the UI fonts; strings added on main since branching translated (4,286 rows) | +| 2026-11-11 | minor | Gujarati (`gu`): 4,235-row machine-assisted interface catalog, one/other plurals, desktop Gujarati font fallback; messages not yet translated; native review pending | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | | 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 2da5f0083d..9a1b11e350 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -110,6 +110,10 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build # through winit and rfd. wayland-backend = { version = "0.3", features = ["client_system"] } +# The Wayland clipboard's pipes (src/clipboard.rs): `poll` and the nonblocking flag give up a read +# or send when the other app moves no data for a while. Apache-2.0 WITH LLVM-exception OR +# Apache-2.0 OR MIT, already in the build through the workspace. +rustix = { workspace = true, features = ["std", "event", "fs"] } # The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the # systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 77a96442c4..17b9327956 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -1790,15 +1790,17 @@ mod portable { /// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads /// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and /// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for -/// a paste to wait for off the UI thread, as arboard's are. +/// a paste to wait for off the UI thread, as arboard's are. A read or send whose pipe moves no data +/// for [`IDLE_WAIT`] fails and closes the pipe, which frees its worker or thread. #[cfg(target_os = "linux")] mod wayland { - use std::io::{Read, Write}; - use std::os::fd::AsFd as _; + use std::io::{ErrorKind, Read, Write}; + use std::os::fd::{AsFd, BorrowedFd}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc}; use std::time::{Duration, Instant}; + use rustix::event::{PollFd, PollFlags, Timespec}; use vectorcraft_engine::cmd::clipboard::names_a_picture; use vectorcraft_ui_egui::ClipboardRead; use wayland_client::backend::ObjectData; @@ -1827,6 +1829,11 @@ mod wayland { const MAX_SENDS: usize = 8; /// The most types of an offer that are kept (untrusted data). const MAX_TYPES: usize = 256; + /// How long a read or send waits for the other app to move a byte through the pipe before it + /// fails, VectorCraft's own limit; the wait starts again after each byte moved. + const IDLE_WAIT: Duration = LOAD_WAIT; + /// The most bytes a read takes from the pipe at a time. + const CHUNK: usize = 64 << 10; /// What a read gives: the bytes, or why there are none. type Got = Result, String>; @@ -1898,37 +1905,117 @@ mod wayland { Some(first) } - /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read. - fn read_capped(from: impl Read, cap: usize) -> Got { - let mut data = vec![]; - from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?; - if data.len() > cap { - return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20)); + /// The time `idle` from now; None when that is past the clock's range (no limit). + fn idle_until(idle: Duration) -> Option { + Instant::now().checked_add(idle) + } + + /// Makes `fd` nonblocking, its other flags kept. + fn nonblocking(fd: BorrowedFd<'_>) -> std::io::Result<()> { + let flags = rustix::fs::fcntl_getfl(fd)?; + rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK)?; + Ok(()) + } + + /// Waits until `fd` is ready for `events`, closed or failed (true), or `until` passes (false); + /// None waits without a limit. A wait a signal cuts short goes on until `until`. + fn ready(fd: BorrowedFd<'_>, events: PollFlags, until: Option) -> std::io::Result { + loop { + let left = until.map(|t| t.saturating_duration_since(Instant::now())); + let timeout = left.and_then(|d| Timespec::try_from(d).ok()); + let mut fds = [PollFd::new(&fd, events)]; + match rustix::event::poll(&mut fds, timeout.as_ref()) { + Ok(n) => return Ok(n > 0), + Err(rustix::io::Errno::INTR) => {} + Err(e) => return Err(e.into()), + } } - Ok(data) + } + + /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read and a + /// pipe that is left open with no byte coming for `idle`. A read that fails gives none of the + /// bytes it took, and closes `from`. + fn read_capped(mut from: impl Read + AsFd, cap: usize, idle: Duration) -> Got { + let failed = |e: std::io::Error| format!("couldn't read the clipboard's data: {e}"); + nonblocking(from.as_fd()).map_err(failed)?; + let (mut data, mut chunk) = (vec![], vec![0u8; CHUNK]); + let mut until = idle_until(idle); + loop { + match from.read(&mut chunk) { + Ok(0) => return Ok(data), + Ok(n) => { + let got = chunk.get(..n).ok_or_else(|| failed(ErrorKind::InvalidData.into()))?; + if data.len() + got.len() > cap { + return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20)); + } + data.extend_from_slice(got); + until = idle_until(idle); + } + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if !ready(from.as_fd(), PollFlags::IN, until).map_err(failed)? { + return Err("the app that copied stopped sending the clipboard's data".to_string()); + } + } + Err(e) => return Err(failed(e)), + } + } + } + + /// `data` written to `to`: an error when the app that pastes closes its end (the process + /// ignores `SIGPIPE`) or takes no byte for `idle`. + fn send(mut to: impl Write + AsFd, data: &[u8], idle: Duration) -> std::io::Result<()> { + nonblocking(to.as_fd())?; + let (mut sent, mut until) = (0, idle_until(idle)); + while let Some(rest) = data.get(sent..).filter(|rest| !rest.is_empty()) { + match to.write(rest) { + Ok(0) => return Err(ErrorKind::WriteZero.into()), + Ok(n) => { + sent += n; + until = idle_until(idle); + } + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if !ready(to.as_fd(), PollFlags::OUT, until)? { + return Err(ErrorKind::TimedOut.into()); + } + } + Err(e) => return Err(e), + } + } + Ok(()) } /// The threads that send a Copy's data to the apps that paste it, counted. - #[derive(Clone, Default)] - struct Sends(Arc); + #[derive(Clone)] + struct Sends { + running: Arc, + /// How long a send waits for the app that pastes to take a byte. + idle: Duration, + } + + impl Default for Sends { + fn default() -> Self { + Self { running: Arc::default(), idle: IDLE_WAIT } + } + } impl Sends { - /// `data` written to `to` on a thread of its own, which then closes `to`; an app that - /// closes its end early ends the write (the process ignores `SIGPIPE`). While - /// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false. - fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool { - if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS { - self.0.fetch_sub(1, Ordering::SeqCst); + /// `data` written to `to` on a thread of its own ([`send`]), which then closes `to`; an app + /// that closes its end early or takes no byte for the send's idle wait ends the write. + /// While [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false. + fn start(&self, to: impl Write + AsFd + Send + 'static, data: Arc<[u8]>) -> bool { + if self.running.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS { + self.running.fetch_sub(1, Ordering::SeqCst); return false; } - let count = self.0.clone(); - let send = move || { - let _ = to.write_all(&data); - drop(to); + let (count, idle) = (self.running.clone(), self.idle); + let work = move || { + let _ = send(to, &data, idle); count.fetch_sub(1, Ordering::SeqCst); }; - if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() { - self.0.fetch_sub(1, Ordering::SeqCst); + if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(work).is_err() { + self.running.fetch_sub(1, Ordering::SeqCst); return false; } true @@ -1937,7 +2024,7 @@ mod wayland { /// How many are being written. #[cfg(test)] fn running(&self) -> usize { - self.0.load(Ordering::SeqCst) + self.running.load(Ordering::SeqCst) } } @@ -2298,7 +2385,7 @@ mod wayland { impl Receiver { /// The clipboard's data of type `wire`, read until the app that copied closes the pipe, - /// [`MAX_BYTES`] at most. + /// [`MAX_BYTES`] at most; an error when that app sends no byte for [`IDLE_WAIT`]. fn receive(&self, wire: String) -> Got { let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?; let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?; @@ -2307,7 +2394,7 @@ mod wayland { // holds the only one, and the read ends when that app closes it. drop(to); self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?; - read_capped(from, MAX_BYTES) + read_capped(from, MAX_BYTES, IDLE_WAIT) } } @@ -2557,31 +2644,86 @@ mod wayland { assert_eq!(keyboard_serial(&modifiers(9), None), None); } + /// A wait no test reaches: the other end of each pipe below either moves data or closes. + const LONG: Duration = Duration::from_secs(600); + + /// A pipe holding `data`, its write end closed when `close`. + fn pipe_with(data: &[u8], close: bool) -> (std::io::PipeReader, Option) { + let (from, mut to) = std::io::pipe().unwrap(); + to.write_all(data).unwrap(); + (from, (!close).then_some(to)) + } + #[test] fn a_read_stops_at_its_limit() { assert_eq!(MAX_BYTES, 64 * 1024 * 1024); - assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec())); - assert_eq!(read_capped(&b""[..], 8), Ok(vec![])); - assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string())); - assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string())); - /// A pipe that breaks. - struct Broken; - impl Read for Broken { - fn read(&mut self, _: &mut [u8]) -> std::io::Result { - Err(std::io::ErrorKind::BrokenPipe.into()) - } + assert_eq!(IDLE_WAIT, LOAD_WAIT); + assert_eq!(read_capped(pipe_with(b"12345678", true).0, 8, Duration::ZERO), Ok(b"12345678".to_vec())); + assert_eq!(read_capped(pipe_with(b"", true).0, 8, Duration::ZERO), Ok(vec![])); + assert_eq!(read_capped(pipe_with(&[0u8; 9], true).0, 8, Duration::ZERO), Err("the clipboard's data is larger than 0 MiB".to_string())); + // More than a pipe holds, from an app that is still sending. + let (from, to) = std::io::pipe().unwrap(); + assert!(Sends::default().start(to, vec![0u8; (2 << 20) + 1].into())); + assert_eq!(read_capped(from, 2 << 20, LONG), Err("the clipboard's data is larger than 2 MiB".to_string())); + // A pipe that can't be read: the write end. + let (_, to) = std::io::pipe().unwrap(); + let unreadable = std::fs::File::from(std::os::fd::OwnedFd::from(to)); + assert!(read_capped(unreadable, 8, LONG).is_err_and(|e| e.starts_with("couldn't read the clipboard's data"))); + } + + #[test] + fn a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe() { + let stopped = Err("the app that copied stopped sending the clipboard's data".to_string()); + // Some bytes and then nothing, the pipe left open: none of them are pasted. + let (from, to) = pipe_with(b"part", false); + assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped); + // The read end is closed, so that app's next write fails. + let mut to = to.unwrap(); + assert_eq!(to.write(b"x").map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe)); + // Nothing at all, and data that fills the limit exactly with more never coming. + let (from, _held) = pipe_with(b"", false); + assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped); + let (from, _held) = pipe_with(b"12345678", false); + assert_eq!(read_capped(from, 8, Duration::ZERO), stopped); + // The next read gets its data. + assert_eq!(read_capped(pipe_with(b"words", true).0, MAX_BYTES, Duration::ZERO), Ok(b"words".to_vec())); + } + + #[test] + fn a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread() { + let data: Arc<[u8]> = vec![3u8; 300_000].into(); + // More than a pipe holds, to an app that never reads. + let (_held, to) = std::io::pipe().unwrap(); + assert_eq!(send(to, &data, Duration::ZERO).map_err(|e| e.kind()), Err(ErrorKind::TimedOut)); + // An app that closes its end before reading. + let (from, to) = std::io::pipe().unwrap(); + drop(from); + assert_eq!(send(to, &data, LONG).map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe)); + // A send thread to an app that does not read until the send gives up, its end still open. + let sends = Sends { idle: Duration::ZERO, ..Sends::default() }; + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, data.clone())); + while sends.running() > 0 { + std::thread::yield_now(); } - assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data"))); + // What the pipe held when the send gave up, then the end of the data: the send closed it. + let got = read_capped(from, MAX_BYTES, Duration::ZERO).map(|d| d.len()); + assert!(got.as_ref().is_ok_and(|&n| n > 0 && n < data.len()), "{got:?}"); + // The thread is free for the next paste. + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, b"words"[..].into())); + assert_eq!(read_capped(from, MAX_BYTES, LONG), Ok(b"words".to_vec())); } #[test] fn a_copys_data_is_sent_through_the_pipe_and_closed() { let sends = Sends::default(); + assert_eq!(sends.idle, IDLE_WAIT); let (from, to) = std::io::pipe().unwrap(); let data: Arc<[u8]> = vec![7u8; 300_000].into(); assert!(sends.start(to, data.clone())); // More than a pipe holds: the write ends once all of it is read, then the pipe closes. - assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..])); + assert_eq!(read_capped(from, MAX_BYTES, LONG).as_deref(), Ok(&data[..])); // An app that closes its end before reading ends the send. let (from, to) = std::io::pipe().unwrap(); drop(from); @@ -2593,9 +2735,9 @@ mod wayland { #[test] fn pastes_that_read_nothing_hold_a_few_sends_at_most() { - let sends = Sends::default(); + let sends = Sends { idle: LONG, ..Sends::default() }; let data: Arc<[u8]> = vec![1u8; 300_000].into(); - // Apps that ask for the data and never read it. + // Apps that ask for the data and never read it, within the sends' idle wait. let stalled: Vec<_> = (0..MAX_SENDS) .map(|_| { let (from, to) = std::io::pipe().unwrap(); @@ -2606,7 +2748,7 @@ mod wayland { // The next paste gets its pipe closed at once, with nothing written. let (from, to) = std::io::pipe().unwrap(); assert!(!sends.start(to, data.clone())); - assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![])); + assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), Ok(vec![])); // Once those apps close their pipes, the sends end and others are sent again. drop(stalled); while sends.running() > 0 { @@ -2614,7 +2756,7 @@ mod wayland { } let (from, to) = std::io::pipe().unwrap(); assert!(sends.start(to, data.clone())); - assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len())); + assert_eq!(read_capped(from, MAX_BYTES, LONG).map(|d| d.len()), Ok(data.len())); } /// What a clipboard holds, by type. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fdbec59bfc..3397ada7a5 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -373,11 +373,14 @@ fn main() -> std::process::ExitCode { // Display Server › X11 opens the window through XWayland instead, where file drops work (#1151), // and the other choices do so when a pen is attached, where the pen draws (#2028, #4967). #[cfg(target_os = "linux")] + let mut forced_to_x11 = false; + #[cfg(target_os = "linux")] let options = { let mut options = options; let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) { if xwayland::x11_libraries_found() { + forced_to_x11 = true; if pref != Some("x11") { log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)"); } else { @@ -393,6 +396,11 @@ fn main() -> std::process::ExitCode { } options }; + #[cfg(not(target_os = "linux"))] + let forced_to_x11 = false; + // Preferences › User Interface notes under System Title Bar when the desktop draws the frame + // with it on or off. + let desktop_keeps_frame = titlebar::desktop_keeps_frame(forced_to_x11); // With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end // is down; the message hook notes each pointer contact and whether it starts with the barrel // button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the @@ -595,6 +603,7 @@ fn main() -> std::process::ExitCode { app.custom_titlebar = custom_titlebar; app.caption_layout = caption_layout; app.desktop_frame = !custom_titlebar; + app.desktop_keeps_frame = desktop_keeps_frame; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 99ca880bc9..1994569c0c 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -346,6 +346,34 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { } } +/// Preferences says under System Title Bar that the desktop draws the frame with it on or off +/// exactly where the start frame without VectorCraft Title Bar on Every Desktop is the desktop's +/// with System Title Bar off as well as on and `VECTORCRAFT_TITLEBAR` is unset: Linux and the BSDs +/// only. +#[test] +fn the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it() { + use crate::titlebar::desktop_keeps_frame_from; + use craft_window::{Frame, Layout}; + let linux = cfg!(not(any(target_os = "macos", target_os = "windows"))); + for probed in [Frame::Desktop, Frame::App(Layout::default())] { + for forced_to_x11 in [false, true] { + let frame = super::start_frame(probed.clone(), None, forced_to_x11, false); + let either_way = !super::custom_titlebar_from(&frame, false) && !super::custom_titlebar_from(&frame, true); + assert_eq!(desktop_keeps_frame_from(&probed, None, forced_to_x11), linux && either_way, "{probed:?} {forced_to_x11}"); + } + } + // KDE, Sway and X11, and GNOME through XWayland, keep the desktop's frame; GNOME on Wayland does not. + assert_eq!(desktop_keeps_frame_from(&Frame::Desktop, None, false), linux); + assert_eq!(desktop_keeps_frame_from(&Frame::App(Layout::default()), None, true), linux); + assert!(!desktop_keeps_frame_from(&Frame::App(Layout::default()), None, false)); + // While `VECTORCRAFT_TITLEBAR` chooses the frame, System Title Bar does not apply and there is no note. + for (frame, overridden) in [(Frame::App(Layout::default()), Some(true)), (Frame::Desktop, Some(false))] { + for forced_to_x11 in [false, true] { + assert!(!desktop_keeps_frame_from(&frame, overridden, forced_to_x11), "{overridden:?} {forced_to_x11}"); + } + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 88dce26897..cb98fa2648 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -191,6 +191,25 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> ( (custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame)) } +/// Whether the desktop would draw this window's frame with System Title Bar on or off while +/// VectorCraft Title Bar on Every Desktop is off: `VECTORCRAFT_TITLEBAR` is not set to `app`, +/// `system` or `desktop` (`overridden` is `None`), and the probe chose +/// [`craft_window::Frame::Desktop`] or the window opens through XWayland (`forced_to_x11`) +/// ([`app_draws_frame`]). The saved VectorCraft Title Bar on Every Desktop is left out, so the value +/// stays `true` on such a desktop while that preference gives the app the frame. Always `false` on +/// macOS and Windows, where System Title Bar decides the frame by itself. +pub(crate) fn desktop_keeps_frame_from(probed: &craft_window::Frame, overridden: Option, forced_to_x11: bool) -> bool { + let probed_app = matches!(probed, craft_window::Frame::App(_)); + cfg!(not(any(target_os = "macos", target_os = "windows"))) && overridden.is_none() && !app_draws_frame(probed_app, None, forced_to_x11, false) +} + +/// [`desktop_keeps_frame_from`] for this start's probe, given whether the launch path opens the +/// window through XWayland. +pub(crate) fn desktop_keeps_frame(forced_to_x11: bool) -> bool { + let choice = probed_choice(); + desktop_keeps_frame_from(choice.frame(), choice.overridden(), forced_to_x11) +} + pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { eframe::NativeOptions { viewport: egui::ViewportBuilder::default() diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 2cf9f43c97..00edeb3113 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -114,51 +114,9 @@ fn strip_brushes(n: &mut Node) { } } -/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer. -pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { - let amount = amount.clamp(0.0, 1.0); - for it in &mut n.appearance.items { - let p = match it { - AppearanceItem::Fill(f) => &mut f.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Solid { color: c, .. } = p { - *p = Paint::solid(c.lerp(color, amount)); - } - } - if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - for p in [&mut r.style.fill, &mut r.style.stroke] { - if let Paint::Solid { color: c, .. } = p { - *p = Paint::solid(c.lerp(color, amount)); - } - } - } - } - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - tint_node(Arc::make_mut(c), color, amount); - } - } -} - -/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's -/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one -/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links. -pub fn stain(inst: &Node) -> Option<(Color, f32)> { - let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?; - Some((f.paint.color()?, f.opacity)) -} - -/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic -/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]). -pub fn instance_art(art: &Node, inst: &Node) -> Node { - let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst); - if let Some((c, amount)) = stain(inst) { - tint_node(&mut a, &c, amount); - } - a -} +/// The Symbol Stainer's mix, an instance's stain and its stained art, from +/// [`vectorcraft_doc::symbol_overrides`], which the effects that reshape an instance also read. +pub use vectorcraft_doc::symbol_overrides::{instance_art, stain, tint_node}; /// The solid colours of the fills and strokes under `nodes`, most used first (then in the order /// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 4860e4989a..ea3c962053 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -683,7 +683,7 @@ impl<'a> Scene<'a> { { // Symbols holding instances of symbols are written in full for each instance, // empty or not: every instance's art counts against one budget for the export. - let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); let count = art.count_with_masks() as u64; // Counted as `place_symbol_art_unclipped` places it: no clip groups. let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc)); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 96c037a4a7..a14af8d929 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1395,6 +1395,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } +/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it. +#[test] +fn a_stained_instance_is_written_in_its_stained_color() { + let mut d = doc_with(|d| { + let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) }); + n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into(); + vec![n] + }); + let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) }); + let out = export(&d, &opts(&d)).unwrap(); + let colors: Vec> = entities(&out).iter().map(|e| get(e, 420).map(str::to_string)).collect(); + assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 43074a4e80..d561663524 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -363,6 +363,14 @@ pub struct SeriesPaint { /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub blend: Option, + /// A gradient in [`Self::fill`] is placed relative to the unit box (0, 0)–(1, 1), and each mark takes it on its own + /// geometric bounds. False: the gradient is in document coordinates, and every mark takes it unchanged. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub fill_unit_box: bool, + /// A gradient in [`Self::stroke`] is placed relative to the unit box, as [`Self::fill_unit_box`], and each mark takes + /// it on its stroke's box (the geometric bounds grown by half the weight). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_unit_box: bool, } /// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index e524d6ca37..a2ff76d83f 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -3,6 +3,8 @@ //! square to it: scaling by a side handle stretches it along its own width or height. Path //! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0. +use std::sync::Arc; + use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt}; use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes}; @@ -84,7 +86,35 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 { } } +/// The turn (counter-clockwise degrees) from the boxes of art placed by a symbol instance's +/// transform `xf` to the instance's own box at `angle`: 0 while the box turns with the transform, +/// otherwise the change Reset Bounding Box made to it. Under a mirroring `xf` the instance's box can +/// stand 180° from the one `xf` gives in one step ([`transformed_angle`] keeps the angle nearer the +/// one before at each step); the turn is then 180°, so the art's boxes match the instance's. +pub fn placed_turn(angle: f64, xf: Affine) -> f64 { + let turn = normalize_deg(angle - transformed_angle(0.0, xf)); + if turn.is_finite() && turn.abs() > EPS_DEG { turn } else { 0.0 } +} + impl Node { + /// Turns the box of this object, and of every object that turns with it ([`Node::transform`]: + /// its children and the art of a linked opacity mask), by `by` degrees counter-clockwise. + /// Layers have no box. + pub fn turn_boxes(&mut self, by: f64) { + if !self.is_layer() { + let a = normalize_deg(self.bbox_angle + by); + self.bbox_angle = if a.abs() <= EPS_DEG { 0.0 } else { a }; + } + for c in self.children_mut().into_iter().flatten() { + Arc::make_mut(c).turn_boxes(by); + } + if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + Arc::make_mut(&mut m.art).turn_boxes(by); + } + } + /// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes), /// without changing it, symbol instances measured by their art from `symbols` /// ([`Node::geometric_bounds_with`]). @@ -185,6 +215,44 @@ mod tests { assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0); } + #[test] + fn placed_turn_is_what_reset_bounding_box_took_off_the_instance() { + let xf = Affine::translate((40.0, 10.0)) * rot(20.0) * Affine::scale_non_uniform(2.0, 0.5); + // The box turns with the transform: no turn. + assert_eq!(placed_turn(transformed_angle(0.0, xf), xf), 0.0); + assert_eq!(placed_turn(0.0, Affine::scale(3.0)), 0.0); + // Reset to 0: the placed art turns back by the 20°. + assert!(close(placed_turn(0.0, xf), -20.0)); + // Mirrored: turned 170° and then flipped, the instance's box stands 180° from the box the + // whole transform gives in one step, and the art turns the half turn to match it. + let flip = Affine::scale_non_uniform(1.0, -1.0); + let flipped = flip * rot(170.0); + let stepwise = transformed_angle(transformed_angle(0.0, rot(170.0)), flip); + assert!(close(stepwise, -170.0) && close(transformed_angle(0.0, flipped), 10.0)); + assert!(close(placed_turn(stepwise, flipped).abs(), 180.0)); + assert!(close(normalize_deg(placed_turn(0.0, flipped) + transformed_angle(0.0, flipped)), 0.0)); + // Degenerate and non-finite transforms turn nothing. + assert_eq!(placed_turn(0.0, Affine::scale(0.0)), 0.0); + assert_eq!(placed_turn(f64::NAN, xf), 0.0); + } + + #[test] + fn turn_boxes_turns_children_and_linked_mask_art_but_not_layers() { + let mut d = Document::new(500.0, 500.0); + let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(a.clone())]); + g.bbox_angle = 170.0; + g.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(a.clone(), true))); + let mut layer = (*d.layers[0]).clone(); + layer.children_mut().unwrap().push(Arc::new(g)); + layer.turn_boxes(20.0); + assert_eq!(layer.bbox_angle, 0.0); + let g = &layer.children().unwrap()[0]; + assert!(close(g.bbox_angle, -170.0), "wraps to (-180, 180]"); + assert!(close(g.children().unwrap()[0].bbox_angle, 20.0)); + assert!(close(g.extras().mask.as_ref().unwrap().art.bbox_angle, 20.0)); + } + #[test] fn oriented_bounds_hug_a_rotated_rectangle() { let mut d = Document::new(500.0, 500.0); diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 53f84e869c..3c8c1b324d 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -614,6 +614,8 @@ impl NineSliceMap { /// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of /// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed /// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`]. + /// The clip groups and clip paths take the angle of the cell map that places them. The group + /// and each copy keep `n`'s box angle, turned by the map that places them. /// /// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own /// effects, after its last other visible effect, go on that group with its opacity, blending @@ -647,11 +649,15 @@ impl NineSliceMap { area.transform(local); let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false }); clip.appearance = Appearance::default().into(); + clip.bbox_angle = crate::orient::transformed_angle(0.0, local); let copy = transformed(n, local * self.natural); - cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }))); + let mut cell = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }); + cell.bbox_angle = crate::orient::transformed_angle(0.0, local); + cells.push(Arc::new(cell)); } } let mut out = Node::group(n.id, cells); + out.bbox_angle = crate::orient::transformed_angle(n.bbox_angle, center); out.visible = n.visible; out.locked = n.locked; out.name = n.name.clone(); diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs index 33f0fd7695..241c61fba8 100644 --- a/crates/doc/src/symbol_overrides.rs +++ b/crates/doc/src/symbol_overrides.rs @@ -2,14 +2,15 @@ //! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays //! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops //! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and -//! applied wherever an instance is drawn or exported ([`dynamic_art`]). +//! applied wherever an instance is drawn or exported ([`dynamic_art`]), and an instance's fill +//! stains the art after them ([`instance_art`]). use std::sync::Arc; use serde::{Deserialize, Serialize}; -use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; -use crate::{Node, NodeKind}; +use crate::{AppearanceItem, Node, NodeKind}; /// Deepest object an override may name: groups nested this deep in the symbol's art. pub const MAX_PATH_DEPTH: usize = 64; @@ -150,6 +151,52 @@ pub fn dynamic_art(art: &Node, inst: &Node) -> Node { a } +/// Mix every solid paint under `n` towards `color` by `amount` (0..1): the Symbol Stainer. +pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { + let amount = amount.clamp(0.0, 1.0); + for it in &mut n.appearance.items { + let p = match it { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Solid { color: c, .. } = p { + *p = Paint::solid(c.lerp(color, amount)); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + for p in [&mut r.style.fill, &mut r.style.stroke] { + if let Paint::Solid { color: c, .. } = p { + *p = Paint::solid(c.lerp(color, amount)); + } + } + } + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + tint_node(Arc::make_mut(c), color, amount); + } + } +} + +/// The stain instance `inst` puts on its symbol's art: its visible fill color and that fill's +/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one +/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links. +pub fn stain(inst: &Node) -> Option<(Color, f32)> { + let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?; + Some((f.paint.color()?, f.opacity)) +} + +/// A symbol's art for instance `inst`, as the canvas draws it: with the instance's own appearance +/// changes to it ([`dynamic_art`]), stained by the instance's fill ([`stain`]). +pub fn instance_art(art: &Node, inst: &Node) -> Node { + let mut a = dynamic_art(art, inst); + if let Some((c, amount)) = stain(inst) { + tint_node(&mut a, &c, amount); + } + a +} + /// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest. pub fn retain_parts(overrides: &mut Vec, art: &Node) { overrides.retain(|o| part(art, &o.path).is_some()); @@ -159,7 +206,6 @@ pub fn retain_parts(overrides: &mut Vec, art: &Node) { mod tests { use super::*; use crate::{Appearance, NodeId}; - use vectorcraft_color::Color; use vectorcraft_geom::{Rect, shapes}; fn square(id: u64) -> Arc { diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index 4193b085ef..1b9ac80452 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -523,13 +523,14 @@ fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) { /// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in /// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art` -/// when given, otherwise the symbol's art with the instance's own appearance changes. +/// when given, otherwise the symbol's art with the instance's own appearance changes, stained by +/// the instance's fill ([`vectorcraft_doc::symbol_overrides::instance_art`]). pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { let symbol = symbol_art - .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))); + .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n))); art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 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/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/solids.rs b/crates/effects/src/solids.rs index 7500af0617..ac9de02cad 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; use vectorcraft_pathops::BoolOp; -use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -656,7 +656,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { #[cfg(test)] thread_local! { - /// How many solids [`built_solids`] has built on this thread, for the tests. + /// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests. static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -2039,31 +2039,35 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); - // The solids the art draws, past the limits of the object's solids together as it is - // ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped. - let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); - // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel - // solid and its mesh are built as the loop reaches them. + // Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel + // solid is built once, as the loop reaches it: its facets decide whether the art draws it, as + // [`built_solids`] does, and its faces before projection make its mesh. let mut inflated = (fx.id == INFLATE).then(|| { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view) }); let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; - for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let mut kept = 0usize; + for (item, member, area, rule, _, _) in &parts { let item: &AppearanceItem = item; - if !drawn? { - // A stroke the art leaves out is left out of the meshes too; Inflate's next puff - // still belongs to the next part. - if let Some(built) = inflated.as_mut() { - built.next(); - } - continue; - } - let mesh = match inflated.as_mut() { - Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), - None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + let built = match inflated.as_mut() { + Some(each) => each.next().unwrap_or_else(|| Ok(MeshedSolid::default())), + None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view), }; - match mesh { + // A stroke the art leaves out is left out of the meshes too. The faces of the solids kept + // count together against the object's limit. + let solid = match built { + Ok(solid) => solid, + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue, + Err(e) => return Err(e), + }; + kept = kept.saturating_add(solid.facets.len()); + if kept > MAX_OBJECT_FACES { + return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()); + } + match solid.mesh() { Ok(mesh) => { let (paint, opacity, _) = paint_of(item, *member); keep(&mut out, mesh, mesh_color(&paint, opacity))?; diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index aeefafbc77..a9de7cd7f9 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -208,3 +208,151 @@ fn a_gradient_takes_the_average_of_its_stops() { let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY }; assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5])); } + +/// The meshes of Extrude & Bevel or Inflate `fx` on `shapes`, each built apart from the facets that +/// decide which solids are drawn ([`built_solids`], then [`vectorcraft_three_d::extrude_mesh`] or +/// [`vectorcraft_three_d::inflate_meshes`]), for comparing with [`meshes_for`]. +fn meshes_built_apart(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result, String> { + let p = crate::merged_params(&fx.id, &fx.params); + let view = view_options(&p); + let (parts, bounds) = solid_parts(shapes, union_bounds(shapes).unwrap()); + let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); + let mut inflated = (fx.id == INFLATE).then(|| { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + }); + let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; + let mut out = vec![]; + for ((item, opacity, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let item: &AppearanceItem = item; + if !drawn? { + if let Some(built) = inflated.as_mut() { + built.next(); + } + continue; + } + let mesh = match inflated.as_mut() { + Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), + None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + }; + match mesh { + Ok(mesh) => { + let (paint, opacity, _) = paint_of(item, *opacity); + keep_mesh(&mut out, total, SolidMesh { mesh, color: mesh_color(&paint, opacity) }, limit)?; + } + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// The bits of every number in `meshes`, for comparing two exports exactly. +type MeshBits = Vec<([u32; 4], Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>)>; + +fn mesh_bits(meshes: &[SolidMesh]) -> MeshBits { + meshes + .iter() + .map(|m| { + let (positions, normals) = (&m.mesh.positions, &m.mesh.normals); + ( + m.color.map(f32::to_bits), + positions.iter().map(|p| p.map(f64::to_bits)).collect(), + normals.iter().map(|n| n.map(f64::to_bits)).collect(), + m.mesh.triangles.clone(), + ) + }) + .collect() +} + +/// A five-pointed star about `(x, 150)` with curved edges, filled red and stroked black. +fn star_node(id: u64, x: f64) -> Node { + let mut b = BezPath::new(); + for k in 0..10 { + let (s, c) = (k as f64 * std::f64::consts::PI / 5.0).sin_cos(); + let r = if k % 2 == 0 { 60.0 } else { 25.0 }; + let p = (x + r * c, 150.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 4.0, p.1), (p.0, p.1 + 4.0), p) } + } + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 6.0)) +} + +/// The shapes `meshes_for` builds from: one node's, or each of several. +fn shapes_of(nodes: &[&Node]) -> Vec { + nodes + .iter() + .map(|n| Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }) + .collect() +} + +/// Building each Extrude & Bevel and Inflate solid once exports the same meshes, to the bit, as +/// building its facets and its mesh apart, within and past the triangle budget. +#[test] +fn one_build_exports_the_meshes_built_apart() { + let mut second_stroke = square_node(3, 400.0); + if let Some(stroke) = second_stroke.appearance.items.get(1).cloned() { + second_stroke.appearance.items.push(stroke); + } + let (star, square, other) = (star_node(1, 100.0), square_node(2, 250.0), star_node(4, 600.0)); + let cases = [ + (EXTRUDE, json!({})), + (EXTRUDE, json!({ "bevel": "rounded", "bevelWidth": 6.0, "bevelHeight": 5.0, "twist": 60.0, "taper": 70.0 })), + (EXTRUDE, json!({ "bevel": "cove", "bevelInside": true, "bevelBothSides": true, "cap": false, "perspectiveAngle": 50.0 })), + (EXTRUDE, json!({ "depth": 0.0 })), + (CLASSIC_EXTRUDE, json!({ "bevel": "classic", "bevelHeight": 8.0 })), + (INFLATE, json!({})), + (INFLATE, json!({ "depth": 30.0, "bothSides": true, "volume": 80.0 })), + (INFLATE, json!({ "depth": 40.0, "twist": 360.0, "taper": 70.0, "cap": false })), + (INFLATE, json!({ "depth": 0.1, "twist": 0.1, "bothSides": true })), + ]; + let sets: [Vec<&Node>; 3] = [vec![&star], vec![&second_stroke], vec![&star, &square, &other]]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + for nodes in &sets { + let shapes = shapes_of(nodes); + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, MAX_MESH_TRIANGLES).unwrap_or_else(|e| panic!("{id} {params}: {e}")); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, MAX_MESH_TRIANGLES).unwrap(); + assert!(!reference.is_empty()); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id} {params}"); + assert_eq!(once, apart); + // Past the budget, at the same mesh, with the same count. + let limit = once - 1; + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, limit); + assert!(built.is_err(), "{id} {params}"); + assert_eq!(built.err(), meshes_built_apart(&effect, &shapes, &mut apart, limit).err(), "{id} {params}"); + assert_eq!(once, apart, "{id} {params}"); + } + } +} + +/// A stroke whose solid is past the limits is left out of the export and the fill's mesh exports, +/// as when the facets and the mesh are built apart. +#[test] +fn a_stroke_past_the_limits_is_left_out_of_the_export() { + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) + }) + .collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + let n = Node::path(NodeId(1), path, Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 2.0)); + for id in [EXTRUDE, INFLATE] { + // Without caps: a cap of 40,000 corners takes more work to cut into triangles than an + // export allows. + let effect = fx(id, json!({ "cap": false })); + let shapes = shapes_of(&[&n]); + // The fill's mesh alone has more triangles than an export takes. + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, usize::MAX).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(built.len(), 1, "{id}: the fill's mesh alone"); + assert!(close(built[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}"); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, usize::MAX).unwrap(); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id}"); + } +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 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 94d8add68b..19695fd4ff 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -440,6 +440,15 @@ pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), } } +/// The whole number of 0 or more that `p` gives for `key`. A missing key or null is "missing integer +/// `{key}`"; any other value, such as `"3"`, `2.5`, `-1` or `true`, is "`{key}` must be a whole +/// number", as [`object_param`] words it. +pub(crate) fn u64_req(p: &Value, key: &str, cmd: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Err(bad(cmd, format!("missing integer `{key}`"))), + Some(v) => v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), + } +} /// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other /// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}". pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result { @@ -550,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result usize { usize::try_from(v).unwrap_or(usize::MAX) } +/// The whole number of 0 or more that `p` gives for `key`, read with [`index`], or `None` when `p` +/// leaves it out or gives null. Any other value, such as `"3"`, `2.5`, `-1` or `true`, is an error +/// under `cmd`: "`{key}` must be a whole number". +pub(crate) fn index_opt(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), + } +} /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { checked_nodes_param(s, p, key, command, false) diff --git a/crates/engine/src/cmd/addons/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/edit.rs b/crates/engine/src/cmd/edit.rs index 990ac1a1c6..9ecb5c3984 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -11,7 +11,7 @@ use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or, - object_param, ok, point_param, str_param, switch_or, + object_param, ok, point_param, str_param, switch_or, u64_req, }; use crate::{Clipboard, EngineError, SourceLayer, inspect}; @@ -249,7 +249,10 @@ fn file_close(s: &mut Session, p: &Value) -> Result { } fn doc_activate(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?; + let i = match p.get("index") { + None | Some(Value::Null) => return Err(bad("document.activate", "missing index")), + Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.activate", "`index` must be a whole number"))?, + }; if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) } } @@ -262,7 +265,10 @@ fn doc_move(s: &mut Session, p: &Value) -> Result { if from >= n { return Err(bad("document.move", "no such document")); } - let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?; + let to = match p.get("to") { + None | Some(Value::Null) => return Err(bad("document.move", "missing `to`: a non-negative integer")), + Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`to` must be a whole number"))?, + }; let to = to.min(n.saturating_sub(1)); if !s.move_document(from, to) { return Err(bad("document.move", "no such document")); @@ -562,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result { Ok(json!({ "units": next.label() })) } -/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given. +/// The `artboard` param of the ruler origin commands: an artboard index, 0 when left out or null. A +/// value that is not a whole number is "`artboard` must be a whole number"; one past the artboards +/// is "index out of range". fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { - let index = match p.get("artboard") { - None => 0, - Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?, - }; + let index = super::index_opt(p, "artboard", c)?.unwrap_or(0); if index >= s.doc()?.doc.artboards.len() { return Err(bad(c, "index out of range")); } @@ -577,14 +582,15 @@ fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { fn ruler_origin(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; // A document without artboards measures from its global zero point. - let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; + let index = + if d.artboards.is_empty() && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; Ok(json!(s.doc()?.doc.ruler_origin_for(index))) } fn set_ruler_origin(s: &mut Session, p: &Value) -> Result { const C: &str = "document.setRulerOrigin"; let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? }; + let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, C)? }; change_ruler_origin(s, index, Some(origin), "Ruler Origin") } @@ -1190,8 +1196,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { /// Document ids scope node ids: two tabs may contain objects with the same node id. fn drop_selection(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.dropSelection"; - let key = "sourceDocument"; - let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?; + let uid = u64_req(p, "sourceDocument", CMD)?; let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates"))); let grab = point("grab")?; let at = point("at")?; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 6ee3905d95..65672f8b6b 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -865,8 +865,8 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several /// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with /// fills, strokes or effects of its own or such members, other objects with effects, type with -/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry -/// effect ([`effects::has_item_geometry`])? +/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry or +/// raster effect ([`effects::has_item_geometry`], [`effects::has_instance_row_raster`])? pub(crate) fn expandable(n: &Node) -> bool { expandable_except(n, &[]) } @@ -883,7 +883,9 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), - NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + effects::has_item_geometry(n) || effects::has_instance_row_raster(n) || !n.appearance.effects.is_empty() + } _ => !n.appearance.effects.is_empty(), } } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index d20c6c46b2..1ff1897c85 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads, leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 28e12c115d..03736c6022 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,