diff --git a/Cargo.lock b/Cargo.lock index 8365b32a69..78306baae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3282,6 +3282,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -6052,8 +6053,10 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-core-text", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "pollster 1.0.1", "raw-window-handle", "rfd", diff --git a/README.md b/README.md index bf358b2d78..fd45eb9cdd 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files, and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs. [Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md). The interface -speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian; +speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech, Georgian and Catalan; [localization-parity.md](docs/localization-parity.md) has the details. The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. diff --git a/ROADMAP.md b/ROADMAP.md index 28c15f3268..8231428eeb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,7 +112,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta | Korean | `ko` | full | 100% | | Vietnamese | `vi` | full | 100% | -Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending). +Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Catalan (full; AI-drafted, native review welcome), Czech (catalog complete; render-site gaps reduced; native review pending). The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md). ## Upcoming diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index d01d24eb53..1137c0d6b9 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -58,7 +58,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_G # with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.2", default-features = false } objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } # Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd. objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] } block2 = "0.6" @@ -70,6 +70,10 @@ raw-window-handle = "0.6" # objc2-foundation. objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] } objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] } +# The windows' Metal layers and the sRGB color space they are tagged with (src/mac_color_space.rs). +# Zlib OR Apache-2.0 OR MIT, already in the build through wgpu with these features. +objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "CALayer", "CAMetalLayer"] } +objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColorSpace"] } # Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0, # already in the build through eframe. pollster = "1" diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 3161d1a371..2847004b34 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,7 +1,11 @@ +#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] +use super::gpu; use super::prefs::save_prefs; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; +#[cfg(target_os = "macos")] +use super::{mac_menu, mac_window, open_documents}; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -59,6 +63,9 @@ impl eframe::App for App { let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar); self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET); } + // macOS converts the frame from sRGB to the display's profile (#2249). + #[cfg(all(target_os = "macos", feature = "wgpu"))] + super::mac_color_space::tag(ui); file_access::unconfined(|| self.app.ui(ui)); // Preferences are written when confirmed, not only at exit: a session ended any other way // (the system shutting down, the process ended or crashing) used to lose them (#1747). diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 0b228f62cf..ef3832f1e2 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -415,7 +415,7 @@ mod mac { use objc2_app_kit::NSPasteboard; use objc2_foundation::{NSArray, NSString}; - use super::*; + use super::{Flavour, pasteboard_types}; /// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only. pub fn has(mimes: &[&'static str]) -> bool { diff --git a/apps/vectorcraft/src/control_server/tests.rs b/apps/vectorcraft/src/control_server/tests.rs index 7fa3f27e7e..e3d946ef0a 100644 --- a/apps/vectorcraft/src/control_server/tests.rs +++ b/apps/vectorcraft/src/control_server/tests.rs @@ -4,12 +4,18 @@ fn short_timeouts() -> ConnectionTimeouts { ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) } } -/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a -/// busy machine either thread can pause for more than 150 ms between connecting and sending, or -/// between starting a reply and writing it, and a short budget then closes the connection with no -/// reply. +/// How long a test waits for the server to reply or to close a connection. The wait only ends a +/// test whose server never does either; the budgets under test are 120 and 150 ms, and 1 s in the +/// persistent test. On a busy machine the server's thread can pause for seconds before or between +/// its reads and writes, and a wait of a few seconds then fails a server that closes the +/// connection on time. +const WAIT: Duration = Duration::from_secs(20); + +/// The short line deadline, with idle and reply budgets longer than [`WAIT`]. On a busy machine +/// either thread can pause for more than 150 ms between connecting and sending, or between +/// starting a reply and writing it, and a short budget then closes the connection with no reply. fn line_timeouts() -> ConnectionTimeouts { - ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() } + ConnectionTimeouts { idle: Duration::from_secs(30), write: Duration::from_secs(30), ..short_timeouts() } } fn socket_fixture( @@ -27,12 +33,12 @@ fn socket_fixture( }); let client = TcpStream::connect(addr).unwrap(); socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap(); - client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + client.set_read_timeout(Some(WAIT)).unwrap(); (client, done, worker) } fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) { - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + let stopped = done.recv_timeout(WAIT).is_ok(); // Always unblock the old implementation too, so a failed regression leaves no worker behind. let _ = client.shutdown(std::net::Shutdown::Both); drop(client); @@ -93,7 +99,7 @@ fn trickled_control_line_has_an_absolute_deadline() { std::thread::sleep(Duration::from_millis(20)); } }); - let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok(); + let stopped = done.recv_timeout(WAIT).is_ok(); stop.store(true, Ordering::SeqCst); // Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well. let _ = client.shutdown(std::net::Shutdown::Both); @@ -110,26 +116,31 @@ fn a_nonreading_control_peer_cannot_retain_the_writer() { expect_socket_deadline(client, done, worker); } +/// Each request gets its own line and reply budgets. The handler takes longer than both budgets, +/// so a deadline kept from the previous request runs out. The line and reply budgets are 1 s, and +/// the idle budget is 30 s, longer than the client's [`WAIT`]: on a busy machine either thread can +/// pause for more than the short budgets of [`short_timeouts`] between starting a budget and the +/// read or write it covers. #[test] fn persistent_control_requests_get_fresh_transport_deadlines() { - let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() }; + let timeouts = ConnectionTimeouts { idle: Duration::from_secs(30), line: Duration::from_secs(1), write: Duration::from_secs(1) }; let (mut client, done, worker) = socket_fixture(timeouts, |_, _| { // Application processing is not transport time; begin the reply budget afterwards. - std::thread::sleep(Duration::from_millis(200)); + std::thread::sleep(Duration::from_millis(1250)); Some(json!({"ok": true})) }); let mut reader = BufReader::new(client.try_clone().unwrap()); for id in [1, 2] { // The client sends each line in one write. `writeln!` on the socket sends the JSON in many - // small writes, and when the machine is busy, a pause of more than the 120 ms line deadline - // between two of them closes the connection. + // small writes, and when the machine is busy, a pause of more than the line deadline between + // two of them closes the connection. client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap(); let mut line = String::new(); reader.read_line(&mut line).unwrap(); assert_eq!(serde_json::from_str::(&line).unwrap(), json!({"id": id, "ok": true})); } client.shutdown(std::net::Shutdown::Write).unwrap(); - done.recv_timeout(Duration::from_secs(3)).unwrap(); + done.recv_timeout(WAIT).unwrap(); worker.join().unwrap(); } @@ -155,7 +166,7 @@ fn timed_out_worker_returns_its_connection_slot() { let (tx, _rx) = channel(); let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap(); let start = std::time::Instant::now(); - while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) { + while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < WAIT { std::thread::sleep(Duration::from_millis(10)); } let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS; @@ -322,7 +333,7 @@ fn tcp_connection_is_closed_after_junk() { serve_lines(read, s, |_, _| Some(json!({"ok": true}))); }); let mut c = TcpStream::connect(addr).unwrap(); - c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.set_read_timeout(Some(WAIT)).unwrap(); c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); let mut text = String::new(); c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection @@ -373,7 +384,7 @@ fn extra_connections_are_refused() { // Idle connections are accepted in order and held open. let held: Vec = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect(); let mut extra = TcpStream::connect(addr).unwrap(); - extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + extra.set_read_timeout(Some(WAIT)).unwrap(); let mut line = String::new(); BufReader::new(&mut extra).read_line(&mut line).unwrap(); assert!(line.contains("too many control connections"), "{line}"); diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 906e35fb61..d13a1c4177 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -548,11 +548,14 @@ fn a_normal_exit_or_another_failure_does_not_restart() { assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); } -/// A log folder of its own for each test. -fn start_dir(tag: &str) -> std::path::PathBuf { +/// A log folder of its own for each test, and the guard that keeps other tests from starting a +/// program while it runs: the test releases the start file's lock and takes it again, and a child +/// starting meanwhile would keep it held (`crate::no_spawns`). +fn start_dir(tag: &str) -> (std::path::PathBuf, std::sync::RwLockReadGuard<'static, ()>) { + let spawns = crate::no_spawns(); let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); - d + (d, spawns) } /// What a start in `dir` leaves out; it then ends without crashing. @@ -578,7 +581,7 @@ fn issue_2243() -> Vec { /// it, without a status-bar note. #[test] fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { - let dir = start_dir("crash"); + let (dir, _spawns) = start_dir("crash"); let c = issue_2243(); let first = Startup::default(); assert_eq!(first.track(&dir, None, false), LeftOut::default()); @@ -609,7 +612,7 @@ fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() { /// an error, a restart on another adapter), leaves nothing out of the next. #[test] fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { - let dir = start_dir("shown"); + let (dir, _spawns) = start_dir("shown"); let c = issue_2243(); for end in [Startup::presented as fn(&Startup), Startup::ended] { let first = Startup::default(); @@ -633,7 +636,7 @@ fn a_start_that_showed_its_first_frame_or_ended_is_cleared() { /// macOS, where wgpu has none. #[test] fn a_crash_while_listing_lists_opengl_alone() { - let dir = start_dir("listing"); + let (dir, _spawns) = start_dir("listing"); let first = Startup::default(); first.track(&dir, None, false); drop(first); @@ -660,7 +663,7 @@ fn a_crash_while_listing_lists_opengl_alone() { /// start shows its first frame on the adapter that crashed, the adapter counts again. #[test] fn wgpu_backend_or_adapter_name_ignores_the_crashes() { - let dir = start_dir("explicit"); + let (dir, _spawns) = start_dir("explicit"); let c = issue_2243(); let first = Startup::default(); first.track(&dir, None, false); @@ -681,7 +684,7 @@ fn wgpu_backend_or_adapter_name_ignores_the_crashes() { /// are ignored: the start goes on with nothing left out. #[test] fn a_damaged_or_unreadable_start_file_is_ignored() { - let dir = start_dir("damaged"); + let (dir, _spawns) = start_dir("damaged"); std::fs::create_dir_all(&dir).unwrap(); let damaged: [&[u8]; 9] = [ b"", diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 014782ed45..4254d1591a 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -5,6 +5,8 @@ use super::tablet; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing}; +#[cfg(target_os = "macos")] +use super::{mac_save_format, mac_screen_color, mac_tablet}; /// The window file dialogs belong to. pub(crate) type Parent = Option>; diff --git a/apps/vectorcraft/src/mac_color_space.rs b/apps/vectorcraft/src/mac_color_space.rs new file mode 100644 index 0000000000..3fa1394804 --- /dev/null +++ b/apps/vectorcraft/src/mac_color_space.rs @@ -0,0 +1,139 @@ +//! macOS: the windows' Metal layers are tagged sRGB, so macOS converts what VectorCraft draws to +//! the display's color profile (#2249, #3161). +//! +//! VectorCraft draws sRGB values. Apple's documentation of `CAMetalLayer.colorspace` says: "When a +//! color space is present, Core Animation performs any necessary color space transformations when +//! compositing this content. The default value is `nil`, indicating that the rendered content +//! isn’t color-matched" +//! (). Without a +//! color space, a wide-gamut display shows the sRGB values as its own, so `#FF0000` is drawn as +//! the display's most saturated red, brighter than the same color in an exported PNG. +//! +//! wgpu 30 sets the layer's color space to nil each time it configures the surface: at start, on +//! a resize and when the window moves to a display with another scale (gfx-rs/wgpu#10013). egui +//! configures the surface while it paints a frame, after [`eframe::App::ui`] has run, so [`tag`] +//! adds a paint callback that draws nothing. egui calls it after the configuration and before the +//! frame is presented, and it sets sRGB on every window's Metal layer that has no color space. A +//! layer that already has a color space keeps it. +//! +//! Reading a layer's sublayers has no safe API, hence the scoped `unsafe_code` allowance. +#![allow(unsafe_code)] + +use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, wgpu}; +use objc2::MainThreadMarker; +use objc2::rc::Retained; +use objc2::{DowncastTarget, Message}; +use objc2_app_kit::NSApplication; +use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB}; +use objc2_quartz_core::{CALayer, CAMetalLayer}; + +/// Adds the callback that tags the windows' Metal layers to the frame `ui` draws: a point in its +/// top-left corner, in the background layer, with nothing drawn. +pub fn tag(ui: &egui::Ui) { + let ctx = ui.ctx(); + let rect = egui::Rect::from_min_size(ctx.content_rect().min, egui::vec2(1.0, 1.0)); + ctx.layer_painter(egui::LayerId::background()).add(egui_wgpu::Callback::new_paint_callback(rect, TagLayers)); +} + +struct TagLayers; + +impl CallbackTrait for TagLayers { + fn paint(&self, _: egui::PaintCallbackInfo, _: &mut wgpu::RenderPass<'static>, _: &CallbackResources) { + // AppKit's windows and views are read on the main thread only, where eframe paints. + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + for window in NSApplication::sharedApplication(mtm).windows() { + if let Some(root) = window.contentView().and_then(|view| view.layer()) { + for layer in metal_layers(&root) { + tag_srgb(&layer); + } + } + } + } +} + +/// The Metal layers that draw a view whose layer is `root`: `root` itself, or the sublayers wgpu +/// adds to it when it is a plain layer. +fn metal_layers(root: &CALayer) -> Vec> { + if let Some(layer) = downcast(root) { + return vec![layer]; + } + // SAFETY: the returned array is a copy; the layers in it are retained while it lives. + let Some(sublayers) = (unsafe { root.sublayers() }) else { + return Vec::new(); + }; + sublayers.iter().filter_map(|layer| downcast(&layer)).collect() +} + +fn downcast(layer: &CALayer) -> Option> { + layer.retain().downcast::().ok() +} + +/// Sets sRGB as `layer`'s color space when it has none. Returns whether it was set. +fn tag_srgb(layer: &CAMetalLayer) -> bool { + if layer.colorspace().is_some() { + return false; + } + // SAFETY: a constant that CoreGraphics exports. + let Some(srgb) = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) else { + return false; + }; + layer.setColorspace(Some(&srgb)); + true +} + +#[cfg(test)] +mod tests { + use objc2_core_foundation::CFString; + use objc2_core_graphics::kCGColorSpaceDisplayP3; + + use super::*; + + fn space_name(layer: &CAMetalLayer) -> Option { + CGColorSpace::name(layer.colorspace().as_deref()).map(|name| name.to_string()) + } + + fn name(constant: &CFString) -> String { + constant.to_string() + } + + /// A layer without a color space is tagged sRGB once; the second call leaves it as it is. + #[test] + fn an_untagged_layer_becomes_srgb() { + let layer = CAMetalLayer::new(); + assert_eq!(space_name(&layer), None, "a new layer has no color space"); + assert!(tag_srgb(&layer)); + assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB }))); + assert!(!tag_srgb(&layer)); + assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB }))); + } + + /// A layer with a color space of its own keeps it. + #[test] + fn a_layer_with_a_color_space_keeps_it() { + let layer = CAMetalLayer::new(); + let p3 = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })).unwrap(); + layer.setColorspace(Some(&p3)); + assert!(!tag_srgb(&layer)); + assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceDisplayP3 }))); + } + + /// The Metal layer is found as the view's layer or as one of its sublayers, and only Metal + /// layers are returned. + #[test] + fn metal_layers_are_found_under_the_view_layer() { + let metal = CAMetalLayer::new(); + assert_eq!(metal_layers(&metal), vec![metal.clone()]); + + let root = CALayer::new(); + assert!(metal_layers(&root).is_empty()); + root.addSublayer(&CALayer::new()); + root.addSublayer(&metal); + assert_eq!(metal_layers(&root), vec![metal.clone()]); + for layer in metal_layers(&root) { + tag_srgb(&layer); + } + assert_eq!(space_name(&metal), Some(name(unsafe { kCGColorSpaceSRGB }))); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c37aebe96c..2dc62fb1d4 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -26,6 +26,8 @@ mod gpu; #[cfg(target_os = "linux")] mod linux_stylus; mod logging; +#[cfg(all(target_os = "macos", feature = "wgpu"))] +mod mac_color_space; #[cfg(target_os = "macos")] mod mac_fonts; #[cfg(target_os = "macos")] @@ -483,5 +485,28 @@ fn main() -> std::process::ExitCode { } } +/// Tests only: while a test starts another program, the child briefly holds a copy of every file +/// this process has open, until it runs that program. A file lock held through such a copy outlives +/// its own handle, so a GPU start test that released the start file and opened it again found it +/// still locked, as if another copy of the app were running, and failed. A test that starts a +/// program holds [`spawning`]; a test that depends on the start file's lock holds [`no_spawns`]. +#[cfg(all( + test, + any(feature = "wgpu", any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")) +))] +static CHILD_SPAWN: std::sync::RwLock<()> = std::sync::RwLock::new(()); + +/// Held while a test starts another program (see [`CHILD_SPAWN`]). +#[cfg(all(test, any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] +fn spawning() -> std::sync::RwLockWriteGuard<'static, ()> { + CHILD_SPAWN.write().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Held by a test that releases a locked file and opens it again (see [`CHILD_SPAWN`]). +#[cfg(all(test, feature = "wgpu"))] +fn no_spawns() -> std::sync::RwLockReadGuard<'static, ()> { + CHILD_SPAWN.read().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(all(test, feature = "wgpu"))] mod tests; diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index b6362107e5..b6dd3cc8e9 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -169,28 +169,37 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option Result, String> { use std::sync::atomic::{AtomicU32, Ordering}; - use zbus::blocking::Proxy; + use zbus::blocking::{MessageIterator, Proxy}; use zbus::zvariant::OwnedObjectPath; const DESKTOP: &str = "org.freedesktop.portal.Desktop"; const REQUEST: &str = "org.freedesktop.portal.Request"; static NEXT: AtomicU32 = AtomicU32::new(0); - let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_"); let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)); - let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); - // Listen before asking, so that an answer that comes at once is not missed. - let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?; + // Listen before asking, on every request path: a portal older than version 0.9 ignores + // `handle_token` and answers on the request path it returns, and its answer can come before + // the call's reply. The stream keeps every `Response` from the time it is made. + let rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DESKTOP) + .and_then(|b| b.interface(REQUEST)) + .and_then(|b| b.member("Response")) + .map_err(|e| e.to_string())? + .build(); + let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?; let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?; // The title is empty, as rfd sends it for a save. - let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?; - // A portal older than version 0.9 ignores `handle_token` and answers on the request path it - // returns. - let reply = if handle.as_str() == path { - replies.next() - } else { - Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next() - } - .ok_or("the save dialog closed without an answer")?; + let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?; + let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; + // The answer is the `Response` on the path returned, from the connection that returned it. + let portal = call.header().sender().map(|s| s.as_str().to_string()); + let reply = responses + .filter_map(Result::ok) + .find(|m| { + let header = m.header(); + header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref() + }) + .ok_or("the save dialog closed without an answer")?; let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; answer(code, &results) } @@ -420,20 +429,34 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] type Seen = std::sync::Arc, Option)>>>; - /// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` - /// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths` - /// it is a portal older than 0.9, which answers on a request path of its own. `None` when there - /// is no `dbus-daemon` to run. + /// How the mock portal answers `SaveFile`. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] - fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { + #[derive(Clone, Copy, PartialEq, Eq)] + enum Portal { + /// On the request path that `handle_token` names, after its reply. + Current, + /// As a portal older than 0.9: on a request path of its own, 300 ms after its reply, as a + /// person answers the dialog. + Old, + /// As `Old`, with the `Response` sent before the reply that names its path. + OldEarly, + } + + /// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` + /// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it. + /// `None` when there is no `dbus-daemon` to run. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { use std::io::BufRead as _; use zbus::zvariant::OwnedObjectPath; - let mut daemon = std::process::Command::new("dbus-daemon") + let spawning = crate::spawning(); + let daemon = std::process::Command::new("dbus-daemon") .args(["--session", "--nofork", "--print-address=1"]) .stdout(std::process::Stdio::piped()) - .spawn() - .ok()?; + .spawn(); + drop(spawning); + let mut daemon = daemon.ok()?; let mut address = String::new(); std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; let address = address.trim().to_string(); @@ -441,9 +464,12 @@ mod tests { zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?; let seen = Seen::default(); let log = seen.clone(); + // Made before the client exists: the connection drops a call that comes while no stream + // reads it. + let incoming = zbus::blocking::MessageIterator::from(&server); std::thread::spawn(move || { let mut script = script.into_iter(); - for msg in zbus::blocking::MessageIterator::from(&server) { + for msg in incoming { let Ok(msg) = msg else { break }; let header = msg.header(); if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { @@ -460,16 +486,12 @@ mod tests { l.len() }); let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break }; - let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() }; + let token = match portal { + Portal::Current => text("handle_token").unwrap_or_default(), + Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()), + }; let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break }; - if server.reply(&header, &handle).is_err() { - break; - } - if own_paths { - // As a person answers the dialog: after the caller listened on the path returned. - std::thread::sleep(std::time::Duration::from_millis(300)); - } let (code, pick) = script.next().unwrap_or((2, None)); let mut results: HashMap<&str, Value<'_>> = HashMap::new(); if let Some((uri, label)) = pick { @@ -477,7 +499,18 @@ mod tests { results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())]))); } let to = header.sender().cloned().map(zbus::names::BusName::from); - if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() { + let respond = || server.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &results)); + if portal == Portal::OldEarly && respond().is_err() { + break; + } + if server.reply(&header, &handle).is_err() { + break; + } + if portal == Portal::Old { + // As a person answers the dialog: after the caller has the path returned. + std::thread::sleep(std::time::Duration::from_millis(300)); + } + if portal != Portal::OldEarly && respond().is_err() { break; } } @@ -486,20 +519,32 @@ mod tests { Some((client, seen, daemon)) } + /// [`save_file`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails + /// the test instead of leaving it waiting. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn save_within(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + let (conn, request) = (conn.clone(), request.clone()); + std::thread::spawn(move || { + let _ = tx.send(save_file(&conn, &request)); + }); + rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds") + } + /// Through a real D-Bus: the call starts the dialog on the first file type with the suggested /// name, and the answer names the file and the type picked; Cancel answers no file. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] #[test] fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() { let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)]; - let Some((conn, seen, mut daemon)) = mock_portal(script, false) else { + let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else { eprintln!("no dbus-daemon: skipped"); return; }; let request = request(); - let saved = save_file(&conn, &request).unwrap(); + let saved = save_within(&conn, &request).unwrap(); assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]); - assert_eq!(save_file(&conn, &request), Ok(None)); + assert_eq!(save_within(&conn, &request), Ok(None)); let seen = seen.lock().map(|s| s.clone()).unwrap_or_default(); assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone())))); let _ = daemon.kill(); @@ -510,12 +555,28 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] #[test] fn the_answer_is_awaited_on_the_path_the_portal_returns() { - let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else { + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else { eprintln!("no dbus-daemon: skipped"); return; }; let request = request(); - assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); + assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); + let _ = daemon.kill(); + } + + /// A portal older than 0.9 can answer before its reply names the request path: the answer is + /// kept until the path is known, and the next dialog does not take it. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn an_answer_that_comes_before_the_path_is_kept() { + let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)]; + let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let request = request(); + assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); + assert_eq!(save_within(&conn, &request), Ok(None)); let _ = daemon.kill(); } diff --git a/contributors/people.toml b/contributors/people.toml index d43e6fa1b7..8578d13f18 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -169,3 +169,6 @@ sync_github_name = false real_name = "Andrés Ortega Montoya" display_name = "Andrés Ortega Montoya" sync_github_name = false + +[people.krosk93] +sync_github_name = true diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 422f09d9c2..e76e67ba5b 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -343,6 +343,44 @@ pub enum PatternFit { Approximate, } +/// What a pattern brush draws at a kind of corner it has no corner art for: no tile, or a tile +/// made from the side tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CornerSource { + /// No corner tile: the side tiles run up to the corner. + None, + /// One side tile stretched around the corner and centered on it. + AutoCentered, + /// A side tile on each side reaching into the corner, stretched so the two meet on the + /// corner's diagonal. + AutoBetween, + /// A side tile on each side cut on the corner's diagonal, the two meeting like a mitered frame. + AutoSliced, + /// A side tile on each side running past the corner, the two overlapping there. + AutoOverlap, +} + +impl CornerSource { + pub const ALL: [CornerSource; 5] = + [CornerSource::None, CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap]; + + /// The id in a brush definition and in `brush.options` params. + pub fn id(self) -> &'static str { + match self { + CornerSource::None => "none", + CornerSource::AutoCentered => "autoCentered", + CornerSource::AutoBetween => "autoBetween", + CornerSource::AutoSliced => "autoSliced", + CornerSource::AutoOverlap => "autoOverlap", + } + } + + pub fn parse(s: &str) -> Option { + CornerSource::ALL.into_iter().find(|a| a.id() == s) + } +} + /// Pattern brush: tiles repeated along the path, with corner and end tiles. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -352,6 +390,14 @@ pub struct PatternBrush { pub outer_corner: Option, #[serde(skip_serializing_if = "Option::is_none")] pub inner_corner: Option, + /// What the outer corners take when there is no `outer_corner` art. Without art or a source, + /// they take the `inner_corner` art. + #[serde(skip_serializing_if = "Option::is_none")] + pub outer_source: Option, + /// What the inner corners take when there is no `inner_corner` art. Without art or a source, + /// they take the `outer_corner` art. + #[serde(skip_serializing_if = "Option::is_none")] + pub inner_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -378,6 +424,8 @@ impl Default for PatternBrush { side: defaults::stitch_tile(), outer_corner: None, inner_corner: None, + outer_source: None, + inner_source: None, start: None, end: None, scale: 100.0, diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 0968f5d6ce..884dd27cf2 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1,6 +1,6 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{BezPath, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes}; use super::*; @@ -273,15 +273,362 @@ fn pattern_tile_count_by_fit() { fn pattern_places_corner_tiles() { let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0)); - let corners = out - .iter() - .filter( - |n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0), - ) - .count(); - assert_eq!(corners, 4); + // The corner tile is an ellipse and the side tile a rectangle, so a kept handle tells them + // apart. Anchor count no longer does: the side tiles keep their own four anchors now that a + // straight run is not flattened (#3926), where they used to arrive as many-anchor polylines. + let curved = |n: &Node| { + matches!(&n.kind, NodeKind::Path { path, .. } + if path.subpaths.iter().any(|s| s.anchors.iter().any(|a| a.h_in != a.p || a.h_out != a.p))) + }; + let corners = out.iter().filter(|n| curved(n)).count(); + assert_eq!(corners, 4, "the four corner ellipses, still curves"); // Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each. assert_eq!(out.len() - corners, 36); + for n in out.iter().filter(|n| !curved(n)) { + let NodeKind::Path { path, .. } = &n.kind else { continue }; + assert_eq!(path.subpaths[0].anchors.len(), 4, "a rectangle tile on a straight run stays four anchors"); + } +} + +/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art. +fn auto_corner_brush(outer: Option, inner: Option) -> Brush { + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.outer_source = outer; + p.inner_source = inner; + } + b +} + +const AUTO: [CornerSource; 4] = [CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap]; + +/// Whether `n` paints a fill over `q`: inside one of its paths, and inside the mask of every +/// clipping group it is in. +fn covers(n: &Node, q: Point) -> bool { + let inside = |n: &Node| n.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), q) != 0); + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().is_some_and(|m| inside(m)) && children.iter().skip(1).any(|c| covers(c, q)), + NodeKind::Path { .. } => inside(n), + _ => n.children().is_some_and(|c| c.iter().any(|c| covers(c, q))), + } +} + +fn points_of(nodes: &[Node]) -> Vec { + let mut pts = vec![]; + for n in nodes { + all_points(n, &mut pts); + } + pts +} + +/// The corners of a 100 pt square and, for each, the outer corner of the 4 pt band around it. +const SQUARE_CORNERS: [((f64, f64), (f64, f64)); 4] = + [((0.0, 0.0), (-2.0, -2.0)), ((100.0, 0.0), (102.0, -2.0)), ((100.0, 100.0), (102.0, 102.0)), ((0.0, 100.0), (-2.0, 102.0))]; + +/// Whether some point of `pts` is within `tol` of `(x, y)`. +fn reaches(pts: &[Point], (x, y): (f64, f64), tol: f64) -> bool { + pts.iter().any(|p| p.distance(Point::new(x, y)) <= tol) +} + +/// Distance from `p` to the outline of the square (0, 0)–(100, 100). +fn from_square_outline(p: Point) -> f64 { + let dx = (p.x - p.x.clamp(0.0, 100.0)).abs(); + let dy = (p.y - p.y.clamp(0.0, 100.0)).abs(); + if dx > 0.0 || dy > 0.0 { dx.hypot(dy) } else { p.x.min(100.0 - p.x).min(p.y).min(100.0 - p.y) } +} + +#[test] +fn pattern_auto_corners_fill_each_corner_of_a_square() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let mut outputs = vec![]; + for kind in AUTO { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &sq, &stroke(1.0)); + let pts = points_of(&out); + for (corner, outside) in SQUARE_CORNERS { + assert!(reaches(&pts, outside, 0.8), "{kind:?}: the art reaches the band's outer corner at {corner:?}"); + } + let far = pts.iter().copied().map(from_square_outline).fold(0.0, f64::max); + assert!(far <= 2.0 * 2f64.sqrt() + 0.05, "{kind:?}: every point stays in the 4 pt band, {far}"); + // The band is filled up to each corner along both of its sides. + let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y))); + for ((cx, cy), _) in SQUARE_CORNERS { + let (sx, sy) = ((50.0 - cx).signum(), (50.0 - cy).signum()); + for d in [0.5, 2.5, 4.5, 6.5, 8.5] { + assert!(filled(cx + sx * d, cy) && filled(cx, cy + sy * d), "{kind:?}: filled {d} pt from ({cx}, {cy})"); + } + } + outputs.push(out); + } + for i in 0..outputs.len() { + for j in i + 1..outputs.len() { + assert_ne!(outputs[i], outputs[j], "{:?} and {:?} draw differently", AUTO[i], AUTO[j]); + } + } +} + +/// A side tile three times as tall as it is long still fills a square's corners without a gap: +/// the two sides meet on the corner's diagonal. +#[test] +fn pattern_auto_corners_of_a_tall_tile_meet_on_the_diagonal() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath(); + for kind in AUTO { + let mut b = auto_corner_brush(Some(kind), Some(kind)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side = rect_art(10.0, 30.0); + } + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y))); + for (cx, cy) in [(0.0_f64, 0.0_f64), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)] { + let (sx, sy) = ((100.0 - cx).signum(), (100.0 - cy).signum()); + for d in [1.0, 5.0, 10.0, 14.0] { + // Along the diagonal, outside and inside the square, and along both sides. + assert!(filled(cx - sx * d, cy - sy * d), "{kind:?}: outside ({cx}, {cy}) by {d}"); + assert!(filled(cx + sx * d, cy + sy * d), "{kind:?}: inside ({cx}, {cy}) by {d}"); + assert!(filled(cx + sx * 3.0 * d, cy) && filled(cx, cy + sy * 3.0 * d), "{kind:?}: {} pt from ({cx}, {cy})", 3.0 * d); + } + } + } +} + +/// Where a curve meets a sharp corner, the corner tile reaches the side tiles on the curve: the +/// middle of the band is filled all the way along the path. +#[test] +fn pattern_auto_corners_join_a_curve_into_the_corner() { + // A straight run into a quarter circle of radius 10, then a sharp turn. + let mut bp = BezPath::new(); + bp.move_to((-50.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.curve_to((15.52, 0.0), (20.0, 4.48), (20.0, 10.0)); + bp.line_to((-80.0, 10.0)); + let track = &crate::track::tracks(&bp, 0.05)[0]; + let l = track.len(); + for kind in AUTO { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + let mut s = 0.25; + while s < l - 0.25 { + let q = track.map(s, 0.0); + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} of {l:.2} ({q:?})"); + s += 0.25; + } + } +} + +/// Where a curve meets a sharp corner, the two halves of a corner tile still meet on the corner's +/// diagonal: the band is filled across its width near the corner, and both Auto-Between halves end +/// at the diagonal's two ends. +#[test] +fn pattern_auto_corners_after_a_curve_meet_across_the_band() { + // A quarter circle of radius 4 straight into a sharp turn. + let mut bp = BezPath::new(); + bp.move_to((-50.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.curve_to((12.21, 0.0), (14.0, 1.79), (14.0, 4.0)); + bp.line_to((-80.0, 4.0)); + let track = &crate::track::tracks(&bp, 0.05)[0]; + let corner = track.corner_positions().first().map(|c| c.1).unwrap(); + for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + let mut s = corner - 3.0; + while s < corner + 3.0 { + for j in -19..=19 { + let y = j as f64 / 10.0; + let q = track.map(s, y); + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} ({y} across, {q:?})"); + } + s += 0.05; + } + } + // The path leaves the corner at (14, 4) heading left, so the 4 pt band's diagonal ends on the + // lines y = 2 and y = 6, where both Auto-Between halves end at the same point. + let both = auto_corner_brush(Some(CornerSource::AutoBetween), Some(CornerSource::AutoBetween)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &both, &bp, &stroke(1.0)); + for edge in [2.0, 6.0] { + let on_edge: Vec> = out + .iter() + .map(|n| points_of(std::slice::from_ref(n)).into_iter().filter(|q| near(q.y, edge, 1e-6) && near(q.x, 14.0, 3.0)).collect()) + .collect(); + let shared = on_edge.iter().enumerate().any(|(i, a)| on_edge.iter().skip(i + 1).any(|b| a.iter().any(|p| reaches(b, (p.x, p.y), 1e-6)))); + assert!(shared, "both halves end at the same point on y = {edge}"); + } +} + +/// On a path shorter than the side tile, a corner tile is squeezed into the path it has: it does +/// not run past the ends, nor past half the way to the next corner. +#[test] +fn pattern_auto_corners_stay_within_a_short_path() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((2.0, 0.0)); + bp.line_to((2.0, 2.0)); + let mut zigzag = BezPath::new(); + zigzag.move_to((0.0, 0.0)); + for j in 1..=6 { + zigzag.line_to((3.0 * j as f64, if j % 2 == 1 { 3.0 } else { 0.0 })); + } + for kind in AUTO { + for (path, (x0, y0, x1, y1)) in [(&bp, (0.0, 0.0, 2.0, 2.0)), (&zigzag, (0.0, 0.0, 18.0, 3.0))] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), path, &stroke(1.0)); + assert!(!out.is_empty(), "{kind:?}: something is drawn"); + // The band is 4 pt wide, and the diagonal of a sharp turn reaches further from it. + let reach = 2.0 * 3.0; + for p in points_of(&out) { + assert!(p.x >= x0 - reach && p.x <= x1 + reach && p.y >= y0 - reach && p.y <= y1 + reach, "{kind:?}: {p:?} is past the path's ends"); + } + } + } +} + +/// A corner squeezed into a path shorter than the band is wide still fills the band up to the +/// corner's diagonal from both sides, with no gap between the two halves. +#[test] +fn pattern_auto_corners_on_a_short_path_meet_on_the_diagonal() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((1.0, 0.0)); + bp.line_to((1.0, 1.0)); + for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + // The diagonal runs from the corner (1, 0) to the band's outer corner (3, -2). + for f in [0.25, 0.5, 0.75] { + let (dx, dy) = (1.0 + 2.0 * f, -2.0 * f); + for q in [Point::new(dx - 0.05, dy), Point::new(dx, dy - 0.05)] { + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {q:?}"); + } + } + } +} + +#[test] +fn pattern_without_corner_tiles_or_auto_corners_leaves_corners_empty() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let plain = stroke_pieces(&doc, &auto_corner_brush(None, None), &sq, &stroke(1.0)); + assert_eq!(plain.len(), 40, "ten side tiles a side"); + let pts = points_of(&plain); + for (corner, outside) in SQUARE_CORNERS { + assert!(!reaches(&pts, outside, 1.5), "no art at the band's outer corner at {corner:?}"); + } + // A square turns the same way at every corner, so only one of the two kinds of corner tile + // draws on it; the other leaves the stroke as it is without automatic corners. + let outer = stroke_pieces(&doc, &auto_corner_brush(Some(CornerSource::AutoBetween), None), &sq, &stroke(1.0)); + let inner = stroke_pieces(&doc, &auto_corner_brush(None, Some(CornerSource::AutoBetween)), &sq, &stroke(1.0)); + assert!((outer == plain) != (inner == plain), "exactly one kind of corner draws on a square"); +} + +#[test] +fn pattern_corner_art_wins_over_corner_sources() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let saved = pattern_brush(PatternFit::Stretch, true); + let mut with_auto = saved.clone(); + if let BrushKind::Pattern(p) = &mut with_auto.kind { + p.outer_source = Some(CornerSource::AutoSliced); + p.inner_source = Some(CornerSource::None); + } + assert_eq!(stroke_pieces(&doc, &with_auto, &sq, &stroke(1.0)), stroke_pieces(&doc, &saved, &sq, &stroke(1.0))); +} + +/// A kind of corner without art takes the other kind's art, as saved brushes draw, unless its +/// source is None. +#[test] +fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let draw = |b: &Brush| stroke_pieces(&doc, b, &sq, &stroke(1.0)); + let (saved, plain) = (draw(&pattern_brush(PatternFit::Stretch, true)), draw(&auto_corner_brush(None, None))); + let without = |outer: bool, source: Option| { + let mut b = pattern_brush(PatternFit::Stretch, true); + if let BrushKind::Pattern(p) = &mut b.kind { + if outer { + (p.outer_corner, p.outer_source) = (None, source); + } else { + (p.inner_corner, p.inner_source) = (None, source); + } + } + draw(&b) + }; + assert!(without(true, None) == saved && without(false, None) == saved, "the other kind's art fills in"); + let (outer, inner) = (without(true, Some(CornerSource::None)), without(false, Some(CornerSource::None))); + assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty"); +} + +#[test] +fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() { + let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap(); + assert!(saved.get("outer_source").is_none() && saved.get("inner_source").is_none(), "{saved}"); + let BrushKind::Pattern(p) = Brush::from_json(saved).unwrap().kind else { panic!("a pattern brush") }; + assert_eq!((p.outer_source, p.inner_source), (None, None)); + let v = serde_json::to_value(auto_corner_brush(Some(CornerSource::AutoCentered), Some(CornerSource::None))).unwrap(); + assert_eq!((v["outer_source"].as_str(), v["inner_source"].as_str()), (Some("autoCentered"), Some("none"))); + let BrushKind::Pattern(p) = Brush::from_json(v).unwrap().kind else { panic!("a pattern brush") }; + assert_eq!((p.outer_source, p.inner_source), (Some(CornerSource::AutoCentered), Some(CornerSource::None))); +} + +/// Auto-Sliced masks each side tile at the corner's diagonal instead of cutting its paths, so a +/// tile's outline gains no stroke along the cut. +#[test] +fn pattern_auto_sliced_masks_stroked_tiles_at_the_diagonal() { + let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 0.5).into(); + } + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + assert_eq!(clips.len(), 8, "two masked tiles at each corner"); + for g in clips { + let kids = g.children().unwrap(); + assert_eq!(kids.len(), 2); + assert!(kids[0].appearance.items.is_empty(), "the mask paints nothing"); + let mask = kids[0].path_data().unwrap().to_bezpath(); + let tile = kids[1].path_data().unwrap(); + assert!( + tile.anchors().any(|(_, _, a)| kurbo::Shape::winding(&mask, a.p) == 0), + "the tile keeps its outline past the diagonal, where the mask hides it" + ); + } +} + +/// Auto-Sliced on a corner whose legs are too short for the side tile squeezes the tile along, +/// but not its stroke: the mask still takes in the stroke past the tile's far end. +#[test] +fn pattern_auto_sliced_masks_keep_the_stroke_of_a_squeezed_tile() { + let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0).into(); + } + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + assert_eq!(clips.len(), 8, "two masked tiles at each corner"); + let center = Point::new(4.0, 4.0); + for g in clips { + let kids = g.children().unwrap(); + let mask = kids[0].path_data().unwrap().to_bezpath(); + let half = kids[1].appearance.stroke_width() / 2.0; + assert!(half > 0.0); + let tile = kids[1].path_data().unwrap(); + let anchors: Vec = tile.anchors().map(|(_, _, a)| a.p).collect(); + let mid = anchors.iter().fold(Point::ZERO, |m, a| m + a.to_vec2() / anchors.len() as f64); + // The tile's corner, and its diagonal through the square's center; each mask keeps one + // side of the diagonal. + let p = [(0.0, 0.0), (8.0, 0.0), (8.0, 8.0), (0.0, 8.0)] + .map(|(x, y)| Point::new(x, y)) + .into_iter() + .min_by(|a, b| a.distance(mid).total_cmp(&b.distance(mid))) + .unwrap(); + let cross = |q: Point| (q - p).cross(center - p); + let side = cross(mid).signum(); + for a in &anchors { + // The points of the stroke round the anchor, along and across the tile's leg. + for d in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + let q = *a + kurbo::Vec2::new(d.0, d.1) * half * 0.9; + if cross(q) * side > 0.01 && (q - p).hypot() < 7.0 { + assert!(kurbo::Shape::winding(&mask, q) != 0, "the mask takes in the stroke at {q:?} (half width {half})"); + } + } + } + } } #[test] @@ -800,19 +1147,22 @@ fn pattern_output_is_capped_across_subpaths_not_only_per_run() { let a1 = anchors(&one); assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}"); - // Sixteen tracks would be sixteen times that without a total budget. - // Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the - // budget must not bite before the per-factor caps have actually multiplied past it. - let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0)); - assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece"); - assert_eq!(anchors(&four), 4 * a1); + // Two tracks are well inside the budget, so nothing is cut: it must not bite before the + // per-factor caps have actually multiplied past it. + let two = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(2), &stroke(1.0)); + assert_eq!(two.len(), 2 * 20_000, "two tracks are under the budget and keep every piece"); + assert_eq!(anchors(&two), 2 * a1); - // Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps. - let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0)); + // Enough tracks that the per-run caps alone would pass the budget. Derived from what one + // track costs rather than written down, because how many anchors a tile holds is not fixed: + // a tile on a straight run keeps its own anchors instead of being flattened (#3926), which + // moved this by a factor of four. + let tracks = 2_000_000 / a1 + 2; + let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(tracks), &stroke(1.0)); let an = anchors(&many); - assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}"); - assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}"); - assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len()); + assert!(an <= 2_000_000, "{tracks} subpaths must not exceed the budget: {an}"); + assert!(an > anchors(&two), "but more is drawn than two tracks' worth: {an}"); + assert!(many.len() < tracks * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len()); } /// The same for scatter, whose per-track guard is also 20,000 copies. @@ -843,3 +1193,94 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() { assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget"); assert!(anchors(&out) < 2_000_000); } + +/// Anchors under `n`, and how many of them keep a handle (so the segment is a curve). +fn anchors_and_curved(n: &Node) -> (usize, usize) { + let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) }; + let all: Vec<_> = path.subpaths.iter().flat_map(|s| s.anchors.iter()).collect(); + (all.len(), all.iter().filter(|a| a.h_in != a.p || a.h_out != a.p).count()) +} + +/// #3926: a Pattern Brush on a straight path only moves, turns and scales its tile, so the +/// tile's curves survive. They used to be flattened into chords by the bending code, which is +/// visible in the stroke itself and leaves no handles after Expand Brush Strokes. +#[test] +fn a_pattern_tile_keeps_its_curves_on_a_straight_path() { + let circle = + Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let (tile_anchors, tile_curved) = anchors_and_curved(&circle); + assert_eq!((tile_anchors, tile_curved), (4, 4), "the tile is a 4-anchor smooth circle"); + + let brush = Brush { + name: "p".into(), + kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0)); + assert!(!out.is_empty(), "the brush drew something"); + for n in &out { + assert_eq!(anchors_and_curved(n), (4, 4), "each circle is still a 4-anchor curve, not a polygon"); + } + // The ink is in the same place: a circle tile still covers the path it was laid along. + let b = bounds(&out); + assert!(near(b.width(), 480.0, 1.0), "the tiles still span the path: {}", b.width()); + assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height()); +} + +/// The other half of the same behaviour: where the path really does bend, the tile is still +/// subdivided, because an affine cannot reproduce a bend. The fit is checked before it is used, +/// so a bent run falls back to exactly what it did before. +#[test] +fn a_pattern_tile_on_a_bent_path_is_still_subdivided() { + let circle = + Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let brush = Brush { + name: "p".into(), + kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + // A circle to run along: every tile sits on a curving section. + let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0)); + assert!(!out.is_empty()); + let bent = out.iter().filter(|n| anchors_and_curved(n).0 > 8).count(); + assert!(bent > out.len() / 2, "tiles on a bend are still densified: {bent} of {}", out.len()); +} + +/// The kind of every node in a piece of brushed art, in paint order. +fn kinds(n: &Node) -> Vec<&'static str> { + let mut out = vec![match &n.kind { + NodeKind::Path { .. } => "path", + NodeKind::Group { .. } => "group", + NodeKind::SymbolInstance { .. } => "symbol", + _ => "other", + }]; + for c in n.children().into_iter().flatten() { + out.extend(kinds(c)); + } + out +} + +/// The straight-run route differs from the bending route in the tile's curves and in nothing else, +/// which includes the node kinds it keeps. A tile holding a kind neither route can map (here a +/// symbol instance, whose art lives in the symbol and not in the node) loses it on a straight run +/// exactly as it does on a bend: an affine is not a way for unmappable art to arrive untouched. +#[test] +fn a_straight_run_keeps_the_same_node_kinds_as_a_bend() { + let circle = + Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let sym = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + let tile = Node::group(NodeId(2), vec![Arc::new(circle), Arc::new(sym)]); + let brush = Brush { + name: "p".into(), + kind: BrushKind::Pattern(PatternBrush { side: tile, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + let doc = Document::new(1.0, 1.0); + let straight = stroke_pieces(&doc, &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0)); + let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath(); + let bent = stroke_pieces(&doc, &brush, &round, &stroke(1.0)); + assert!(!straight.is_empty() && !bent.is_empty(), "both paths drew something"); + for (what, out) in [("a straight run", &straight), ("a bend", &bent)] { + for n in out { + assert_eq!(kinds(n), ["group", "path"], "{what} keeps the path and drops the symbol instance"); + } + } +} diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 85ccbd9904..7d87991864 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -29,6 +29,12 @@ fn unit(v: Vec2) -> Vec2 { if l < 1e-12 { Vec2::new(1.0, 0.0) } else { v / l } } +/// Whether two unit vectors point the same way, to within the slack a unit vector carries after +/// a few multiplications (a bend of even a thousandth of a degree fails this). +fn aligned(a: Vec2, b: Vec2) -> bool { + a.dot(b) > 1.0 - 1e-12 +} + /// Left-hand normal in y-down document space (for a path running +x it points +y). pub fn normal(t: Vec2) -> Vec2 { Vec2::new(-t.y, t.x) @@ -154,6 +160,59 @@ impl Track { let (p, t) = self.frame(s); p + normal(t) * d } + + /// Whether [`Self::frame`] holds one constant tangent over arc lengths `s0..s1`, so mapping + /// through it is an affine of the art and nothing more. + /// + /// Read off the track, not sampled. A bend in the span is two segments with different + /// directions, however short the bent part is, so this cannot miss one; a sampled check can, + /// because a wiggle narrower than the sample spacing passes between the samples (#3926). + /// + /// Fails closed: a span that needs reasoning this does not do (a closed track's seam, a + /// non-finite bound, a track with no segments) is reported as not straight, and the caller + /// bends it the long way. + pub fn is_straight(&self, s0: f64, s1: f64) -> bool { + // Checked before the min/max, which quietly drop a NaN rather than propagate it. + let n = self.segments(); + if !(s0.is_finite() && s1.is_finite()) || n == 0 { + return false; + } + let (lo, hi) = (s0.min(s1), s0.max(s1)); + let l = self.len(); + // A closed track wraps, so a span reaching past either end covers segments twice. + if self.closed && (lo < -1e-9 || hi > l + 1e-9) { + return false; + } + // Past an open track's ends `frame` extrapolates along the end segment's direction, so + // those segments are part of the span too. + let ends = [(!self.closed && lo < 0.0).then_some(0), (!self.closed && hi > l).then(|| n - 1)]; + let touched = (0..n).filter(|&i| { + let (a, b) = (self.cum.get(i), self.cum.get(i + 1)); + matches!((a, b), (Some(a), Some(b)) if *a < hi + 1e-9 && *b > lo - 1e-9) + }); + let mut dir: Option = None; + let mut any = false; + for i in touched.chain(ends.into_iter().flatten()) { + let (Some(&d), Some(&t0), Some(&t1), Some(&c0), Some(&c1)) = + (self.dirs.get(i), self.tangents.get(i), self.tangents.get(i + 1), self.corner.get(i), self.corner.get(i + 1)) + else { + return false; + }; + if dir.is_some_and(|first: Vec2| !aligned(first, d)) { + return false; + } + dir = Some(d); + any = true; + // `frame` blends the two vertex tangents across the segment unless the vertex is a + // corner, where it uses the segment's own direction. So a neighbour outside the span + // can still bend the tangent inside it, and checking the directions alone would miss + // that: segments d, d, e leave vertex 2's tangent at unit(d + e). + if !(c0 || aligned(t0, d)) || !(c1 || aligned(t1, d)) { + return false; + } + } + any + } } /// The most flattened elements of a path kept. @@ -253,6 +312,47 @@ pub fn seed_of(bp: &BezPath, salt: &str) -> u64 { mod tests { use super::*; + /// A straight run is straight; a bend in the span is not, however short it is. This is the + /// property a sampled check cannot offer, so it is worth pinning on its own (#3926 review). + #[test] + fn straightness_is_read_off_the_track_not_sampled() { + let line = |pts: &[(f64, f64)], closed: bool| { + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + for p in &pts[1..] { + bp.line_to(*p); + } + if closed { + bp.close_path(); + } + tracks(&bp, 0.1).remove(0) + }; + + let straight = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false); + assert!(straight.is_straight(0.0, 200.0), "two collinear segments are one straight run"); + // Open tracks extrapolate along the end directions, which is still the same line. + assert!(straight.is_straight(-50.0, 250.0), "and so is the extrapolation past both ends"); + + // A detour of a fifth of a point, 0.1% of the run: far below any sampling grid this code + // would choose, and still a bend. + let wiggle = line(&[(0.0, 0.0), (99.9, 0.0), (100.0, 0.2), (100.1, 0.0), (200.0, 0.0)], false); + assert!(!wiggle.is_straight(0.0, 200.0), "a 0.2 pt detour is a bend"); + assert!(!wiggle.is_straight(99.0, 101.0), "and is caught when the span is only around it"); + assert!(wiggle.is_straight(0.0, 50.0), "while a span before it is straight"); + + // `frame` blends vertex tangents, so the segment before a bend is not straight all the way + // to the vertex: the tangent there is already turning. Pinning this because checking the + // segment directions alone would call that span straight and be wrong. + let kink = line(&[(0.0, 0.0), (100.0, 0.0), (160.0, 60.0)], false); + assert!(!kink.is_straight(0.0, 100.0), "the last segment's tangent is already turning at its far vertex"); + assert!(kink.is_straight(0.0, 50.0), "a span clear of the turning vertex is straight"); + + // Fails closed where it does not reason: a closed track's seam, and nonsense bounds. + let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true); + assert!(!ring.is_straight(-10.0, 10.0), "a span across a closed track's seam is refused"); + assert!(ring.is_straight(10.0, 90.0), "a span inside one of its sides is straight"); + assert!(!straight.is_straight(f64::NAN, 10.0), "a non-finite bound is refused"); + } #[test] fn straight_track_frames() { let mut bp = BezPath::new(); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index d20fe57009..95632ad495 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -6,11 +6,11 @@ use std::sync::Arc; -use vectorcraft_doc::{Node, NodeKind, PressureProfile}; -use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile}; +use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath, Vec2}; -use crate::track::{Rng, Track, seed_of, tracks}; -use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; +use crate::track::{Rng, Track, normal, seed_of, tracks}; +use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; /// Longest mapped segment (points in document space). const MAX_SEG: f64 = 1.5; @@ -31,6 +31,8 @@ const MAX_SUB: usize = 4000; const MAX_ANCHORS: usize = 2_000_000; /// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget. const MAX_WALK: usize = 10_000; +/// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with. +const MASK_STEPS: usize = 32; /// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and /// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by @@ -153,12 +155,111 @@ impl AlongMap { let w = self.xs[i + 1] - self.xs[i]; if w <= 1e-12 { 1.0 } else { (self.ss[i + 1] - self.ss[i]) / w } } + /// Whether `at` is one linear function across the art, so it adds no bend of its own. + /// + /// [`AlongMap::linear`] always is: its outer two pieces have no width and the middle one + /// spans everything. Between Guides is not, once its three pieces really do differ, which is + /// the whole point of it. + fn is_linear(&self) -> bool { + let mut slope: Option = None; + for i in 0..3 { + // A piece with no width constrains nothing: no art x lands inside it. + if self.xs[i + 1] - self.xs[i] <= 1e-12 { + continue; + } + let s = self.slope(i); + if slope.is_some_and(|first: f64| (first - s).abs() > 1e-9 * first.abs().max(1.0)) { + return false; + } + slope = Some(s); + } + slope.is_some() + } + + /// The arc lengths the art spans, low to high. + fn span(&self) -> (f64, f64) { + (self.ss[0].min(self.ss[3]), self.ss[0].max(self.ss[3])) + } + /// Largest along-scale (arc length per art unit) of the pieces. fn max_scale(&self) -> f64 { (0..3).map(|i| self.slope(i).abs()).fold(1e-6, f64::max) } } +/// The affine that reproduces `f` over `b` to within `tol`, if one does. +/// +/// Bending is only non-linear where the track bends. On a straight run the arc-length map and the +/// normal offset are both linear, so the art is just moved, turned and scaled, and flattening it +/// into chords throws its curves away for nothing (#3926). +/// +/// Derived from three corners and then checked against a grid. The check is a second opinion, not +/// the gate: `bend` only calls this once the track and the along map have been read and found +/// straight, because sampling can reject a bend and cannot promise there is none (#3926 review). +fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option { + let (w, h) = (b.width(), b.height()); + if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) { + return None; + } + let origin = Point::new(b.x0, b.y0); + let q0 = f(origin); + // Images of the unit x and y directions. + let u = (f(Point::new(b.x1, b.y0)) - q0) / w; + let v = (f(Point::new(b.x0, b.y1)) - q0) / h; + if ![q0.x, q0.y, u.x, u.y, v.x, v.y].iter().all(|k| k.is_finite()) { + return None; + } + let a = Affine::translate(q0.to_vec2()) * Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]) * Affine::translate(-origin.to_vec2()); + // Five by five including the corners the fit came from, so a map that merely agrees at those + // three is rejected. + for i in 0..=4 { + for j in 0..=4 { + let p = Point::new(b.x0 + w * i as f64 / 4.0, b.y0 + h * j as f64 / 4.0); + let (want, got) = (f(p), a * p); + if !(want.x.is_finite() && want.y.is_finite()) || want.distance(got) > tol { + return None; + } + } + } + Some(a) +} + +/// [`warp_node`] for a map that is affine: the art is transformed rather than flattened, so its +/// curves survive. +/// +/// Everything else matches [`warp_node`] deliberately, so curves are the only difference between +/// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every +/// path, an empty path or a group left with no children is dropped, and strokes are scaled by +/// `width_scale` at every level rather than by the transform's own factor. +fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option { + let mut out = prepared(n, width_scale)?; + out.transform(a, false); + Some(out) +} + +/// The part of [`affine_node`] that mirrors [`warp_node`]'s walk, before the single transform. +fn prepared(n: &Node, width_scale: f64) -> Option { + let mut out = n.clone(); + out.appearance.scale_strokes(width_scale); + match &mut out.kind { + NodeKind::Path { path, live, .. } => { + *live = None; + if path.is_empty() { + return None; + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => { + let kids: Vec> = children.iter().filter_map(|c| prepared(c, width_scale)).map(Arc::new).collect(); + if kids.is_empty() { + return None; + } + *children = kids; + } + _ => return None, + } + Some(out) +} + /// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`. fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option { let cy = b.center().y; @@ -166,6 +267,20 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option Opti bend(t, &tile.0, tile.1, AlongMap::linear(tile.1.width(), s0, s1), cross) } +/// What a pattern brush draws at a corner: corner tile art (oriented, with its bounds), or what +/// [`CornerSource`] gives. +#[derive(Clone, Copy)] +enum Corner<'a> { + Art(&'a (Node, Rect)), + Source(CornerSource), +} + +/// The pieces of the corner tile that `source` makes at corner `i` of `t` from `side` (oriented, +/// with its bounds) at scale `k`, and the length of path on each side of the corner they cover, +/// which is at most `room`. +/// +/// At the corner, the pieces run straight along the directions the path arrives at and leaves +/// the corner in, and towards their far ends they move onto the path itself, so they meet the side +/// tiles where the path curves. The corner's diagonal is the line through the corner that halves +/// the turn, where the two offset edges of the band meet. [`CornerSource::None`] and a turn of +/// nearly 180° get no pieces. +fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k: f64, room: f64) -> (Vec, f64) { + let (art, b) = side; + let (a_dir, b_dir) = t.in_out(i); + let (Some(&p), Some(&sc)) = (t.verts.get(i), t.cum.get(i)) else { return (vec![], 0.0) }; + let bt = a_dir + b_dir; + if source == CornerSource::None || a_dir.dot(bt) < 1e-3 { + return (vec![], 0.0); + } + let (tw, r) = (b.width() * k, b.height() * k / 2.0); + let (x0, cy) = (b.x0, b.center().y); + // Art point → (distance along from the left edge, offset across), in points. + let local = move |q: Point| ((q.x - x0) * k, (q.y - cy) * k); + // How far along `d` (from the corner) the diagonal is at offset `y` across `d`'s left normal. + let diag = move |d: Vec2, y: f64| -y * normal(d).dot(bt) / d.dot(bt); + // `u` along `d` from the corner and `y` across, moved from the straight line onto the path as + // `u` goes from `from` to `far` (signed: negative before the corner; `from` is the corner or + // a point between it and `far`). + let on_path = move |d: Vec2, u: f64, y: f64, from: f64, far: f64| { + let straight = p + d * u + normal(d) * y; + let span = far - from; + let w = if span.abs() > 1e-9 { ((u - from) / span).clamp(0.0, 1.0) } else { 0.0 }; + if w > 0.0 { straight.lerp(t.map(sc + u, y), w) } else { straight } + }; + // The most the diagonal is ahead of the corner, along either direction, within the band. Each + // source stretches the side tile along to `len` where the band is too tall for the tile to + // reach the diagonal, so the two sides always meet on it. + let ahead = r * normal(a_dir).dot(bt).abs() / a_dir.dot(bt); + // Before the corner, `u` in [-len, 0] maps onto [-len, diagonal]; after it, [0, len] onto + // [diagonal, len]. Both ends stay on the diagonal when `len` is shorter than `ahead`, and the + // points on the diagonal stay on the straight lines, where the two sides meet. + let into = move |u: f64, y: f64, len: f64| { + let reach = len.max(ahead); + let e = diag(a_dir, y).clamp(-reach, reach); + on_path(a_dir, -len + (u + len) * (e + len) / len.max(1e-9), y, e.min(0.0), -len) + }; + let out_of = move |u: f64, y: f64, len: f64| { + let reach = len.max(ahead); + let e = diag(b_dir, y).clamp(-reach, reach); + on_path(b_dir, e + u * (len - e) / len.max(1e-9), y, e.max(0.0), len) + }; + let flat_tol = tolerance(b.height().max(b.width()) * k) / k; + // The tile is squeezed along so that it covers at most `room` of the path on each side. + let room = room.max(0.0); + let len = tw.max(2.0 * ahead).min(match source { + CornerSource::AutoCentered => 2.0 * room, + CornerSource::AutoBetween => room, + _ => room + ahead, + }); + if len <= 1e-9 { + return (vec![], 0.0); + } + let s = if tw > 1e-9 { len / tw } else { 1.0 }; + let max_du = MAX_SEG / (2.0 * k * s.max(1.0)); + let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, k); + // Art point → (distance along from the left edge, stretched to `len`, and offset across). + let along = move |q: Point| { + let (x, y) = local(q); + (x * s, y) + }; + match source { + CornerSource::None => (vec![], 0.0), + CornerSource::AutoCentered => { + let half = len / 2.0; + let f = move |q: Point| { + let (x, y) = along(q); + let u = x - half; + if u < 0.0 { into(u, y, half) } else { out_of(u, y, half) } + }; + (warp(&f).into_iter().collect(), half) + } + CornerSource::AutoBetween => { + let before = move |q: Point| { + let (x, y) = along(q); + into(x - len, y, len) + }; + let after = move |q: Point| { + let (x, y) = along(q); + out_of(x, y, len) + }; + (warp(&before).into_iter().chain(warp(&after)).collect(), len) + } + CornerSource::AutoSliced | CornerSource::AutoOverlap => { + // Each side's tile runs `ahead` past the corner. + let far = len - ahead; + let before = move |q: Point| { + let (x, y) = along(q); + on_path(a_dir, x - far, y, 0.0, -far) + }; + let after = move |q: Point| { + let (x, y) = along(q); + on_path(b_dir, x - ahead, y, 0.0, far) + }; + let (first, second) = (warp(&before), warp(&after)); + if source == CornerSource::AutoOverlap { + return (first.into_iter().chain(second).collect(), far); + } + // Auto-Sliced masks each tile by the part of the outline of the box round its art + // (strokes included), placed as the tile is, on its own side of the diagonal. A tile + // squeezed along keeps its stroke width, so the box's margins at its ends grow by as + // much as the tile is squeezed. + let vb = art.visual_bounds().unwrap_or(*b); + let grow = 1.0 / s.min(1.0); + let (x0, x1) = (b.x0 - (b.x0 - vb.x0).max(0.0) * grow, b.x1 + (vb.x1 - b.x1).max(0.0) * grow); + let corners = [(x0, vb.y0), (x1, vb.y0), (x1, vb.y1), (x0, vb.y1), (x0, vb.y0)].map(|(x, y)| Point::new(x, y)); + let outline = |f: &dyn Fn(Point) -> Point| -> Vec { + let steps = (0..MASK_STEPS).map(|j| j as f64 / MASK_STEPS as f64); + corners.windows(2).flat_map(|w| steps.clone().map(move |s| w[0].lerp(w[1], s))).map(f).collect() + }; + let pieces = + first.and_then(|n| masked(n, &outline(&before), p, bt)).into_iter().chain(second.and_then(|n| masked(n, &outline(&after), p, -bt))); + (pieces.collect(), far) + } + } +} + +/// `n` in a clipping group whose mask is the part of polygon `quad` on the side of the line through +/// `p` that `towards` points away from. `None` when nothing of `quad` is on that side. +fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option { + let side = |q: Point| (q - p).dot(towards); + let mut kept = vec![]; + for (j, &a) in quad.iter().enumerate() { + let b = quad.get(j + 1).or(quad.first()).copied().unwrap_or(a); + let (da, db) = (side(a), side(b)); + if da <= 0.0 { + kept.push(a); + } + if (da <= 0.0) != (db <= 0.0) { + kept.push(a.lerp(b, da / (da - db))); + } + } + if kept.len() < 3 { + return None; + } + let mask = Node::path(NodeId(0), PathData::new(vec![SubPath::polyline(&kept, true)]), Appearance::default()); + Some(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(n)], clip: true })) +} + pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across); let Some(side) = orient(&pb.side) else { return vec![] }; @@ -413,6 +682,9 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let end = pb.end.as_ref().and_then(orient); let tw = side.1.width() * k; let gap = tw * pb.spacing.max(0.0) / 100.0; + // Each kind of corner takes its own art, else its source, else the other kind's art. + let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source)); + let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source)); let mut out = Budget::default(); for t in tracks(bp, 0.05) { if out.full() { @@ -423,13 +695,51 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec continue; } let corners = t.corner_positions(); - // Corner tiles, centred on the corner and turned to the bisector. + // Corner tiles, centred on the corner and turned to the bisector. The path on each side of + // a corner is shortened by `trims` (`None`: half the widest corner tile art). let mut corner_half = 0.0_f64; - for &(i, _) in &corners { + let mut trims: Vec> = Vec::with_capacity(corners.len()); + // The path a tile made at corner `ci` may cover on each side: up to the ends of an open + // track, and half the way to the next corner. + let room = |ci: usize| { + let at = |j: usize| corners.get(j).map(|c| c.1); + let s = at(ci).unwrap_or(0.0); + let last = corners.len().saturating_sub(1); + let before = match (ci.checked_sub(1).and_then(at), t.closed) { + (Some(prev), _) => (s - prev) / 2.0, + (None, true) => (s - (at(last).unwrap_or(s) - l)) / 2.0, + (None, false) => s, + }; + let after = match (at(ci + 1), t.closed) { + (Some(next), _) => (next - s) / 2.0, + (None, true) => (at(0).unwrap_or(s) + l - s) / 2.0, + (None, false) => l - s, + }; + before.min(after) + }; + for (ci, &(i, _)) in corners.iter().enumerate() { + if out.full() { + break; + } let (a, b) = t.in_out(i); // Turning towards the left normal puts the tile's +y side on the inside. - let tile = if a.cross(b) > 0.0 { inner.as_ref().or(outer.as_ref()) } else { outer.as_ref().or(inner.as_ref()) }; - let Some((art, bb)) = tile else { continue }; + let tile = if a.cross(b) > 0.0 { inner_tile.or(outer.as_ref().map(Corner::Art)) } else { outer_tile.or(inner.as_ref().map(Corner::Art)) }; + let (art, bb) = match tile { + Some(Corner::Art(tile)) => tile, + Some(Corner::Source(source)) => { + let (pieces, trim) = auto_corner(&t, i, source, &side, k, room(ci)); + for n in pieces { + out.take(Some(n)); + } + trims.push(Some(trim)); + continue; + } + None => { + trims.push(Some(0.0)); + continue; + } + }; + trims.push(None); corner_half = corner_half.max(bb.width() * k / 2.0); let bis = a + b; let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() }; @@ -441,30 +751,30 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec n.transform(xf, true); out.take(Some(n)); } - // Runs between corners (closed tracks wrap past the end). + let trim = |c: Option| c.and_then(|c| trims.get(c).copied()).map_or(0.0, |t| t.unwrap_or(corner_half)); + // Runs between corners (closed tracks wrap past the end), with the corners they start and + // end at. let mut bounds: Vec = corners.iter().map(|c| c.1).collect(); - let runs: Vec<(f64, f64, bool, bool)> = if bounds.is_empty() { - vec![(0.0, l, false, false)] + let nc = bounds.len(); + let runs: Vec<(f64, f64, Option, Option)> = if bounds.is_empty() { + vec![(0.0, l, None, None)] } else if t.closed { let first = bounds[0]; bounds.push(first + l); - bounds.windows(2).map(|w| (w[0], w[1], true, true)).collect() + bounds.windows(2).enumerate().map(|(i, w)| (w[0], w[1], Some(i), Some((i + 1) % nc))).collect() } else { let mut v = vec![0.0]; v.extend(bounds); v.push(l); - let n = v.len(); - v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i > 0, i + 2 < n)).collect() + v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i.checked_sub(1), (i < nc).then_some(i))).collect() }; let nruns = runs.len(); - let corner_tiles = inner.is_some() || outer.is_some(); for (ri, (mut a, mut b, ca, cb)) in runs.into_iter().enumerate() { - if ca && corner_tiles { - a += corner_half; - } - if cb && corner_tiles { - b -= corner_half; + if out.full() { + break; } + a += trim(ca); + b -= trim(cb); if !t.closed && ri == 0 && let Some(st) = &start @@ -532,3 +842,83 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec } out.out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::track::tracks; + + /// #3926 review: the five by five grid cannot promise a run is straight, only that it looks + /// straight where it was asked. A zigzag whose vertices land on the sample positions is + /// invisible to it: every sample sits on a y = 0 vertex whose blended tangent is flat, so all + /// 25 points agree with the affine exactly, while the art between them is thrown 4 pt off. + /// + /// This is why the gate is `Track::is_straight` and the grid is only a second opinion. + #[test] + fn a_bend_shorter_than_the_sample_grid_is_still_a_bend() { + // Half-period 20 pt, amplitude 4 pt. The 11.3 degree turn is under `CORNER_DEG`, so the + // vertices are not corners and their tangents blend flat, which is what hides them. + let (d, a) = (20.0, 4.0); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for i in 1..=16 { + bp.line_to((d * i as f64, if i % 2 == 1 { a } else { 0.0 })); + } + let t = tracks(&bp, 0.01).remove(0); + assert_eq!(t.corner.iter().filter(|c| **c).count(), 0, "no vertex is a corner, so every tangent is blended"); + + // A span over four full periods, held clear of both ends so no sample hits the + // extrapolation branch: the five samples land on vertices 4, 6, 8, 10 and 12, all at y = 0. + let seglen = (d * d + a * a).sqrt(); + let (s0, s1) = (4.0 * seglen, 12.0 * seglen); + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let along = AlongMap::linear(b.width(), s0, s1); + let (cy, x0, cross) = (b.center().y, b.x0, 1.0); + let f = |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross); + let scale = along.max_scale().max(cross.abs()).max(1e-6); + let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale; + + // The grid is satisfied, and it should not be. + let fit = affine_fit(&f, b, flat_tol.max(1e-9)).expect("the sampled check accepts this bend"); + + // How wrong it is: an art x landing on a peak of the zigzag, which no sample reaches. + let peak_x = x0 + (5.0 * seglen - s0) / (s1 - s0) * b.width(); + let at_peak = Point::new(peak_x, cy); + let off = (f(at_peak) - fit * at_peak).hypot(); + assert!( + off > 3.0 && off > flat_tol * 20.0, + "the affine the grid approved is {off:.3} pt out at the peak, against a {flat_tol:.4} pt tolerance" + ); + + // Reading the track instead refuses it, and still admits the straight run it is made of. + assert!(!t.is_straight(s0, s1), "a bend between the samples is still a bend"); + let straight = tracks( + &{ + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((320.0, 0.0)); + bp + }, + 0.01, + ) + .remove(0); + assert!(straight.is_straight(s0, s1), "and the same span of an actually straight track is straight"); + + // End to end through `bend`: the aliased bend is subdivided, the straight run keeps its + // curves. This is the gate itself, not the two predicates it is made of. + let anchors = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::(), + _ => 0, + }; + let circle = Node::path( + vectorcraft_doc::NodeId(0), + vectorcraft_geom::shapes::ellipse(b), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + assert_eq!(anchors(&circle), 4, "the art is a 4-anchor circle to begin with"); + let bent = bend(&t, &circle, b, along, cross).expect("the zigzag still draws something"); + assert!(anchors(&bent) > 8, "the aliased bend is subdivided, not affine-mapped: {} anchors", anchors(&bent)); + let moved = bend(&straight, &circle, b, along, cross).expect("the straight run still draws something"); + assert_eq!(anchors(&moved), 4, "the straight run keeps the tile's curves"); + } +} diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs new file mode 100644 index 0000000000..cf85507c72 --- /dev/null +++ b/crates/doc/src/canvas_range.rs @@ -0,0 +1,328 @@ +//! A read document brought within the canvas: what lies beyond [`MAX_COORD`] is removed (#3575). +//! Edits refuse to leave anything there (`doc_sane` in the engine checks the artboards, the guides, +//! the ruler origin and the objects an edit touches), so a file holding something beyond it could be +//! opened but not edited: one object, artboard or guide out there made every move fail. + +use std::sync::Arc; + +use vectorcraft_geom::{Point, Rect}; + +use crate::{Document, MAX_COORD, Node, NodeId, NodeKind}; + +/// What [`Document::drop_beyond_canvas`] removed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BeyondCanvas { + /// Objects removed whole (a layer or group keeps its children within the canvas). + pub objects: usize, + pub artboards: usize, + /// Guides beyond the canvas, and the artboard guides of the artboards removed. + pub guides: usize, + /// The ruler origin was beyond the canvas and is back at the origin. + pub ruler_origin: bool, +} + +impl BeyondCanvas { + /// The warning for what was removed, or `None` when nothing was. + pub fn note(&self) -> Option { + let count = |n: usize, one: &str, many: &str| match n { + 0 => None, + 1 => Some(format!("1 {one}")), + n => Some(format!("{n} {many}")), + }; + let parts: Vec = + [count(self.objects, "object", "objects"), count(self.artboards, "artboard", "artboards"), count(self.guides, "guide", "guides")] + .into_iter() + .flatten() + .collect(); + let limit = format!("more than {MAX_COORD} pt from the origin"); + match (parts.is_empty(), self.ruler_origin) { + (true, false) => None, + (true, true) => Some(format!("the ruler origin lay beyond the canvas ({limit}) and was reset")), + (false, origin) => Some(format!( + "{} beyond the canvas ({limit}) {} removed{}", + parts.join(", "), + if self.objects + self.artboards + self.guides == 1 { "was" } else { "were" }, + if origin { "; the ruler origin, beyond it too, was reset" } else { "" } + )), + } + } +} + +/// Is every side of `r` finite and within [`MAX_COORD`] of the origin? +fn rect_in_range(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| in_range(*v)) +} + +fn in_range(v: f64) -> bool { + v.is_finite() && v.abs() <= MAX_COORD +} + +/// Does `n` reach beyond the canvas? An object without geometry doesn't. +fn beyond(n: &Node) -> bool { + n.geometric_bounds().is_some_and(|b| !rect_in_range(b)) +} + +/// Whether `n` is a layer or group, and if so whether it clips: the containers whose objects are +/// looked at one by one. Anything else is one object, measured whole. +fn clipping(n: &Node) -> Option { + match n.kind { + NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => Some(clip), + _ => None, + } +} + +/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by +/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its +/// clipping path. +fn reaches_beyond(n: &Node) -> bool { + match clipping(n) { + None => beyond(n), + Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))), + } +} + +/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips +/// go together, so no other object becomes the clipping path. +fn clips_beyond(n: &Node) -> bool { + clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c)) +} + +/// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go +/// with them. +fn layers_in(n: &Node, layers: &mut Vec) { + if matches!(n.kind, NodeKind::Layer { .. }) { + layers.push(n.id); + } + if clipping(n).is_some() { + for c in n.children().into_iter().flatten() { + layers_in(c, layers); + } + } +} + +/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its +/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything +/// else goes whole. Only containers holding such an object are copied. Recursion follows the +/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`. +fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec) { + if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) { + return; + } + let Some(children) = Arc::make_mut(node).children_mut() else { return }; + children.retain_mut(|c| { + if !reaches_beyond(c) { + return true; + } + if clipping(c).is_some() && !clips_beyond(c) { + drop_children_beyond(c, dropped, layers); + return true; + } + layers_in(c, layers); + *dropped += 1; + false + }); +} + +impl Document { + /// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects + /// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied + /// and stops clipping), artboards with their ruler origins and artboard guides (their ids + /// are retired, as deleting them does), guides with the layer guides of the layers removed, + /// and put a ruler origin beyond it back at the origin. Saved selections, assets and variable + /// bindings then forget the objects removed. Run after [`Document::limit_depth`]. A document + /// whose artboards were all removed has none left. + pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas { + let mut gone = BeyondCanvas::default(); + let mut layers = vec![]; + for layer in &mut self.layers { + if clips_beyond(layer) { + let layer = Arc::make_mut(layer); + if let NodeKind::Layer { children, clip, .. } = &mut layer.kind { + for c in children.iter() { + layers_in(c, &mut layers); + } + gone.objects += children.len(); + children.clear(); + *clip = false; + } + } else { + drop_children_beyond(layer, &mut gone.objects, &mut layers); + } + } + if gone.objects > 0 { + self.tidy_saved_selections(); + self.prune_assets(); + self.prune_variable_bindings(); + } + let removed: Vec = self.artboards.iter().filter(|a| !rect_in_range(a.rect)).map(|a| a.id).collect(); + self.artboards.retain(|a| rect_in_range(a.rect)); + for id in &removed { + self.artboard_ruler_origins.remove(id); + self.retire_artboard_id(*id); + } + gone.artboards = removed.len(); + let guides = self.guides.len(); + self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + gone.guides = guides - self.guides.len(); + if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) { + self.ruler_origin = Point::ZERO; + gone.ruler_origin = true; + } + gone + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Appearance, Artboard, Guide}; + use vectorcraft_geom::shapes; + + fn rect(id: u64, y: f64) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, y, 10.0, y + 10.0)), Appearance::default_art())) + } + + fn doc(children: Vec>) -> Document { + let mut d = Document::new(100.0, 100.0); + if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) { + ch.extend(children); + } + d + } + + fn ids(d: &Document) -> Vec { + let mut out = vec![]; + fn walk(n: &Node, out: &mut Vec) { + out.push(n.id.0); + for c in n.children().into_iter().flatten() { + walk(c, out); + } + } + for l in &d.layers { + walk(l, &mut out); + } + out + } + + #[test] + fn a_document_within_the_canvas_is_left_alone() { + let mut d = doc(vec![rect(10, 0.0), rect(11, MAX_COORD - 10.0), rect(12, -MAX_COORD)]); + let before = d.clone(); + assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default()); + assert_eq!(d, before); + assert_eq!(BeyondCanvas::default().note(), None); + } + + #[test] + fn objects_beyond_the_canvas_are_removed_and_groups_keep_the_rest() { + let group = Arc::new(Node::group(NodeId(20), vec![rect(21, 0.0), rect(22, 1e17)])); + let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, f64::NAN), rect(32, 0.0)], clip: true })); + let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), group, clip, rect(12, -MAX_COORD - 1.0)]); + let layer = d.layers[0].id.0; + let gone = d.drop_beyond_canvas(); + assert_eq!(gone, BeyondCanvas { objects: 4, ..Default::default() }); + assert_eq!(ids(&d), [layer, 10, 20, 21], "the group keeps its object within the canvas; the clip group goes with its clipping path"); + assert!(d.layers.iter().all(|l| !beyond(l))); + assert_eq!(gone.note().unwrap(), format!("4 objects beyond the canvas (more than {MAX_COORD} pt from the origin) were removed")); + } + + #[test] + fn artboards_guides_and_the_ruler_origin_beyond_the_canvas_are_removed_or_reset() { + let mut d = doc(vec![rect(10, 0.0)]); + let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }; + d.artboards.push(far); + let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) }; + d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; + d.ruler_origin = Point::new(1e17, 0.0); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true }); + assert_eq!(d.artboards.len(), 1); + assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(d.ruler_origin, Point::ZERO); + assert_eq!( + gone.note().unwrap(), + format!( + "1 artboard, 3 guides beyond the canvas (more than {MAX_COORD} pt from the origin) were removed; the ruler origin, beyond it too, was reset" + ) + ); + } + + /// A sublayer like the document's first layer, holding `children`. + fn sublayer(d: &Document, id: u64, children: Vec>, clip: bool) -> Arc { + let mut l = (*d.layers[0]).clone(); + l.id = NodeId(id); + if let NodeKind::Layer { children: c, clip: k, .. } = &mut l.kind { + *c = children; + *k = clip; + } + Arc::new(l) + } + + #[test] + fn objects_hidden_or_clipped_inside_a_container_are_looked_at_one_by_one() { + // A layer's bounds leave out its hidden objects, and a clip group's are its clipping path's. + let mut hidden = (*rect(41, 1e17)).clone(); + hidden.visible = false; + let probe = Document::new(1.0, 1.0); + let sub = sublayer(&probe, 40, vec![Arc::new(hidden), rect(42, 0.0)], false); + let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, 0.0), rect(32, 1e17), rect(33, 5.0)], clip: true })); + let mut d = doc(vec![sub, clip]); + let layer = d.layers[0].id.0; + assert_eq!(d.drop_beyond_canvas().objects, 2); + assert_eq!(ids(&d), [layer, 40, 42, 30, 31, 33], "the far hidden object and the far clipped one go; the clipping path stays first"); + } + + #[test] + fn a_top_level_layer_clipping_by_a_path_beyond_the_canvas_is_emptied() { + let mut d = doc(vec![rect(10, 1e17), rect(11, 0.0), rect(12, 5.0)]); + if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *clip = true; + } + assert_eq!(d.drop_beyond_canvas().objects, 3, "the clipping path and what it clipped go together"); + assert!( + matches!(&d.layers[0].kind, NodeKind::Layer { children, clip: false, .. } if children.is_empty()), + "no other object becomes the clipping path" + ); + // A clipping sublayer goes whole, as a clip group does. + let mut d = doc(vec![]); + let sub = sublayer(&d, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); + let mut d2 = doc(vec![sub, rect(10, 0.0)]); + let layer = d2.layers[0].id.0; + assert_eq!(d2.drop_beyond_canvas().objects, 1); + assert_eq!(ids(&d2), [layer, 10]); + assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default()); + } + + #[test] + fn a_removed_artboards_ruler_origin_goes_and_its_id_is_not_given_again() { + let mut d = doc(vec![]); + let kept = d.artboards[0].id; + d.artboards.push(Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }); + d.artboard_ruler_origins.insert(kept, vectorcraft_geom::Vec2::new(1.0, 2.0)); + d.artboard_ruler_origins.insert(9, vectorcraft_geom::Vec2::new(3.0, 4.0)); + assert_eq!(d.drop_beyond_canvas().artboards, 1); + assert_eq!(d.artboard_ruler_origins.keys().copied().collect::>(), [kept]); + assert!(d.next_artboard_id() > 9, "a new artboard doesn't take the removed one's id"); + } + + #[test] + fn what_names_a_removed_object_forgets_it() { + let probe = Document::new(1.0, 1.0); + let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); + let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]); + d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; + d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); + d.assets = vec![ + crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] }, + crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] }, + ]; + d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); + d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); + let gone = d.drop_beyond_canvas(); + assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide"); + assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays"); + assert_eq!(d.saved_selections[0].objects, [NodeId(10)]); + assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); + assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 18873d6d93..91ede3df9d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -117,6 +117,7 @@ use vectorcraft_color::{Swatch, SwatchGroup}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; mod artboard; +mod canvas_range; mod document; mod document_ext; mod document_guides; @@ -127,6 +128,7 @@ mod records; mod units; pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +pub use canvas_range::BeyondCanvas; #[cfg(test)] use document_ext::free_id; pub use graphic_style::{DEFAULT_GRAPHIC_STYLE, GraphicStyle}; diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 61b0b91a9b..c67dbee3ee 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -1,8 +1,8 @@ -use super::envelope::{EnvelopeOptions, expand_envelope}; +use super::envelope::{EnvelopeOptions, PreserveShape, expand_envelope}; use super::mesh::GradientMesh; use super::warp::map_nonlinear; use super::{ - Affine, Appearance, AppearanceItem, Arc, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2, + Affine, Appearance, AppearanceItem, Arc, Color, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2, blend_expand, }; @@ -157,8 +157,10 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { /// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into /// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its -/// edges (at most half a point) so neighbours overlap and no seams show. +/// that takes the triangle where the warp takes its corners (with Preserve Shape Using +/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing). +/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no +/// seams show. fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); // Two triangles a cell, three objects each; an image an inner envelope already cut into @@ -189,11 +191,20 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { if len > 1e-12 { *p + v * (grow / len) } else { *p } }) .collect(); - let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default()); + let shape = PathData::single(SubPath::polyline(&tri, true)); + let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); + image.extras_mut().pixelated = !w.options.anti_alias; + if w.options.preserve_shape == PreserveShape::Transparency { + // Preserve Shape Using Transparency: the triangle is the piece's alpha (a + // white opacity mask) instead of its clipping path. + let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); + pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))))); + continue; + } + let mut clip = Node::path(n.id, shape, Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2be25e0b10..3dd07351e3 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -28,10 +28,11 @@ pub use attributes::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttribut pub use clip::{ClipPaint, Uniter}; pub use extras::{Extras, ExtrasMut, NodeExtras}; pub use ids::{LAYER_COLORS, LayerColor, NodeId}; -pub use kind::{ImageObject, NodeKind}; +pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget}; pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; use wire::NodeWire; +pub use wire::{MAX_READ_NODES, with_node_budget}; fn yes() -> bool { true diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 4689303487..2d8ad8c9c0 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -23,6 +23,9 @@ pub struct NodeExtras { /// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale /// the placed gradients in it with the outlines. pub type_outlines: bool, + /// An image drawn without smoothing: each pixel of the picture takes the color of the image + /// pixel nearest to it (an envelope's distorted image with Envelope Options › Anti-Alias off). + pub pixelated: bool, /// Editable Shaper composition; the original art is retained in its source child. pub shaper: Option>, /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps @@ -56,6 +59,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { graph: None, series_index: None, type_outlines: false, + pixelated: false, shaper: None, graphic_style: None, attrs: None, diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs index 0994cf390f..22470cbf3e 100644 --- a/crates/doc/src/node/kind.rs +++ b/crates/doc/src/node/kind.rs @@ -129,6 +129,8 @@ pub enum NodeKind { /// anywhere among its fields. Files have it first (it is written first): then the kind is read /// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the /// fields before `type` are buffered, and only those. +pub use kind_de::{MAX_BUFFERED_VALUES, with_buffered_value_budget}; + mod kind_de { use std::fmt; use std::sync::Arc; @@ -237,12 +239,150 @@ mod kind_de { let tag: String = map.next_value()?; return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } }); } - before.push((key, map.next_value::()?)); + before.push((key, map.next_value::()?.0)); } Err(de::Error::missing_field("type")) } } + /// Most JSON values (each number, string, array, object…) the fields read before an object's + /// `type` may hold together while [`with_buffered_value_budget`] runs: 32M (about 1 GB as + /// `serde_json::Value`s, some 32 bytes each), 4M in the web build. VectorCraft writes `type` + /// first, so its files buffer nothing; a reordered file buffers what comes before it, and + /// a crafted one could fill memory with values two bytes each in the file before any object in + /// them is counted (#3587). + pub const MAX_BUFFERED_VALUES: usize = if cfg!(target_arch = "wasm32") { 4 << 20 } else { 32 << 20 }; + + std::thread_local! { + /// Values the fields buffered on this thread may still hold, and the budget they come from. + static VALUES_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// Buffered fields being read back: what they hold was counted when it was buffered. + static REPLAYING: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + /// Run `f` (which reads objects with serde) letting the fields it buffers before an object's + /// `type` hold at most `max` values together; buffering more fails. + pub fn with_buffered_value_budget(max: usize, f: impl FnOnce() -> T) -> T { + struct Restore(Option<(usize, usize)>); + impl Drop for Restore { + fn drop(&mut self) { + VALUES_LEFT.set(self.0); + } + } + let _restore = Restore(VALUES_LEFT.replace(Some((max, max)))); + f() + } + + /// While buffered fields are read back ([`REPLAYING`]). + struct Replaying; + + impl Replaying { + fn enter() -> Self { + REPLAYING.set(REPLAYING.get().saturating_add(1)); + Replaying + } + } + + impl Drop for Replaying { + fn drop(&mut self) { + REPLAYING.set(REPLAYING.get().saturating_sub(1)); + } + } + + /// Count one buffered value against [`with_buffered_value_budget`]. + fn count() -> Result<(), E> { + if REPLAYING.get() > 0 { + return Ok(()); + } + if let Some((left, max)) = VALUES_LEFT.get() { + let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("an object has more than {max} values before its type")))?; + VALUES_LEFT.set(Some((left, max))); + } + Ok(()) + } + + /// A field read before `type`, as a `serde_json::Value` counted value by value as it is read, + /// so the budget stops a large one before it is all in memory. + struct Counted(serde_json::Value); + + impl<'de> Deserialize<'de> for Counted { + fn deserialize>(d: D) -> Result { + d.deserialize_any(CountedVisitor) + } + } + + struct CountedVisitor; + + impl<'de> Visitor<'de> for CountedVisitor { + type Value = Counted; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a JSON value") + } + + fn visit_bool(self, v: bool) -> Result { + count()?; + Ok(Counted(serde_json::Value::Bool(v))) + } + + fn visit_i64(self, v: i64) -> Result { + count()?; + Ok(Counted(v.into())) + } + + fn visit_u64(self, v: u64) -> Result { + count()?; + Ok(Counted(v.into())) + } + + fn visit_f64(self, v: f64) -> Result { + count()?; + Ok(Counted(serde_json::Number::from_f64(v).map_or(serde_json::Value::Null, serde_json::Value::Number))) + } + + fn visit_str(self, v: &str) -> Result { + count()?; + Ok(Counted(serde_json::Value::String(v.to_owned()))) + } + + fn visit_string(self, v: String) -> Result { + count()?; + Ok(Counted(serde_json::Value::String(v))) + } + + fn visit_none(self) -> Result { + self.visit_unit() + } + + fn visit_unit(self) -> Result { + count()?; + Ok(Counted(serde_json::Value::Null)) + } + + fn visit_some>(self, d: D) -> Result { + Counted::deserialize(d) + } + + fn visit_seq>(self, mut seq: S) -> Result { + count()?; + let mut out = Vec::new(); + while let Some(Counted(v)) = seq.next_element()? { + out.push(v); + } + Ok(Counted(serde_json::Value::Array(out))) + } + + fn visit_map>(self, mut map: M) -> Result { + count()?; + let mut out = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + let Counted(v) = map.next_value()?; + out.insert(key, v); + } + Ok(Counted(serde_json::Value::Object(out))) + } + } + /// The fields read before `type` (buffered), then the rest of the object. struct Buffered { before: std::vec::IntoIter<(String, serde_json::Value)>, @@ -265,7 +405,11 @@ mod kind_de { fn next_value_seed>(&mut self, seed: V) -> Result { match self.value.take() { - Some(value) => seed.deserialize(value).map_err(de::Error::custom), + Some(value) => { + // Counted when it was read: the objects it holds buffer it again as they read it. + let _replaying = Replaying::enter(); + seed.deserialize(value).map_err(de::Error::custom) + } None => self.map.next_value_seed(seed), } } diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 3e7a399f26..0565c9e079 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -433,3 +433,37 @@ fn bad_kinds_are_errors() { assert!(serde_json::from_str::(bad).is_err(), "{bad}"); } } + +/// #3587: [`with_node_budget`] counts every object read, nested ones included, refuses the one past +/// it with a message naming the budget, and is gone once its reading is done. +#[test] +fn objects_are_read_within_the_node_budget() { + let group = Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![])), Arc::new(Node::group(NodeId(3), vec![]))]); + let json = serde_json::to_string(&group).unwrap(); + let read = || serde_json::from_str::(&json); + assert_eq!(with_node_budget(3, read).unwrap(), group, "three objects fit a budget of three"); + let why = with_node_budget(2, read).unwrap_err().to_string(); + assert!(why.contains("more than 2 objects"), "{why}"); + assert_eq!(read().unwrap(), group, "no budget outside a read"); + // A budget left by a read that failed doesn't carry over to the next one. + assert!(with_node_budget(1, read).is_err()); + assert!(with_node_budget(3, read).is_ok()); +} + +/// #3587: fields read before an object's `type` are buffered value by value within +/// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers +/// its own fields again without counting them twice. VectorCraft writes `type` first, which +/// buffers nothing. +#[test] +fn fields_before_the_type_are_buffered_within_the_value_budget() { + let reordered = r#"{"id":1,"kind":{"children":[{"id":2,"kind":{"children":[],"type":"group"}}],"type":"group"}}"#; + let read = |json: &str| serde_json::from_str::(json); + // The children's array, the inner object, its id, its kind, the kind's empty children and + // its type: six values. + assert!(with_buffered_value_budget(6, || read(reordered)).is_ok()); + let why = with_buffered_value_budget(5, || read(reordered)).unwrap_err().to_string(); + assert!(why.contains("more than 5 values before its type"), "{why}"); + let written = serde_json::to_string(&Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![]))])).unwrap(); + assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}"); + assert!(read(reordered).is_ok(), "no budget outside a read"); +} diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 0af451316b..84d0b220eb 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -30,6 +30,7 @@ impl Serialize for Node { e.graph.is_none(), e.series_index.is_none(), is_default(&e.type_outlines), + is_default(&e.pixelated), e.shaper.is_none(), false, e.graphic_style.is_none(), @@ -69,6 +70,7 @@ impl Serialize for Node { field!("graph", &e.graph); field!("series_index", &e.series_index); field!("type_outlines", &e.type_outlines); + field!("pixelated", &e.pixelated); field!("shaper", &e.shaper); field!("kind", &self.kind); field!("graphic_style", &e.graphic_style); @@ -84,11 +86,48 @@ impl Serialize for Node { } } +/// Most objects [`with_node_budget`] lets a document read: 4M (about 1 GB of the smallest +/// objects), 512K in the web build. Paths have their own anchor budget +/// ([`vectorcraft_geom::path::MAX_READ_ANCHORS`]); this one bounds the objects themselves. +pub const MAX_READ_NODES: usize = if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 }; + +std::thread_local! { + /// Objects the document being read on this thread may still have, and the budget they come + /// from ([`with_node_budget`]). + static NODES_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Run `f` (which reads nodes with serde) letting it read at most `max` objects together; reading +/// more fails. A compressed file of repeated empty objects stays small while they take gigabytes +/// once read, about 270 bytes each against some 50 in the file (#3587). +pub fn with_node_budget(max: usize, f: impl FnOnce() -> T) -> T { + struct Restore(Option<(usize, usize)>); + impl Drop for Restore { + fn drop(&mut self) { + NODES_LEFT.set(self.0); + } + } + let _restore = Restore(NODES_LEFT.replace(Some((max, max)))); + f() +} + +/// A node's id, counting the node against [`with_node_budget`]. Every node has exactly one id, so +/// each counts once, and the id usually comes first, before the node's children are read. +fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let id = NodeId::deserialize(d)?; + if let Some((left, max)) = NODES_LEFT.get() { + let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?; + NODES_LEFT.set(Some((left, max))); + } + Ok(id) +} + /// How a [`Node`] is read: the flat field list of the file format. Fields move into the node /// (and its extras, allocated only when one is set) without a copy of what they own. #[derive(Deserialize)] #[serde(rename = "Node")] pub(super) struct NodeWire { + #[serde(deserialize_with = "counted_id")] id: NodeId, #[serde(default)] name: Option, @@ -121,6 +160,8 @@ pub(super) struct NodeWire { #[serde(default)] type_outlines: bool, #[serde(default)] + pixelated: bool, + #[serde(default)] shaper: Option>, kind: NodeKind, #[serde(default)] @@ -152,6 +193,7 @@ impl From for Node { graph: w.graph, series_index: w.series_index, type_outlines: w.type_outlines, + pixelated: w.pixelated, shaper: w.shaper, graphic_style: w.graphic_style, attrs: w.attrs, diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index b5a1ffe328..875fcf3a0c 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -145,6 +145,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[ ("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }), ("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }), ("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }), + ("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }), ("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }), ("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }), ("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }), diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 8dbed28aed..692fa868c7 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -108,9 +108,10 @@ impl Document { Some(list.remove(i)) } - /// Visit every colour that can link to a swatch — solid paints and gradient stops of fills, - /// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern - /// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops). + /// Visit every color that can link to a swatch (solid paints and gradient stops of fills, + /// strokes and text runs) in the art (opacity masks included), symbol definitions, graph + /// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles + /// and the swatches themselves (tint swatches, gradient swatches' stops). /// `f(colour, link, tint)` may change them and returns true when it did /// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are /// copied. Returns the number of changed paints. @@ -119,7 +120,10 @@ impl Document { for s in &mut self.symbols { n += map_tree(&mut s.art, f); } - for p in &mut self.patterns { + for g in &mut self.graph_designs { + n += map_tree(&mut g.art, f); + } + for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) { n += map_trees(&mut p.art, f); } for gs in &mut self.graphic_styles { @@ -307,6 +311,19 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { } } +/// [`Document::map_solid_paints`] for art a document keeps outside its trees, such as the art of a +/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only +/// the nodes holding a changed paint are copied. Returns the number of changed paints. +pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { + match map_node(n, f) { + Some((new, count)) => { + *n = new; + count + } + None => 0, + } +} + fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { match map_node(n, f) { Some((new, count)) => { @@ -400,6 +417,25 @@ mod tests { assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0])); } + #[test] + fn mapping_reaches_graph_designs_and_the_tile_that_pattern_cancel_puts_back() { + let (mut d, a, _) = doc(); + let art = d.node_arc(a).cloned().unwrap(); + d.graph_designs.push(crate::GraphDesign { name: "Bar".into(), art: art.clone() }); + d.pattern_edit = + Some(crate::PatternEdit { pattern: "Tile".into(), layer: NodeId(0), original: Some(crate::PatternDef::new("Tile", vec![art])) }); + let mut loose = d.node(a).unwrap().clone(); + let blue = Color::rgb(0.0, 0.0, 1.0); + let mut to_blue = |c: &mut Color, s: &mut Option, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue; + assert_eq!(d.map_solid_paints(&mut to_blue), 3, "the layer's rectangle, the design and the Cancel tile"); + assert_eq!(d.graph_designs[0].art.appearance.fill_paint(), linked(blue, "Brand")); + let tile = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0]; + assert_eq!(tile.appearance.fill_paint(), linked(blue, "Brand")); + assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 1); + assert_eq!(loose.appearance.fill_paint(), linked(blue, "Brand")); + assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change"); + } + #[test] fn mapping_within_ids_visits_each_paint_once() { let (mut d, a, b) = doc(); diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 8fb97a7001..8f095566b4 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -71,6 +71,8 @@ pub struct SymbolBoxes<'a> { /// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`] /// ([`Self::unplaced_reach`]). unplaced: RefCell>, + /// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]). + runs: Cell, } impl Document { @@ -87,6 +89,7 @@ impl Document { holders: Cell::new(0), farthest: RefCell::default(), unplaced: RefCell::default(), + runs: Cell::new(0), } } } @@ -146,9 +149,16 @@ impl<'a> SymbolBoxes<'a> { pub fn measure_art(&self, name: &str, measure: impl FnOnce(&Arc) -> R) -> Option { self.depth(name)?; let sym = self.doc.symbols.iter().find(|s| s.name == name)?; + self.runs.set(self.runs.get() + 1); Some(measure(&sym.art)) } + /// How many times [`Self::measure_art`] has run a measure on symbol art for these boxes, the + /// measures behind the cached art boxes and placed visual boxes included. + pub fn art_measured(&self) -> usize { + self.runs.get() + } + /// The geometric box of the art of the symbol named `name` in symbol space, with the instances /// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of /// the symbol are then measured by [`SYMBOL_SQUARE`]. @@ -460,6 +470,26 @@ mod tests { assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0))); } + #[test] + fn each_symbols_art_box_is_measured_once() { + // `Pair` holds two instances of `Dot`; three instances of `Pair` are measured twice each. + let mut d = Document::new(200.0, 200.0); + let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0); + symbol(&mut d, "Dot", vec![dot]); + let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0))); + let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0))); + symbol(&mut d, "Pair", vec![right, below]); + let pairs: Vec = (0..3).map(|i| instance(&mut d, "Pair", Affine::translate((f64::from(i) * 10.0, 0.0)))).collect(); + let s = d.symbol_boxes().quick(); + assert_eq!(s.art_measured(), 0); + for _ in 0..2 { + for p in &pairs { + p.geometric_bounds_with(&s); + } + } + assert_eq!(s.art_measured(), 2, "the art of Pair and of Dot"); + } + #[test] fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() { // A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5ad48a1ed9..e000b930f4 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -264,7 +264,7 @@ pub fn effect_catalog() -> Vec { "stylize.dropShadow", "Drop Shadow…", STYLIZE, - "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)", + "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": use d = darkness / 100 on the source colors before document color conversion and blur; RGB channels become max(0, channel - d), CMYK keeps C/M/Y and sets K to min(1, K + d), and Gray sets its black component to min(1, K + d). The near-100% endpoint is gray black. Shadow opacity remains separate)", json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}), ), r( diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index f3c5b3e5bd..8ef171d326 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art). scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index c7a7c39b5d..155e7d1647 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -10,6 +10,7 @@ //! and the traced filled paths above it, mapped with the image's transform. Expand drops the //! image (a plain group of paths remains); Release puts the image back in place of the group. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -51,7 +52,7 @@ pub fn specs() -> Vec { "Shape Builder", [], None, - "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", + "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", has_selection_or_ids, sb_merge ), @@ -301,6 +302,89 @@ fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option) n } +/// Put each list of `outs` in place of the leaf of `leaves` at the same index (none: the leaf stays +/// as it is), then remove the groups under `roots` that this leaves empty. A Live Paint or Shaper +/// group (the outermost one under the root) is replaced as a whole, by the lists of its leaves, +/// with a copy of each leaf that stays and of each hidden or locked object in it. Other objects +/// under the roots, such as type, hidden or locked objects and clipping paths, stay where they are. Returns what to select: each root +/// still there, or what went in its place. +fn replace_leaves(d: &mut Document, roots: &[NodeId], leaves: &[Node], outs: Vec>>) -> Result> { + let listed: HashSet = roots.iter().copied().collect(); + let paths = d.index_paths(leaves.iter().map(|l| l.id)); + // Each leaf's unit (the leaf, or the group replaced as a whole) and the groups from the unit's + // parent up to its root, innermost first. + let mut places: Vec<(NodeId, Vec)> = vec![]; + for leaf in leaves { + let anc = paths.get(&leaf.id).and_then(|p| d.ancestry_at(p)).unwrap_or_default(); + let below = anc.iter().position(|a| listed.contains(a)).and_then(|r| anc.get(r..)).unwrap_or_default(); + let whole = below.iter().position(|a| d.node(*a).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some())); + let at = whole.unwrap_or(below.len().saturating_sub(1)); + let unit = below.get(at).copied().unwrap_or(leaf.id); + places.push((unit, below.get(..at).unwrap_or_default().iter().rev().copied().collect())); + } + // (unit, groups above it, what goes in place of each of its leaves, whether it changes); a + // unit's leaves are next to each other, in paint order. + type Unit = (NodeId, Vec, Vec<(NodeId, Vec)>, bool); + let mut units: Vec = vec![]; + for ((leaf, out), (unit, above)) in leaves.iter().zip(outs).zip(places) { + let changed = out.is_some(); + let list = match out { + Some(list) => list, + None if unit != leaf.id => vec![d.reid(leaf)], + None => vec![], + }; + match units.last_mut() { + Some(u) if u.0 == unit => { + u.2.push((leaf.id, list)); + u.3 |= changed; + } + _ => units.push((unit, above, vec![(leaf.id, list)], changed)), + } + } + units.retain(|u| u.3); + let ids: Vec = units.iter().map(|u| u.0).collect(); + let mut replaced = Beside::replacing(d, &ids); + let mut put: HashMap> = HashMap::new(); + let mut emptied = vec![]; + for (unit, above, lists, _) in units { + // A group replaced as a whole keeps a copy of each hidden or locked object in it, where it was. + let members: Vec = d.node(unit).map(|n| b::builder_members(n).into_iter().cloned().collect()).unwrap_or_default(); + let mut at: HashMap = HashMap::new(); + for (k, l) in lists.iter().enumerate() { + at.entry(l.0).or_insert(k); + } + let mut lists: Vec>> = lists.into_iter().map(|l| Some(l.1)).collect(); + let mut list = vec![]; + for m in &members { + if let Some(l) = at.remove(&m.id).and_then(|k| lists.get_mut(k)).and_then(Option::take) { + list.extend(l); + } else if (m.locked || !m.visible) && m.id != unit { + list.push(d.reid(m)); + } + } + list.extend(lists.into_iter().flatten().flatten()); + if list.is_empty() { + emptied.push(above); + } + put.insert(unit, list.iter().map(|n| n.id).collect()); + replaced.put(d, unit, list)?; + } + replaced.finish(d)?; + // A group goes only when nothing is left in it. + for above in emptied { + for g in above { + match d.node(g) { + None => continue, + Some(n) if !n.is_layer() && n.children().is_some_and(|c| c.is_empty()) => { + d.remove(g)?; + } + Some(_) => break, + } + } + } + Ok(roots.iter().flat_map(|r| put.get(r).cloned().unwrap_or_else(|| d.node(*r).map(|_| vec![*r]).unwrap_or_default())).collect()) +} + // ---------- Live Paint ---------- /// The source nodes a root contributes (a Live Paint group contributes its own sources). diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index c64c199154..fea79a0e6a 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,6 +1,6 @@ use super::{ Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers, - f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, replace_with_pieces, root_ids, + f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids, shape_node, switch_or, }; @@ -125,43 +125,48 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { let covered = if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) }; let style_src = covered.or(map.shapes.len().checked_sub(1)); - let mut new_nodes: Vec = vec![]; + // What goes in place of each object the map was made from (none: it stays as it is). + let mut outs: Vec>> = vec![]; let mut merged_id = None; for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() { + let mut out = None; let pieces: Vec = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect(); if !pieces.is_empty() { // An open path loses the pieces deleted and those between two merged (or deleted) regions. let keep: Vec = pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect(); - if keep.len() == pieces.len() { - new_nodes.push(d.reid(leaf)); - } else { + if keep.len() != pieces.len() { + let mut kept = vec![]; for k in keep { if let Some((l, _)) = map.lines.get(k) { - new_nodes.push(remnant(d, leaf, l.path.clone(), None)); + kept.push(remnant(d, leaf, l.path.clone(), None)); } } + out = Some(kept); } } else if let Some(Some(rest)) = geometry.rests.get(i) { // A shape loses the regions deleted or merged, then the edges deleted (each one on // its outline, shared edges from every shape along them). What is left of its own // outline keeps its fill rule; united faces fill with non-zero. - if !rest.path.is_empty() { - new_nodes.push(remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))); - } - } else { - new_nodes.push(d.reid(leaf)); + out = Some(if rest.path.is_empty() { + vec![] + } else { + vec![remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))] + }); } + // The merged shape goes in place of the object it takes its look from, so it stays in + // that object's group (a clip group too). if !erase && !union.is_empty() && Some(i) == style_src { let mut m = shape_node(d, union.clone(), Some(leaf)); if let Some(f) = &fill { m.appearance.set_fill(f.clone()); } merged_id = Some(m.id); - new_nodes.push(m); + out.get_or_insert_with(|| vec![leaf.clone()]).push(m); } + outs.push(out); } - let new_ids = replace_roots(d, &roots, new_nodes)?; + let new_ids = replace_leaves(d, &roots, &leaves, outs)?; sel.set(new_ids.iter().copied()); Ok((new_ids, merged_id, touched, None)) })?; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 5c31deb1c2..b1ad12800e 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -322,12 +322,16 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result { Ok(json!({ "changed": changed })) } -/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols, -/// pattern tiles and swatches through the colour settings (`intent`, default: theirs) → how many -/// colours of the art, symbols and patterns changed. Gray colours stay Gray (they print on the -/// black plate in either mode); RGB greys are colours like any other and separate through the -/// CMYK profile, unless `grays` puts them on the black plate. Lab colours (spot colour -/// definitions) are device independent and fit either mode. Document Color Mode and +/// Put `d` in color `mode` and, with `convert`, convert every color of its art (opacity masks +/// included), symbols, graph designs, pattern tiles (the tile that pattern editing's Cancel puts +/// back included), graphic styles, brushes (their art and a Hue Shift key color) and swatches +/// through the color settings (`intent`, default: theirs) → how many colors of the art (opacity +/// masks included), symbols, graph designs and pattern tiles changed; the colors of graphic +/// styles and brushes convert without being counted. Gray colors stay +/// Gray (they print on the black plate in either mode); RGB grays are colors like any other and +/// separate through the CMYK profile, unless `grays` puts them on the black plate. Lab colors +/// (spot color definitions) are device independent and fit either mode. A tint of a global +/// swatch becomes the converted swatch's color at its tint. Document Color Mode and /// `document.open {colorMode}` both convert this way. pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option, grays: Grays) -> usize { d.color_mode = mode; @@ -344,18 +348,6 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, Color::Rgb { .. } if k_only && Neutral::of(col).is_some() => c.convert(&c.convert(col, Model::Gray, intent), Model::Cmyk, intent), _ => c.convert(col, model, intent), }; - let mut changed = 0usize; - let mut count = |col: &Color, _: proof::Link| { - let n = conv(col); - if n != *col { - changed += 1; - } - n - }; - proof::map_document_colors(d, &mut count); - for a in d.patterns.iter_mut().flat_map(|p| &mut p.art) { - proof::map_node_colors(Arc::make_mut(a), &mut count); - } for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), @@ -369,6 +361,73 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, } // Tints are their converted swatch's colour at their tint (exact ink percentages). let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect(); + let changed = std::cell::Cell::new(0usize); + let mut count = |col: &Color, link: proof::Link| { + let n = match link.and_then(|(name, tint)| Some((bases.iter().find(|(n, _)| n == name)?, tint))) { + Some(((_, base), tint)) if tint < 1.0 => base.tinted(tint), + _ => conv(col), + }; + if n != *col { + changed.set(changed.get() + 1); + } + n + }; + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|e| e.original.as_mut())); + for a in d + .layers + .iter_mut() + .chain(d.symbols.iter_mut().map(|s| &mut s.art)) + .chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(patterns.flat_map(|p| &mut p.art)) + { + map_art_colors(Arc::make_mut(a), &mut count); + } + // The reply counts the colors of the document's art only, not those of the graphic styles and brushes. + let in_art = changed.get(); + for it in d.graphic_styles.iter_mut().flat_map(|g| &mut g.appearance.items) { + let paint = match it { + vectorcraft_doc::AppearanceItem::Fill(l) => &mut l.paint, + vectorcraft_doc::AppearanceItem::Stroke(l) => &mut l.paint, + }; + map_paint_colors(paint, &mut count); + } + // Convert each brush's art. A Hue Shift key color takes the converted color of the art color + // it matches (a tint of a global swatch included), so the key still matches the converted art; + // a key that matches no art color converts on its own. + let in_styles = changed.get(); + let mut brushes = vectorcraft_brush::library(d); + let mut keys = false; + for b in &mut brushes { + let old_key = match b.kind.colorization() { + vectorcraft_brush::Colorization::HueShift { key } => Some(key), + _ => None, + }; + let mut new_key = None; + for art in b.art_mut() { + map_art_colors(art, &mut |col: &Color, link: proof::Link| { + let n = count(col, link); + if new_key.is_none() && old_key.is_some_and(|k| matches_key(col, &k)) { + new_key = Some(n); + } + n + }); + } + let colorization = match &mut b.kind { + vectorcraft_brush::BrushKind::Art(a) => Some(&mut a.colorization), + vectorcraft_brush::BrushKind::Scatter(s) => Some(&mut s.colorization), + vectorcraft_brush::BrushKind::Pattern(p) => Some(&mut p.colorization), + vectorcraft_brush::BrushKind::Calligraphic(_) | vectorcraft_brush::BrushKind::Bristle(_) => None, + }; + if let Some(vectorcraft_brush::Colorization::HueShift { key }) = colorization { + let n = new_key.unwrap_or_else(|| conv(key)); + keys |= n != *key; + *key = n; + } + } + if keys || changed.get() > in_styles { + vectorcraft_brush::store(d, &brushes); + } + // This pass gives tint swatches their converted swatch's color at their tint; the passes above already did the same for the art's tints. d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) { Some((_, base)) if *tint < 1.0 => { *c = base.tinted(*tint); @@ -376,7 +435,43 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, } _ => false, }); - changed + in_art +} + +/// [`proof::map_node_colors`] over `n`'s subtree and the art of every opacity mask in it. +fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { + proof::map_node_colors(n, f); + map_mask_colors(n, f); +} + +fn map_mask_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { + map_art_colors(Arc::make_mut(&mut mask.art), f); + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + map_mask_colors(Arc::make_mut(c), f); + } + } +} + +/// Whether art color `c` is the Hue Shift key color `key`, with the tolerance the brush's +/// colorization matches it with. +fn matches_key(c: &Color, key: &Color) -> bool { + c.to_rgb().iter().zip(key.to_rgb()).map(|(a, b)| (a - b).abs()).sum::() < 0.02 +} + +/// `f` applied to a solid paint's color or a gradient's stop colors, given their swatch link. +fn map_paint_colors(p: &mut Paint, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { + match p { + Paint::Solid { color, swatch, tint } => *color = f(color, swatch.as_deref().map(|s| (s, *tint))), + Paint::Gradient(g) => { + for st in &mut g.gradient.stops { + st.color = f(&st.color, st.swatch.as_deref().map(|s| (s, st.tint))); + } + } + _ => {} + } } fn values(c: &Color) -> Vec { diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 9b008500a1..3da43957c8 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -198,8 +198,8 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}", - has_doc, + "{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again; refused while an opacity mask is edited → {id}", + super::maskedit::outside_mask_editing, edit_text ), ] diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index f1cd091076..05973c9f9b 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -234,7 +234,7 @@ pub fn specs() -> Vec { "Document Color Mode", ["File", "Document Color Mode"], None, - "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols, pattern tiles and swatches through the colour settings; swatch links kept; Gray colours stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB greys separate through the CMYK profile like any colour, into four-colour greys and a rich black) | \"black\" (to CMYK, RGB greys with R = G = B go on the black plate only, K = their grey value)} → {changed}", + "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks included), symbols, graph designs, pattern tiles, graphic styles, brushes and swatches through the color settings; swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", has_doc, super::colormgmt::convert_mode ), diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index dd6bbb7fe6..a05cc08248 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -13,13 +13,16 @@ use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster}; use vectorcraft_geom::{Point, Rect}; use super::super::flatten::{FlattenOptions, flatten_document}; -use super::super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, str_param}; +use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param}; use super::{ArtboardPick, Encoded, FormatOption}; const C: &str = "document.export"; /// Most pixels along a side of a preview. const MAX_PREVIEW: f64 = 2048.0; +/// Save needs a lightweight compatibility preview, not an uncompressed 16 MiB image beside +/// a small vector. TIFF format choices are unchanged; there is no public resolution option. +const SAVE_PREVIEW: f64 = 1024.0; /// Most pixels along a side of a thumbnail. const THUMBNAIL: f64 = 128.0; @@ -260,7 +263,20 @@ fn native(doc: &Document, board: Option, include_linked: bool) -> Vec /// artboard (after a central file of the whole document when there are several and art shows). /// Transparency is flattened first (once for all of them); the previews show `doc` itself. pub(super) fn encode(doc: &Document, p: &Value) -> Result { + encode_document(doc, p, false) +} + +/// Save keeps a single page's original frame and all native editing data, unlike Export's +/// optional art-bound crop or separate file per artboard. Existing EPS loss warnings still apply. +pub(super) fn encode_save(doc: &Document, p: &Value) -> Result { + encode_document(doc, p, true) +} + +fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let set = parse(p)?; + if saving && (set.use_artboards == Some(false) || bool_or(p, "selectedOnly", false)) { + return Err(bad(C, "EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS")); + } let original = doc; let (full, warnings) = crate::cmd::place::document::full_documents(doc); let doc = &*full; @@ -272,7 +288,13 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { } else { Cow::Borrowed(original) }; - let pages = pages(doc, &set)?; + let pages = if saving { + let board = set.boards.one(doc.artboards.len()).map_err(|e| bad(C, e))?; + let rect = doc.artboards.get(board).ok_or_else(|| bad(C, "the document has no artboard"))?.rect; + vec![(Some(board), rect, Point::new(rect.x0, rect.y1))] + } else { + pages(doc, &set)? + }; if pages.is_empty() { return Err(bad(C, "the document has no artboard")); } @@ -284,13 +306,14 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { if transparent && flat.is_some() { enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into()); } + let preview_limit = if saving { SAVE_PREVIEW } else { MAX_PREVIEW }; let created = vectorcraft_doc::metadata::output_time(); let mut renderer = vectorcraft_render::Renderer::new(); for (board, region, origin) in pages { let rect = vectorcraft_eps::preview_rect(region, origin); let preview = match set.eps.preview { Preview::None => None, - kind => Some(render(&mut renderer, doc, rect, MAX_PREVIEW, set.eps.transparent_preview && kind == Preview::TiffColor)?), + kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?), }; let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?; let o = EpsOptions { @@ -298,7 +321,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { origin, title: doc.title.clone(), created, - native: Some(native(&editing, board, set.include_linked)), + native: Some(native(&editing, if saving { None } else { board }, set.include_linked)), ..set.eps.clone() }; let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 59e7840731..ecb1c30993 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -430,8 +430,19 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs); - // What reading the file left out of the document, if anything. - let losses: Vec = warnings.iter().filter(|w| is_loss(w)).cloned().collect(); + // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing + // fallbacks must ask before overwriting the richer source they could not fully read. + let losses: Vec = warnings + .iter() + .filter(|w| { + is_loss(w) + || (format.id == "eps" + && (matches!(w.as_str(), EDITING_DAMAGED | EDITING_STALE) + || w.starts_with("this file's PostScript couldn't be read") + || w.starts_with("the file was read up to an error"))) + }) + .cloned() + .collect(); let source_path = path.clone().filter(|_| !losses.is_empty()); // A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled // document. diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 15f3072193..579ed51685 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -21,7 +21,7 @@ use crate::{DocState, Prefs}; /// The formats Save As offers, in menu order (append-only). The other writable formats are exports: /// they never become the document's own file. -pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]; +pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"]; /// The formats Save as Template writes, the default first: a native template, or a /// PDF-compatible `.ait` (written as a `.ai` file is). @@ -431,9 +431,9 @@ pub fn reads_option(f: &Format, key: &str) -> bool { } /// Formats that carry the whole native document: they lose nothing, and record links relative to -/// where they are written. A `.ai` or `.ait` file is a PDF carrying it. +/// where they are written. A `.ai` or `.ait` file is a PDF carrying it; EPS carries it in comments. fn is_native(f: &Format) -> bool { - matches!(f.id, "vectorcraft" | "template" | "ai" | "ait") + matches!(f.id, "vectorcraft" | "template" | "ai" | "ait" | "eps") } fn is_pdf(f: &Format) -> bool { @@ -520,7 +520,7 @@ impl std::io::Write for Counted { /// `separateArtboards`, those `range` names (default all). fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { // A legacy .ai file is a file per artboard already. - if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? { + if !is_native(f) || f.id == "eps" || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? { return Ok(vec![]); } let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string); @@ -737,8 +737,12 @@ impl SaveJob { /// Encode one file of `doc` in the job's format. fn encode_one(&self, doc: &Document) -> Result { let (cmd, f) = (self.plan.mode.command(), self.plan.format); - let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params) } else { encode_all(doc, f.id, &self.params) } - .map_err(|e| named_for_save(cmd, e))?; + let enc = match f.id { + "ai" | "ait" => encode_ai(cmd, f, doc, &self.params), + "eps" => super::eps::encode_save(doc, &self.params), + _ => encode_all(doc, f.id, &self.params), + } + .map_err(|e| named_for_save(cmd, e))?; // A legacy .ai file per artboard. let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?; if enc.files.len() != 1 && !per_artboard { @@ -952,7 +956,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), @@ -961,7 +965,7 @@ pub(super) fn specs() -> Vec { "Save As…", ["File"], Some("Cmd+Shift+S"), - "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", + "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}", has_doc, |s, p| save(s, SaveMode::SaveAs, p) ), diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 0651f0039a..e0909cb570 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -174,7 +174,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result> = chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect()); let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref()); - // SVG has no filters for the Photoshop-style effects: their objects go in as images. + // Render effects SVG cannot represent faithfully, including source-model Darkness shadows. let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc); let doc = flat.as_ref().unwrap_or(doc); let mut enc = Encoded { warnings, ..Encoded::default() }; diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 61a96bd5ec..3b9d793aa7 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -297,3 +297,95 @@ fn svgz_opens_and_pastes_like_svg() { assert_eq!(r["count"], 2); assert!(s.execute("document.open", &json!({"name": "bad.svgz", "dataBase64": vectorcraft_format::base64_encode(&[0x1f, 0x8b, 1, 2])})).is_err()); } + +#[test] +fn svg_darkness_preserves_source_model_and_gradient_appearance() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{ColorMode, Effect}; + use vectorcraft_geom::Rect; + for kind in ["rgb", "cmykSource", "gradient", "cmykDocument"] { + let mut s = session(1); + s.edit("paint source", |d, _| { + if kind == "cmykDocument" { + d.color_mode = ColorMode::Cmyk; + } + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node_mut(id).unwrap(); + n.appearance.fill_mut().unwrap().paint = match kind { + "cmykSource" => Paint::solid(Color::cmyk(0.15, 0.3, 0.4, 0.2)), + "gradient" => Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(0.8, 0.4, 0.2))], + )))), + _ => Paint::solid(Color::rgb(0.8, 0.4, 0.2)), + }; + n.appearance.items.retain(|i| i.is_fill()); + n.appearance.effects = vec![Effect { + id: "stylize.dropShadow".into(), + visible: true, + params: json!({"shade":"darkness","darkness":25,"x":60,"y":0,"blur":0,"opacity":100,"mode":"normal"}), + }]; + Ok(()) + }) + .unwrap(); + let native = s.doc().unwrap().doc.clone(); + let text = svg(&mut s, json!({})); + if kind == "rgb" { + assert!(text.contains("feComponentTransfer") && !text.contains(" Vec { "Edit Opacity Mask", ["Window", "Transparency"], None, - "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does → {layer}", - has_doc, + "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode → {layer}", + outside_isolation_mode, enter ), cmd!( @@ -45,13 +47,33 @@ pub fn specs() -> Vec { "View Opacity Mask", ["Window", "Transparency"], None, - "{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing) → {on, id}", - has_doc, + "{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode → {on, id}", + outside_isolation_mode, view_mask ), ] } +/// Is the document in isolation mode? Pattern editing mode and opacity mask editing isolate their +/// art too, and do not count as isolation mode here. +pub(crate) fn in_isolation_mode(st: &crate::DocState) -> bool { + st.isolation.is_some() && st.doc.pattern_edit.is_none() && st.doc.mask_edit.is_none() +} + +/// A document is open and it is not in isolation mode ([`in_isolation_mode`]): opacity mask +/// editing is not entered in isolation mode. +pub(crate) fn outside_isolation_mode(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if in_isolation_mode(st) { Err("exit isolation mode first".into()) } else { Ok(()) } +} + +/// A document is open and no opacity mask is being edited: isolation mode is not entered while +/// an opacity mask is edited. +pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.doc.mask_edit.is_some() { Err("stop editing the opacity mask first".into()) } else { Ok(()) } +} + /// Mask art made of `children`: a plain group, so the mask keeps one art node. Editing spreads it /// back out onto the editing layer. pub(crate) fn mask_group(children: Vec>) -> Node { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index e4e02128c6..bd8da087c3 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -13,7 +13,7 @@ use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2}; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; -use super::edit::{duplicate_in, selected_roots}; +use super::edit::{duplicate_in, selected_roots, selected_roots_to_transform}; use super::{ CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, fileio, find_command, has_doc, has_selection, has_selection_outside_isolation, switch_or, @@ -74,6 +74,15 @@ pub fn specs() -> Vec { has_selection_outside_isolation, |s, _| other_layers(s, false) ), + cmd!( + "object.hide.unselected", + "Unselected Artwork", + ["Object", "Hide"], + Some("Cmd+Alt+Shift+3"), + "{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; guide paths stay as they are while Lock Guides is on; object.showAll shows them again; disabled in isolation mode → {count}", + has_selection_outside_isolation, + hide_unselected + ), cmd!( "object.transformEach", "Transform Each…", @@ -312,6 +321,46 @@ fn other_layers(s: &mut Session, lock: bool) -> Result { Ok(json!({ "count": n })) } +/// The objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and hold none of +/// them. The walk goes into a plain group that holds one of `roots`; a clip group, a Shaper group, a compound path and +/// every other object that holds one is left out with its members. Guide paths are left out while `guides_locked`. +fn unselected(d: &vectorcraft_doc::Document, iso: Option, roots: &[NodeId], guides_locked: bool) -> Vec { + let holds: HashSet = d.index_paths(roots.iter().copied()).values().filter_map(|p| d.ancestry_at(p)).flatten().collect(); + let roots: HashSet = roots.iter().copied().collect(); + let mut out = vec![]; + super::select::walk_selectable(d, iso, |n| { + if !holds.contains(&n.id) { + if !(guides_locked && matches!(n.kind, NodeKind::Path { guide: true, .. })) { + out.push(n.id); + } + return false; + } + !roots.contains(&n.id) && matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none() + }); + out +} + +fn hide_unselected(s: &mut Session, _: &Value) -> Result { + let roots = selected_roots_to_transform(s)?; + let guides_locked = s.menu.guides_locked; + let st = s.doc()?; + let ids = unselected(&st.doc, st.isolation, &roots, guides_locked); + // With nothing to hide, the document and its undo history stay as they are. + if !ids.is_empty() { + s.edit("Hide", |d, _| { + // Hiding moves no node in the tree, so the paths found up front stay right. + let paths = d.index_paths(ids.iter().copied()); + for id in &ids { + if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p)) { + n.visible = false; + } + } + Ok(()) + })?; + } + Ok(json!({ "count": ids.len() })) +} + // ---------- Transform Each ---------- /// Small deterministic PRNG (xorshift64*) for Transform Each → Random. diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 873dc08ba1..5c494db55d 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -26,8 +26,8 @@ fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { } } -/// Param `key` of the Align commands, Shear and `text.setFormat`'s lists: one of `values`, or -/// `None` when it is missing or null. Any other value is an error that lists `values`. +/// Param `key` of the Align commands, Shear, Unlock All, Show All and `text.setFormat`'s lists: one +/// of `values`, or `None` when it is missing or null. Any other value is an error that lists `values`. pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; match v.as_str() { diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 48061fbe38..5649c957d1 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -1,3 +1,4 @@ +use super::align::choice; use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots}; pub(super) enum Arrange { @@ -172,8 +173,42 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { v } -pub(super) fn unlock_all(s: &mut Session, _: &Value) -> Result { - let ids = collect_flag(&s.doc()?.doc, |n| n.locked); +/// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole +/// document, or with `scope: "group"` inside the groups that hold the selected objects. A selected +/// group is the group that holds its own members, and the objects of a group inside another such +/// group are taken once. With `scope: "group"` and no selected object in a group, it returns an +/// error. +fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> bool) -> Result> { + let st = s.doc()?; + let d = &st.doc; + if choice(p, cmd, "scope", &["document", "group"])? != Some("group") { + return Ok(collect_flag(d, f)); + } + let is_group = |id: &NodeId| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. })); + let mut groups: Vec = st.selection.objects.iter().filter_map(|id| d.ancestry(*id)?.into_iter().rev().find(is_group)).collect(); + groups.sort(); + groups.dedup(); + if groups.is_empty() { + return Err(EngineError::Other("nothing selected is in a group".into())); + } + let mut v = vec![]; + for g in &groups { + if d.ancestry(*g).is_some_and(|a| a.iter().any(|up| up != g && groups.contains(up))) { + continue; + } + for c in d.node(*g).and_then(Node::children).into_iter().flatten() { + c.walk(&mut |n| { + if !n.is_layer() && f(n) { + v.push(n.id) + } + }); + } + } + Ok(v) +} + +pub(super) fn unlock_all(s: &mut Session, p: &Value) -> Result { + let ids = collect_flag_in_scope(s, "object.unlockAll", p, |n| n.locked)?; // With nothing locked, the document, the selection and the undo history stay as they are. if ids.is_empty() { return Ok(json!({ "count": 0 })); @@ -211,8 +246,8 @@ pub(super) fn hide(s: &mut Session, _: &Value) -> Result { ok() } -pub(super) fn show_all(s: &mut Session, _: &Value) -> Result { - let ids = collect_flag(&s.doc()?.doc, |n| !n.visible); +pub(super) fn show_all(s: &mut Session, p: &Value) -> Result { + let ids = collect_flag_in_scope(s, "object.showAll", p, |n| !n.visible)?; // With nothing hidden, the document, the selection and the undo history stay as they are. if ids.is_empty() { return Ok(json!({ "count": 0 })); diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index b01380411a..d5885ad3cf 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { "Transform", [], None, - "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)", + "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), releaseOutside?: bool} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too); releaseOutside (without copy or anchors): a Shaper Group's original shape that ends wholly outside the bounds the group's original shapes had before takes shaper.releaseSource in the same undo step, named Remove from Shaper Group, as the Selection tool's moves in Construction mode do", has_doc, transform ), @@ -154,7 +154,15 @@ pub fn specs() -> Vec { has_selection_outside_isolation, lock ), - cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", outside_isolation, unlock_all), + cmd!( + "object.unlockAll", + "Unlock All", + ["Object"], + Some("Cmd+Alt+2"), + "{scope?: \"document\"|\"group\" (default document; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them → {count}", + outside_isolation, + unlock_all + ), cmd!( "object.hide", "Selection", @@ -164,7 +172,15 @@ pub fn specs() -> Vec { has_selection_outside_isolation, hide ), - cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", outside_isolation, show_all), + cmd!( + "object.showAll", + "Show All", + ["Object"], + Some("Cmd+Alt+3"), + "{scope?: \"document\"|\"group\" (default document; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them → {count}", + outside_isolation, + show_all + ), cmd!( "object.compoundPath.make", "Make", @@ -224,8 +240,8 @@ pub fn specs() -> Vec { "Enter Isolation Mode", [], None, - "{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused", - has_doc, + "{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused, and so is every object while an opacity mask is edited", + crate::cmd::maskedit::outside_mask_editing, isolate ), cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 007ffae4f6..bed6e6c381 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -235,13 +235,40 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool { pub(super) fn transform(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; - check_switches(p, &["anchors"], "object.transform")?; + check_switches(p, &["anchors", "releaseOutside"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), }; - apply_transform(s, "Transform", ids, m, p, anchors) + // Shaper's Construction mode: an original shape moved wholly outside its group's bounds leaves + // the group. + let moved = if bool_or(p, "releaseOutside", false) && !bool_or(p, "copy", false) && !anchors { + crate::cmd::shaper::moving_sources(s, &ids)? + } else { + vec![] + }; + // Outside a drag and an undo group, the move and the removal are one undo step, named for the + // removal when there is one. + let grouped = !moved.is_empty() && s.doc()?.interaction.is_none() && s.doc()?.undo_group.is_none(); + if grouped { + s.begin_named_undo_group(None); + } + let r = apply_transform(s, "Transform", ids, m, p, anchors); + let released = match &r { + Ok(_) if !moved.is_empty() => crate::cmd::shaper::release_outside(s, &moved), + _ => Ok(false), + }; + if grouped && let Some(st) = s.active_mut() { + st.undo_group = None; + if matches!(released, Ok(true)) + && let Some(e) = st.history.undo.last_mut() + { + e.label = "Remove from Shaper Group".into(); + } + } + released?; + r } pub(super) fn move_cmd(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index bccf1f4f7f..37dadf5aac 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -24,8 +24,8 @@ pub fn specs() -> Vec { "Make Opacity Mask", ["Window", "Transparency"], None, - "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask → {id, editing}", - has_doc, + "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode → {id, editing}", + can_make, make ), cmd!( @@ -162,6 +162,22 @@ fn masked_targets(s: &Session, p: &Value) -> Result> { Ok(ids) } +/// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty +/// mask and takes into mask editing, is not in isolation mode +/// ([`super::maskedit::outside_isolation_mode`]). The selection is one object when it has one +/// subject root, which `make` masks without `ids`, and at most one outermost object, which it +/// masks with `ids` (a call's `ids` stand in for the selection here). `make` itself refuses any +/// single target in isolation mode, a layer included, so a selection with a target counts as one +/// object. +fn can_make(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let Some(st) = s.active() else { return Ok(()) }; + let roots = super::appearance::subject_roots(s).map_or(0, |r| r.len()); + let outermost = super::edit::outermost(&st.doc, st.selection.in_paint_order(&st.doc)).len(); + let targeted = st.selection.target.is_some(); + if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::outside_isolation_mode(s) } else { Ok(()) } +} + fn make(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.makeOpacityMask"; let d = &s.doc()?.doc; @@ -178,6 +194,10 @@ fn make(s: &mut Session, p: &Value) -> Result { None => super::appearance::subject_roots(s)?, }; let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) }; + // One target gets an empty mask and enters mask editing, which isolation mode does not allow. + if below.is_empty() && super::maskedit::in_isolation_mode(s.doc()?) { + return Err(bad(C, "exit isolation mode first")); + } let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped); let invert = bool_or(p, "invert", s.menu.new_masks_inverted); // Several objects: the top one is the mask. One: an empty mask, drawn in mask editing. diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index af57b06ec2..c285fd15e9 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -222,18 +222,27 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { let whole = s.doc()?.selection.whole(); let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() }; transform_anchors(s, "Move", vectorcraft_geom::Affine::translate(d), sc)?; + // Transform Again repeats a move that moved something; a drag records it when it commits. + let st = s.doc_mut()?; + if st.interaction.is_none() && (d.x != 0.0 || d.y != 0.0) { + let options = json!({ "copy": false, "patterns": sc.patterns }); + st.last_transform = Some(crate::LastTransform::Matrix(vectorcraft_geom::Affine::translate(d), options)); + st.last_perspective = None; + } ok() } /// Apply `xf` to the direct-selected anchors, their handles with them, and (with `sc`) to the -/// objects selected whole (those with no entry in the selection's `anchors`). +/// objects selected whole (those with no entry in the selection's `anchors`). An object inside +/// another selected object, such as a path in a selected group, moves once, with that object. pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_geom::Affine, sc: Scaling) -> Result<()> { let sel = s.doc()?.selection.clone(); + let ids = super::edit::outermost(&s.doc()?.doc, sel.objects.clone()); let c = xf.as_coeffs(); let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; let delta = vectorcraft_geom::Vec2::new(c[4], c[5]); s.edit(label, |doc, _| { - for id in &sel.objects { + for id in &ids { match sel.anchors.get(id) { Some(set) => { let path = path_mut(doc, *id)?; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 7589cab652..6ade1874e9 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -14,7 +14,10 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, has_multi, has_selection, str_param}; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_multi, has_selection, number_or, + str_param, switch_or, +}; pub fn specs() -> Vec { vec![ @@ -153,6 +156,15 @@ pub fn specs() -> Vec { has_selection, simplify ), + cmd!( + "object.path.smooth", + "Smooth…", + ["Object", "Path"], + None, + "{level?: 0..100 (50; the Smooth slider: 0 minimum smoothing, the path staying within 0.1% of its longer side, 100 maximum: refitted with curves within up to 2.5% of it, corners kept only where the path turns back, never with more anchor points), auto?: bool (Auto-Smooth: level 50), ids?: [id…] (default: the selection)} smooth each path under the objects, compound path members included, as one undo step → {ids, before, after} anchor counts, and the level used", + has_selection, + smooth + ), cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points), cmd!( "object.path.divideObjectsBelow", @@ -957,20 +969,20 @@ fn path_ids(d: &Document, roots: &[NodeId]) -> Vec { /// selected segments when they were picked as segments). `None` selects the path whole. type PartSelection = Option<(BTreeSet, Option>)>; -/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it, given its +/// Replace each path under `roots` ([`path_ids`]) by what `f` makes of it, given its /// direct-selected anchors and picked segments (none when it is selected whole): → (their ids, how /// many anchors they have before and after). A path that `f` would empty or returns unchanged is /// left as it is, with its live shape and its direct selection; a replaced path loses its live /// shape and has the direct selection `f` gives it. fn edit_paths( s: &mut Session, + roots: &[NodeId], label: &str, f: impl Fn(&PathData, Option<&BTreeSet>, Option<&BTreeSet>) -> (PathData, PartSelection), ) -> Result<(Vec, usize, usize)> { - let roots = selected_roots(s)?; let st = s.doc()?; let (d, sel) = (&st.doc, &st.selection); - let ids = path_ids(d, &roots); + let ids = path_ids(d, roots); if ids.is_empty() { return Err(EngineError::Other(format!("{label}: select paths"))); } @@ -1064,7 +1076,8 @@ fn simplify(s: &mut Session, p: &Value) -> Result { let whole = bool_or(p, "wholePaths", false); // A path with segments direct-selected simplifies only their runs, and keeps those runs (and the // other anchors it had selected) direct-selected. - let (ids, before, after) = edit_paths(s, "Simplify", |path, anchors, segments| { + let roots = selected_roots(s)?; + let (ids, before, after) = edit_paths(s, &roots, "Simplify", |path, anchors, segments| { let Some(picked) = selected_segments(path, anchors, segments).filter(|_| !whole) else { return (po::simplify_with(path, &opts), None); }; @@ -1084,8 +1097,20 @@ fn simplify(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// Object › Path › Smooth (`object.path.smooth`): [`po::smooth_level`] on each path under the +/// objects given or selected, as one undo step; Auto-Smooth (`auto`) uses [`po::SMOOTH_AUTO_LEVEL`]. +fn smooth(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.path.smooth"; + let level = number_or(p, "level", po::SMOOTH_AUTO_LEVEL, C)?.round().clamp(0.0, 100.0); + let level = if switch_or(p, "auto", false, C)? { po::SMOOTH_AUTO_LEVEL } else { level }; + let roots = checked_ids_or_selection(s, p, C)?; + let (ids, before, after) = edit_paths(s, &roots, "Smooth", |path, _, _| (po::smooth_level(path, level), None))?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after, "level": level })) +} + fn add_anchor_points(s: &mut Session, _: &Value) -> Result { - let (ids, before, after) = edit_paths(s, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?; + let roots = selected_roots(s)?; + let (ids, before, after) = edit_paths(s, &roots, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after })) } diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 0d673aac57..43ec3e40d7 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -11,8 +11,9 @@ //! images with the same helpers. //! //! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style -//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying -//! one of those into images the same way ([`flatten_pixel_effects`]). +//! effects (Radial Blur, Unsharp Mask…) or Rasterize. Darkness shadows also need rendered images +//! when source colors or gradients cannot be represented by an RGB component-transfer filter. +//! SVG export prepares these images with [`flatten_pixel_effects`]. use std::sync::Arc; @@ -452,9 +453,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { enum Which { /// Every raster effect (PDF). All, - /// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their - /// raster effects). - Pixel, + /// Effects SVG cannot represent faithfully, with their other raster effects. + Pixel { cmyk: bool }, } /// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image? @@ -462,10 +462,34 @@ fn imaged(n: &Node, which: Which) -> bool { let fx = raster_fx(n); match which { Which::All => !fx.is_empty(), - Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))), + Which::Pixel { cmyk } => { + fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) + || (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx))) + } } } +/// A filter on one uniform RGB paint can express the source-color adjustment exactly. +/// Other paints need the existing rendered-effect image to keep their source-model semantics. +fn simple_rgb_shadow_source(n: &Node, fx: &[RasterFx]) -> bool { + if !vectorcraft_color::cms::active().rgb_is_srgb() || !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || fx.len() != 1 { + return false; + } + // Other effects can generate varying source colors even from a single plain paint. + let only_shadow = std::iter::once(&n.appearance.effects) + .chain(n.appearance.items.iter().map(AppearanceItem::effects)) + .flatten() + .all(|e| !e.visible || e.id == "stylize.dropShadow"); + if !only_shadow || n.extras().mask.is_some() { + return false; + } + let mut paints = n.appearance.items.iter().filter(|i| effects::paints(i)); + let Some(item) = paints.next() else { + return false; + }; + paints.next().is_none() && matches!(item.paint(), Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, swatch: None, .. }) +} + /// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes', /// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers /// aren't drawn. @@ -566,7 +590,7 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { /// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its /// filters draw the other raster effects live), or `None` when there are none. pub fn flatten_pixel_effects(doc: &Document) -> Option { - flatten(doc, &mut FlattenReport::new(Which::Pixel, doc)) + flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc)) } fn flatten(doc: &Document, report: &mut FlattenReport) -> Option { diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 0ba4ebc0d7..49f59e7bfa 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Select Shaper Face", [], None, - "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original; while a group's originals are isolated, it selects one of that group's originals → {id}", + "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", has_doc, select ), @@ -53,6 +53,15 @@ pub fn specs() -> Vec { has_doc, expand ), + cmd!( + "shaper.releaseSource", + "Remove from Shaper Group", + [], + None, + "{source: id, group?: id (default: the Shaper Group that holds `source`)} take one original shape out of its Shaper Group: it becomes a path just above the group, keeps its look and is selected, and Construction mode ends; the group's result is computed again from the shapes left, and a group left with none is removed. In Construction mode, a Selection-tool move that ends with an original shape wholly outside the bounds the group's original shapes had does the same in the move's undo step (object.transform's `releaseOutside`) → {id}", + has_doc, + release_source + ), ] } @@ -678,7 +687,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(id) })?; - s.doc_mut()?.isolation = None; + end_isolation(s)?; Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes})) } @@ -730,13 +739,25 @@ fn select(s: &mut Session, p: &Value) -> Result { } else { group }; - let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten(); + // While an opacity mask or a pattern is edited, the source pick leaves its editing layer isolated. + let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten().filter(|_| editing_layer(&st.doc).is_none()); s.select(|_, sel| sel.set([target]))?; if let Some(id) = isolation { s.doc_mut()?.isolation = Some(id); } Ok(json!({"id": target.0})) } +/// The layer an opacity mask or a pattern is edited in, which stays isolated while that mode is on. +fn editing_layer(d: &Document) -> Option { + d.mask_edit.map(|m| m.layer).or_else(|| d.pattern_edit.as_ref().map(|e| e.layer)) +} +/// Leave isolation mode, Construction mode included. While an opacity mask or a pattern is edited, +/// its editing layer stays isolated. +fn end_isolation(s: &mut Session) -> Result<()> { + let st = s.doc_mut()?; + st.isolation = editing_layer(&st.doc); + Ok(()) +} fn target(s: &Session, p: &Value) -> Result { let id = id_param(p, "id") .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) @@ -757,7 +778,7 @@ fn release(s: &mut Session, p: &Value) -> Result { sel.set(ids.iter().copied()); Ok(ids) })?; - s.doc_mut()?.isolation = None; + end_isolation(s)?; Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) } fn expand(s: &mut Session, p: &Value) -> Result { @@ -774,6 +795,108 @@ fn expand(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(()) })?; - s.doc_mut()?.isolation = None; + end_isolation(s)?; Ok(json!({"id": id.0})) } + +/// The editable Shaper Group whose original shapes include `source`. +fn holder(d: &Document, source: NodeId) -> Option { + let sources = d.parent_of(source)?; + let group = d.parent_of(sources)?; + (d.node(group).and_then(source_group).is_some_and(|g| g.id == sources) && d.is_editable(source)).then_some(group) +} + +/// Take each of `shapes` (group, original shape) out of its Shaper Group and put it just above that +/// group; the shapes taken out are selected, alone when `only`, else along with what was +/// selected. [`refresh`] then computes each group's result again, or removes a group that holds no +/// shape any more. Construction mode ends when one of those groups was in it. +fn take_out(s: &mut Session, shapes: &[(NodeId, NodeId)], only: bool) -> Result<()> { + let sources: Vec = { + let d = &s.doc()?.doc; + shapes.iter().filter_map(|(group, _)| d.node(*group).and_then(source_group).map(|n| n.id)).collect() + }; + s.edit("Remove from Shaper Group", |d, sel| { + // The topmost shape goes first, so each one put just above its group goes below the ones + // that were above it. + let mut order = shapes.to_vec(); + order.sort_by_key(|(_, source)| std::cmp::Reverse(d.position(*source).map_or(0, |p| p.1))); + for (group, source) in order { + let node = d.remove(source)?; + let (parent, index, _) = d.position(group).ok_or(EngineError::NoNode(group))?; + d.insert(parent, index + 1, Arc::unwrap_or_clone(node))?; + } + if only { + sel.set(shapes.iter().map(|(_, source)| *source)); + } + for &(_, source) in shapes { + if !sel.contains(source) { + sel.objects.push(source); + } + } + Ok(()) + })?; + let st = s.doc_mut()?; + if st.isolation.is_some_and(|i| sources.contains(&i)) { + st.isolation = None; + } + Ok(()) +} + +fn release_source(s: &mut Session, p: &Value) -> Result { + const C: &str = "shaper.releaseSource"; + let source = id_param(p, "source").ok_or_else(|| bad(C, "source must be the id of a Shaper Group's original shape"))?; + let held = holder(&s.doc()?.doc, source); + let group = match p.get("group") { + None | Some(Value::Null) => held, + Some(_) => Some(id_param(p, "group").ok_or_else(|| bad(C, "group must be the id of a Shaper Group"))?), + }; + let group = group.filter(|g| held == Some(*g)).ok_or_else(|| bad(C, "the shape is not an editable original shape of that Shaper Group"))?; + take_out(s, &[(group, source)], true)?; + Ok(json!({"id": source.0})) +} + +/// What `object.transform`'s `releaseOutside` checks once the move is made: each of `ids` that is +/// an original shape of an editable Shaper Group, with that group and the bounds the group's +/// original shapes have before the move. +pub(crate) fn moving_sources(s: &Session, ids: &[NodeId]) -> Result> { + let d = &s.doc()?.doc; + Ok(ids + .iter() + .filter_map(|&id| { + let group = holder(d, id)?; + Some((group, id, d.node(group).and_then(source_group).and_then(Node::geometric_bounds)?)) + }) + .collect()) +} + +/// Do bounds `b` lie wholly outside `bounds`? Bounds that touch an edge, and flat bounds (a +/// horizontal or vertical line's) on the same line as flat `bounds`, are not outside. +pub(crate) fn wholly_outside(b: Rect, bounds: Rect) -> bool { + b.x0 > bounds.x1 || b.x1 < bounds.x0 || b.y0 > bounds.y1 || b.y1 < bounds.y0 +} + +/// Take out of their Shaper Groups the `moved` shapes ([`moving_sources`]) that now lie wholly +/// outside the bounds their group's original shapes had before the move. Inside a drag the step +/// is named for that. → whether any shape was taken out. +pub(crate) fn release_outside(s: &mut Session, moved: &[(NodeId, NodeId, Rect)]) -> Result { + let outside: Vec<(NodeId, NodeId)> = { + let d = &s.doc()?.doc; + moved + .iter() + .filter(|(_, id, before)| d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| wholly_outside(b, *before))) + .map(|(group, id, _)| (*group, *id)) + .collect() + }; + if outside.is_empty() { + return Ok(false); + } + // Shapes the group can't be computed from without the ones taken out keep it as it is: the + // edit is rolled back, and the move stays a plain move. + if take_out(s, &outside, false).is_err() { + return Ok(false); + } + if let Some(it) = &mut s.doc_mut()?.interaction { + it.label = "Remove from Shaper Group".into(); + } + Ok(true) +} diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 8de986583a..49edc2dd93 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; -use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors}; +use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; @@ -257,7 +257,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { for n in &names { d.remove_swatch(n); } - Ok(d.map_solid_paints(&mut unlink)) + Ok(map_swatch_links(d, &mut unlink)) })?; map_default_paints(s, &mut unlink); Ok(json!({"deleted": names, "unlinked": unlinked})) @@ -529,6 +529,130 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) { } } +/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of +/// the brushes in its library, which is stored again only when a brush's paint changed. A Hue Shift +/// key that was the color of a recolored paint takes the paint's new color. Returns the number of +/// changed paints. Stored entries are stored again as they were read, except that a brush whose +/// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does +/// not read are kept at every level. +pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { + use vectorcraft_brush::{Brush, BrushKind, Colorization}; + let mut n = d.map_solid_paints(f); + // Each stored entry, its brush when it parses, the brush as written before the edit (only + // when its paint changed), and whether its paint changed. + let mut lib: Vec<(Value, Option, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) { + Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(), + None => { + vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect() + } + }; + let mut in_brushes = 0; + for (_, b, base, changed) in lib.iter_mut() { + let Some(b) = b else { continue }; + let before_edit = serde_json::to_value(&*b).unwrap_or_default(); + let key = match b.kind.colorization() { + Colorization::HueShift { key } => Some(key), + _ => None, + }; + let before = key.map(|_| art_paint_colors(b.art())); + let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::(); + *changed = in_brush > 0; + if *changed { + *base = before_edit; + } + in_brushes += in_brush; + let (Some(key), Some(before)) = (key, before) else { continue }; + // Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them. + let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex(); + let after = art_paint_colors(b.art()); + // The key moves when the art's fills and strokes (not opacity masks) had the key color and + // none has it now, to the first new color of a paint that had it. + let moved = before.iter().any(|c| same(c, &key)) && !after.iter().any(|c| same(c, &key)); + let new_key = before.iter().zip(&after).find(|(o, _)| same(o, &key)).map(|(_, n)| *n); + if let Some(key) = new_key.filter(|_| moved) { + let c = match &mut b.kind { + BrushKind::Art(a) => &mut a.colorization, + BrushKind::Scatter(s) => &mut s.colorization, + BrushKind::Pattern(p) => &mut p.colorization, + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue, + }; + *c = Colorization::HueShift { key }; + } + } + if in_brushes > 0 { + let lib = lib + .into_iter() + .map(|(raw, b, base, changed)| match b.filter(|_| changed) { + Some(b) => merge_stored(raw, &base, serde_json::to_value(b).unwrap_or_default()), + None => raw, + }) + .collect(); + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib)); + n += in_brushes; + } + n +} + +/// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that +/// `base` (the definition as read from `raw` and written before the edit) does not hold. An object +/// keeps the fields of `raw` that `base` leaves out, and drops the fields that `base` holds and +/// `new` leaves out. An array takes the length of `new`, each element merged with the element at +/// the same index in `raw`. +fn merge_stored(raw: Value, base: &Value, new: Value) -> Value { + match (raw, new) { + (Value::Object(mut raw), Value::Object(new)) => { + let mut out = serde_json::Map::new(); + for (k, v) in new { + let v = match raw.remove(&k) { + Some(r) => merge_stored(r, base.get(k.as_str()).unwrap_or(&Value::Null), v), + None => v, + }; + out.insert(k, v); + } + out.extend(raw.into_iter().filter(|(k, _)| base.get(k.as_str()).is_none())); + Value::Object(out) + } + (Value::Array(raw), Value::Array(new)) => { + let mut raw = raw.into_iter(); + let merged = new.into_iter().enumerate().map(|(i, v)| match raw.next() { + Some(r) => merge_stored(r, base.get(i).unwrap_or(&Value::Null), v), + None => v, + }); + Value::Array(merged.collect()) + } + (_, new) => new, + } +} + +/// The solid colors of the fills and strokes under `nodes` (type runs' fills and strokes included, +/// opacity masks left out), in tree order and with repeats, the paints [`vectorcraft_brush::art_colors`] +/// counts. +fn art_paint_colors(nodes: Vec<&vectorcraft_doc::Node>) -> Vec { + fn walk(n: &vectorcraft_doc::Node, out: &mut Vec) { + for it in &n.appearance.items { + let (visible, paint) = match it { + vectorcraft_doc::AppearanceItem::Fill(f) => (f.visible, &f.paint), + vectorcraft_doc::AppearanceItem::Stroke(s) => (s.visible, &s.paint), + }; + out.extend(paint.color().filter(|_| visible)); + } + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + for r in &t.runs { + let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke); + out.extend([Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color)); + } + } + for c in n.children().into_iter().flatten() { + walk(c, out); + } + } + let mut out = vec![]; + for n in nodes { + walk(n, &mut out); + } + out +} + fn swatch_edit(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.edit"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); @@ -592,7 +716,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { } } } - Ok(d.map_solid_paints(&mut |c, l, t| relink.apply(c, l, t))) + Ok(map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t))) })?; relink.defaults(s); Ok(json!({"name": to, "relinked": relinked})) @@ -747,7 +871,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { for n in merged { d.remove_swatch(n); } - Ok(d.map_solid_paints(&mut relink)) + Ok(map_swatch_links(d, &mut relink)) })?; map_default_paints(s, &mut relink); Ok(json!({"name": keep, "merged": merged, "relinked": relinked})) @@ -803,7 +927,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result { .filter(|w| w.spot && matches!(w.paint, Paint::Solid { color: Color::Lab { .. }, swatch: None, .. })) .filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))) .collect(); - let relinked = d.map_solid_paints(&mut follow_bases(&bases)); + let relinked = map_swatch_links(d, &mut follow_bases(&bases)); Ok((relinked, bases)) })?; map_default_paints(s, &mut follow_bases(&bases)); @@ -880,7 +1004,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren let g = &mut d.swatch_groups[gi]; (g.name, g.swatches) = (name.clone(), swatches); let names = g.swatches.iter().map(|w| w.name.clone()).collect(); - let relinked = if relinks.is_empty() { 0 } else { d.map_solid_paints(&mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) }; + let relinked = if relinks.is_empty() { 0 } else { map_swatch_links(d, &mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) }; Ok(GroupEdit { name, swatches: names, relinks, relinked }) } diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index b6dbde7fc0..b4fa6a071d 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -17,7 +17,7 @@ use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatc use super::fileio::{create_dir, extension, read_file, write_file, write_new_file}; use super::menucmds::squash; use super::package::free_name; -use super::swatch::{map_default_paints, str_list, swatch_json}; +use super::swatch::{map_default_paints, map_swatch_links, str_list, swatch_json}; use super::{Color, CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; pub fn specs() -> Vec { @@ -644,7 +644,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result { (d.swatches, d.swatch_groups) = (defaults.into(), groups.into()); added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone())); live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone())); - d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l)); + map_swatch_links(d, &mut |_, l, _| unlink_dead(&live, l)); return Ok(added); } for (i, w) in defaults.into_iter().enumerate() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 102d76cb0e..2f982c9fcf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,7 +37,7 @@ pub use journal::Journal; pub use tooling::{UiRequest, ViewInfo}; pub use vectorcraft_doc as doc; pub use vectorcraft_doc::cancel; -pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, simplify_level_tolerance}; +pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, simplify_level_tolerance}; pub use vectorcraft_tools as tools; /// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 7ace120507..dfebc2ce9c 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -256,6 +256,11 @@ impl Session { // A preview that left the document as it was records no Transform Again either (as // `keep_interaction` records no undo step): the last real transformation stands. let changed = !Arc::ptr_eq(&it.doc, &st.doc); + // How far a drag of direct-selected anchors moved them (`path.moveAnchors`). + let moved = vectorcraft_geom::Vec2::new( + preview.1.get("dx").and_then(Value::as_f64).unwrap_or(0.0), + preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0), + ); st.keep_interaction(it); if preview.0 == "object.transform" { let m = cmd::matrix_param(&preview.1, "matrix"); @@ -270,6 +275,16 @@ impl Session { st.last_transform = Some(LastTransform::Matrix(m, options)); st.last_perspective = None; } + } else if preview.0 == "path.moveAnchors" && (moved.x != 0.0 || moved.y != 0.0) { + // A drag of direct-selected anchors (from an anchor, or from a fill with anchors + // selected): Transform Again moves the selected anchors and the objects selected whole + // by the same distance. A drag that ends where it began leaves the last transform. + let mut options = serde_json::json!({ "copy": false }); + if let Some(patterns) = preview.1.get("patterns") { + options["patterns"] = patterns.clone(); + } + st.last_transform = Some(LastTransform::Matrix(vectorcraft_geom::Affine::translate(moved), options)); + st.last_perspective = None; } else if preview.0 == "object.transformEach" && changed { // Transform Again repeats it as it stood when OK was pressed. st.last_transform = Some(LastTransform::Each(preview.1.clone())); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8b2fe6abae..7d3064c92c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -140,6 +140,7 @@ mod shortcutset; mod simplify_part; mod slices; mod smartguides; +mod smooth_path; mod store; mod strokegeom; mod strokegradient; @@ -1671,6 +1672,71 @@ fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() { } } +/// Unlock All and Show All with `scope: "group"` unlock or show only the objects inside the groups +/// that hold the selected objects: the group nearest above a selected object, or a selected group +/// itself. With no selected object in a group they are refused and change nothing. +#[test] +fn unlock_all_and_show_all_within_the_groups_of_the_selection() { + let mut s = session(); + let ids = |s: &mut Session, cmd: &str| NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap()); + let [a, b, m, k, h, s1, s2, j, j2] = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0].map(|i| rect(&mut s, i * 30.0, 0.0, 20.0, 20.0)); + // `g` holds `m`, `k`, `h` and the group `sub`, which holds `s1` and `s2`; `other` holds `j` and `j2`. + s.execute("select.set", &json!({"ids": [s1.0, s2.0]})).unwrap(); + let sub = ids(&mut s, "object.group"); + s.execute("select.set", &json!({"ids": [m.0, k.0, h.0, sub.0]})).unwrap(); + let g = ids(&mut s, "object.group"); + s.execute("select.set", &json!({"ids": [j.0, j2.0]})).unwrap(); + let other = ids(&mut s, "object.group"); + for id in [a, k, s1, j] { + s.execute("layer.setProps", &json!({"id": id.0, "locked": true})).unwrap(); + } + s.execute("layer.setProps", &json!({"id": h.0, "visible": false})).unwrap(); + let locked = |s: &Session| [a, k, s1, j].map(|id| s.doc().unwrap().doc.node(id).unwrap().locked); + let group = json!({"scope": "group"}); + + // A member of `g`, or `g` itself, selected: `k` and `s1` inside `sub` are unlocked and selected. + for selected in [m, g] { + s.execute("select.set", &json!({"ids": [selected.0]})).unwrap(); + let before = history(&s); + assert_eq!(s.execute("object.unlockAll", &group).unwrap(), json!({"count": 2}), "{selected:?}"); + assert_eq!(locked(&s), [true, false, false, true], "{selected:?}"); + assert_eq!(s.doc().unwrap().selection.objects, [k, s1], "{selected:?}"); + assert_eq!(history(&s).0, before.0 + 1, "{selected:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + // `s2` selected: only `sub` counts. With `m` selected too, `sub`'s objects are taken once. + for (selected, count) in [(vec![s2.0], 1), (vec![m.0, s2.0], 2)] { + s.execute("select.set", &json!({"ids": selected})).unwrap(); + assert_eq!(s.execute("object.unlockAll", &group).unwrap(), json!({"count": count}), "{selected:?}"); + assert!(!s.doc().unwrap().doc.node(s1).unwrap().locked, "{selected:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Show All within `g` shows `h` and selects it; `other` holds nothing hidden. + s.execute("select.set", &json!({"ids": [m.0]})).unwrap(); + assert_eq!(s.execute("object.showAll", &group).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().selection.objects, [h]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + assert_eq!(s.execute("object.showAll", &group).unwrap(), json!({"count": 0})); + + // `b`, outside every group, or nothing selected: refused, and nothing changes. + for selected in [vec![b.0], vec![]] { + s.execute("select.set", &json!({"ids": selected})).unwrap(); + let (doc, before) = (s.doc().unwrap().doc.clone(), history(&s)); + for cmd in ["object.unlockAll", "object.showAll"] { + let e = s.execute(cmd, &group).unwrap_err(); + assert!(matches!(&e, EngineError::Other(msg) if msg == "nothing selected is in a group"), "{cmd}: {e:?}"); + } + assert!(s.doc().unwrap().doc == doc && history(&s) == before, "{selected:?}"); + } + // Any other scope is refused; `scope: "document"` and no scope take the whole document. + let e = s.execute("object.unlockAll", &json!({"scope": "layer"})).unwrap_err(); + assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == "`scope` must be one of document, group"), "{e:?}"); + assert_eq!(s.execute("object.unlockAll", &json!({"scope": "document"})).unwrap(), json!({"count": 4})); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("object.showAll", &json!({})).unwrap(), json!({"count": 1})); +} + /// Set Bounds with the current position and size records no undo step on a rotated object, with /// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these /// cases computing the transform can leave a rounding error in it. diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index f2bc3f279f..97f3560787 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -1,10 +1,11 @@ //! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool: -//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18). +//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and +//! Escape dropping the first pick. use serde_json::{Value, json}; use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use crate::*; @@ -140,6 +141,32 @@ fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() { assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions")); } +#[test] +fn escape_drops_the_blend_tools_first_pick() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + let esc = |s: &mut Session| s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap(); + click(&mut s, 20.0, 20.0); + assert!(s.tool_busy()); + let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len()); + assert!(esc(&mut s).is_empty()); + assert!(!s.tool_busy(), "Escape drops the pick"); + assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "and changes nothing"); + // The next two clicks blend b and c; a is not part of it. + click(&mut s, 220.0, 20.0); + assert_eq!(s.doc().unwrap().doc.layers, layers, "b is picked first"); + click(&mut s, 420.0, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, vec![b, c]); + // Escape after the blend is made: the next click starts a new blend instead of adding to it. + esc(&mut s); + click(&mut s, 20.0, 20.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(spec(&s, g).0, vec![b, c], "a was not added"); +} + #[test] fn start_points_round_trip_and_old_blends_load() { let mut s = session(); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index de7862a13c..d811dc87e6 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -297,6 +297,85 @@ fn shape_builder_fill_option_and_untouched_shapes_kept() { assert_eq!(outline(&node(&s, sq)).0.anchor_count(), 4); } +fn parent(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.parent_of(id) +} + +/// The merge replaces only the shapes it uses: a selected type object stays as it was. +#[test] +fn shape_builder_merge_keeps_a_selected_type_object() { + let mut s = session(); + let (a, b) = two(&mut s); + let t = NodeId(s.execute("text.create", &json!({"x": 300, "y": 300, "text": "Label"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0, t.0]})).unwrap(); + let before = node(&s, t); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [125, 50]]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(t), Some(&before)); + assert_eq!(layer_children(&s), 2, "the merged shape and the type"); + assert!(ids(&r).contains(&t), "{r}"); +} + +/// A hidden member of a selected group stays hidden in its group, with the merged shape. +#[test] +fn shape_builder_merge_keeps_a_hidden_group_member() { + let mut s = session(); + let (a, b) = two(&mut s); + let c = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.hide", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [125, 50]]})).unwrap(); + let merged = NodeId(r["merged"].as_u64().unwrap()); + assert_eq!((parent(&s, c), node(&s, c).visible, parent(&s, merged)), (Some(g), false, Some(g))); + assert_eq!(node(&s, g).children().unwrap().len(), 2); +} + +/// A locked member of a selected group is neither cut nor moved, and does not cut the others. +#[test] +fn shape_builder_merge_leaves_a_locked_group_member() { + let mut s = session(); + let (_, b) = two(&mut s); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.lock", &json!({"ids": [b.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let before = node(&s, b); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(b), Some(&before)); + assert_eq!(parent(&s, b), Some(g)); + let merged = NodeId(r["merged"].as_u64().unwrap()); + assert!((area(&s, merged) - 10000.0).abs() < 1.0, "A whole: {}", area(&s, merged)); +} + +/// In a clip group, the clipping path stays and the merged shape stays inside the group. +#[test] +fn shape_builder_merge_keeps_a_clip_groups_clipping_path() { + let mut s = session(); + let (a, b) = two(&mut s); + let m = rect(&mut s, 0.0, 0.0, 120.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, m.0]})).unwrap(); + let g = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("shapeBuilder.merge", &json!({"ids": [g.0], "points": [[25, 50], [125, 50]]})).unwrap(); + let merged = NodeId(r["merged"].as_u64().unwrap()); + let gn = node(&s, g); + let NodeKind::Group { children, clip: true } = &gn.kind else { panic!("a clip group: {:?}", gn.kind) }; + assert_eq!(children.first().map(|c| c.id), Some(m), "the clipping path"); + assert_eq!(parent(&s, merged), Some(g)); +} + +/// A group goes when deleting regions leaves it empty, and stays while anything is left in it. +#[test] +fn shape_builder_erase_removes_only_a_group_it_empties() { + let mut s = session(); + two(&mut s); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]], "erase": true})).unwrap(); + assert_eq!((ids(&r), node(&s, g).children().unwrap().len()), (vec![g], 2)); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[60, 50], [140, 50]], "erase": true})).unwrap(); + assert!(ids(&r).is_empty() && s.doc().unwrap().doc.node(g).is_none(), "{r}"); + assert_eq!(layer_children(&s), 0); +} + #[test] fn shape_builder_miss_is_an_error_and_undo_restores() { let mut s = session(); @@ -2615,12 +2694,45 @@ fn live_paint_bucket_tool_fills_face() { let gn = node(&s, g); let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(125.0, 50.0)).unwrap(); assert_eq!(f.appearance.fill_paint(), s.paint.fill); - // Live Paint Selection selects the face. + // A Live Paint Selection click selects the face. s.select_tool("livePaintSelection", v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); } +#[test] +fn live_paint_selection_marquee_selects_what_it_touches() { + let mut s = session(); + let g = live(&mut s); + let v = ViewInfo::default(); + s.select_tool("livePaintSelection", v).unwrap(); + let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64), m: Mods, escape: bool| { + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(m), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap(); + if escape { + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap(); + s.doc().unwrap().selection.objects.clone() + }; + let gn = node(&s, g); + let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id; + let edge = |x: f64| vectorcraft_tools::builder::edge_near(&gn, Point::new(x, 50.0), 0.5).unwrap().id; + let shift = Mods { shift: true, ..Default::default() }; + // Across the left side: the left face and that side. + assert_eq!(drag(&mut s, (-10.0, 40.0), (40.0, 60.0), Mods::default(), false), vec![face(25.0), edge(0.0)]); + // Shift-drag across the right side adds the right face and that side. + assert_eq!(drag(&mut s, (160.0, 40.0), (140.0, 60.0), shift, false), vec![face(25.0), edge(0.0), face(125.0), edge(150.0)]); + // Shift-drag across the left side again takes those out. + assert_eq!(drag(&mut s, (-10.0, 40.0), (40.0, 60.0), shift, false), vec![face(125.0), edge(150.0)]); + // Escape during the drag, with or without Shift, from a press on a face or outside the group: + // the selection stays as it was. + for (from, m) in [((-10.0, 40.0), shift), ((25.0, 50.0), Mods::default()), ((25.0, 50.0), shift)] { + assert_eq!(drag(&mut s, from, (40.0, 60.0), m, true), vec![face(125.0), edge(150.0)], "{from:?} {m:?}"); + } +} + fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId { let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap(); NodeId(r["id"].as_u64().unwrap()) @@ -2763,11 +2875,77 @@ fn shape_builder_keeps_the_edges_of_an_unfilled_live_paint_group() { let c = rect(&mut s, 160.0, 20.0, 100.0, 20.0); set_fill(&mut s, c, Color::rgb(0.0, 1.0, 0.0)); s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap(); + let before = node(&s, g); let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap(); - let out = ids(&r); + // The merge does not use the group, so it stays as it was, with every edge: the outline of + // 0..150 × 0..100 and the edges at x = 50 and 100. + assert_eq!(s.doc().unwrap().doc.node(g), Some(&before)); + assert!(ids(&r).contains(&g), "{r}"); + let edges: Vec = vectorcraft_tools::builder::edges(&before).iter().map(|e| e.id).collect(); + assert!((stroked_length(&s, &edges) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &edges)); +} + +/// A locked face of a Live Paint group the merge replaces is not a region, and a copy of it stays, +/// still locked. +#[test] +fn shape_builder_merge_keeps_a_locked_live_paint_face() { + let mut s = session(); + let g = live(&mut s); + let locked = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(125.0, 50.0)).unwrap().id; + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let map = s.execute("shapeBuilder.regions", &json!({})).unwrap(); + assert!(map["regions"].as_array().unwrap().iter().all(|r| !r["sources"].as_array().unwrap().iter().any(|i| *i == locked.0)), "{map}"); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + assert_eq!(r["regions"], 2, "{r}"); assert!(s.doc().unwrap().doc.node(g).is_none()); - // Every edge of the group: the outline of 0..150 × 0..100 and the edges at x = 50 and 100. - assert!((stroked_length(&s, &out) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &out)); + let kept: Vec = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| n.locked).collect(); + assert_eq!(kept.len(), 1, "{r}"); + assert!((area(&s, kept[0].id) - 5000.0).abs() < 1.0, "{}", area(&s, kept[0].id)); +} + +/// A hidden face of a Live Paint group the merge replaces is not a region, and a copy of it stays, +/// still hidden. +#[test] +fn shape_builder_merge_keeps_a_hidden_live_paint_face() { + let mut s = session(); + let g = live(&mut s); + let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(125.0, 50.0)).unwrap().id; + s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + assert_eq!(r["regions"], 2, "{r}"); + assert!(s.doc().unwrap().doc.node(g).is_none()); + let kept: Vec = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| !n.visible).collect(); + assert_eq!(kept.len(), 1, "{r}"); + assert!((area(&s, kept[0].id) - 5000.0).abs() < 1.0, "{}", area(&s, kept[0].id)); +} + +/// A locked face without a fill and a locked edge without a stroke, in a Live Paint group the +/// merge replaces, each keep a copy, still locked. +#[test] +fn shape_builder_merge_keeps_unpainted_locked_live_paint_members() { + let mut s = session(); + let g = live(&mut s); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [125, 50], "none": true})).unwrap(); + let n = node(&s, g); + let face = vectorcraft_tools::builder::face_at(&n, Point::new(125.0, 50.0)).unwrap().id; + let edge = vectorcraft_tools::builder::visible_edge_near(&n, Point::new(150.0, 50.0), 4.0).unwrap().id; + let names = (node(&s, face).name, node(&s, edge).name); + assert_ne!(names.0, names.1); + s.edit("t", |d, _| { + d.node_mut(edge).unwrap().appearance.set_stroke(Paint::None); + Ok(()) + }) + .unwrap(); + s.execute("object.lock", &json!({"ids": [face.0, edge.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).is_none()); + let kept: Vec = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| n.locked).collect(); + let faces = kept.iter().filter(|n| n.name == names.0 && n.appearance.fill_paint().is_none() && (area(&s, n.id) - 5000.0).abs() < 1.0).count(); + let edges = kept.iter().filter(|n| n.name == names.1 && n.appearance.stroke_paint().is_none()).count(); + assert_eq!((kept.len(), faces, edges), (2, 1, 1), "{r}"); } /// A plain group whose name starts with "Live Paint" is not a Live Paint group: the Shape Builder @@ -2791,7 +2969,9 @@ fn shape_builder_takes_the_paths_of_a_plain_group_named_live_paint() { assert_eq!(map["regions"].as_array().unwrap().len(), 2, "{map}"); let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap(); let out = ids(&r); - assert!(out.iter().any(|i| (area(&s, *i) - 10000.0).abs() < 1.0), "the group's rectangle is kept: {r}"); + // The merge keeps the unused group whole, so its rectangle is inside an id of the reply. + let kept_area = |n: &Node| n.children().map_or_else(|| area(&s, n.id), |c| c.iter().map(|c| area(&s, c.id)).sum()); + assert!(out.iter().any(|i| (kept_area(&node(&s, *i)) - 10000.0).abs() < 1.0), "the group's rectangle is kept: {r}"); // A stroke of the group's rectangle splits as a plain path's does. s.execute("edit.undo", &json!({})).unwrap(); s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index c6727d8660..1ad528544b 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -154,6 +154,115 @@ fn images_bend_as_a_raster_mesh_warp() { assert_eq!(svg.matches(" NodeId { + let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("two".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((100.0, 100.0)) * Affine::scale(40.0); + let im = vectorcraft_doc::ImageObject { key: "two".into(), width: 2, height: 2, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +/// The pixels well inside the image's frame (the clip edges left out) that are a mix of its red +/// and blue rather than one of them. +fn mixed_pixels(s: &Session) -> usize { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(110.0, 110.0, 170.0, 170.0), 2.0, true); + let mut mixed = 0; + for y in 0..img.height { + for x in 0..img.width { + let p = img.pixel(x, y); + mixed += (p[0] > 40 && p[2] > 40) as usize; + } + } + mixed +} + +#[test] +fn anti_alias_off_draws_distorted_images_without_smoothing() { + let mut s = session(); + let im = two_color_image(&mut s); + sel(&mut s, &[im]); + let e = arch(&mut s, 2.0, json!({})); + let smooth = mixed_pixels(&s); + assert!(smooth > 400, "with Anti-Alias on the colors blend where the pixels meet: {smooth}"); + s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap(); + let mut pieces = 0; + for n in evaluated(&s, e) { + n.walk(&mut |c| pieces += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize); + } + assert!(pieces >= 32, "{pieces} pixelated pieces"); + let hard = mixed_pixels(&s); + assert!(hard * 50 < smooth, "with Anti-Alias off each pixel is red or blue: {hard} mixed of {smooth}"); + // The SVG asks viewers not to smooth the pieces either. + let svg = |s: &mut Session| { + let b64 = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + String::from_utf8(vectorcraft_format::base64_decode(&b64).unwrap()).unwrap() + }; + assert!(svg(&mut s).matches("image-rendering").count() >= 32); + s.execute("object.envelope.options", &json!({"antiAlias": true})).unwrap(); + assert!(!svg(&mut s).contains("image-rendering")); + s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap(); + // Expanded, the pieces keep it through a native save. + s.execute("object.envelope.expand", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + let mut kept = 0; + back.walk(|c| kept += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize); + assert_eq!(kept, pieces); +} + +fn clip_groups_in(nodes: &[Node]) -> usize { + let mut n = 0; + for c in nodes { + c.walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize); + } + n +} + +#[test] +fn transparency_masks_distorted_images_instead_of_clipping_them() { + let mut s = session(); + let im = red_image(&mut s); + sel(&mut s, &[im]); + let e = arch(&mut s, 80.0, json!({})); + let region = Rect::new(80.0, 60.0, 160.0, 160.0); + let clipped = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true); + assert!(clip_groups_in(&evaluated(&s, e)) >= 32); + s.execute("object.envelope.options", &json!({"preserveShape": "transparency"})).unwrap(); + let out = evaluated(&s, e); + assert_eq!(clip_groups_in(&out), 0, "no clipping masks"); + let (mut images, mut masked) = (0, 0); + for n in &out { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Image(_)) { + images += 1; + masked += c.extras().mask.as_ref().is_some_and(|m| m.clip && !m.invert) as usize; + } + }); + } + assert!(images >= 32 && masked == images, "{masked} of {images} pieces masked"); + // It draws as the clipping masks did. + let alpha = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true); + let (mut sum, mut n) = (0u64, 0u64); + for y in 0..clipped.height { + for x in 0..clipped.width { + let (a, b) = (clipped.pixel(x, y), alpha.pixel(x, y)); + sum += (0..4).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs() as u64).sum::(); + n += 4; + } + } + assert!(sum < n, "mean difference {} per channel", sum as f64 / n as f64); + assert!(alpha.pixel(80, 74)[0] > 200 && alpha.pixel(80, 74)[1] < 60, "the bent image shows: {:?}", alpha.pixel(80, 74)); +} + #[test] fn symbols_bend_with_the_envelope() { let mut s = session(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 5c227c8aa2..2dd6658e2a 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -492,6 +492,112 @@ fn lock_and_hide_with_nothing_to_lock_or_hide_record_no_undo_step() { } } +/// Hide › Unselected Artwork hides, in one undo step, each object that is not selected and holds no selected object: +/// here `b`, the member `n` of the group that holds the selected `m`, and `p` on another layer. The selected objects, +/// that group, the clip group that holds the selected `c2` with all its members, a locked object, the art on a locked +/// layer and an object hidden before stay as they are, and so does the selection. Show All shows them again. +#[test] +fn hide_unselected_artwork_hides_every_object_that_is_not_selected() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + sel(&mut s, &[m, n]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let c1 = rect(&mut s, 80.0, 0.0, 10.0, 10.0); + let c2 = rect(&mut s, 85.0, 5.0, 10.0, 10.0); + let c3 = rect(&mut s, 80.0, 0.0, 30.0, 30.0); + sel(&mut s, &[c1, c2, c3]); + let clip = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let k = rect(&mut s, 120.0, 0.0, 10.0, 10.0); + s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap(); + let h = rect(&mut s, 140.0, 0.0, 10.0, 10.0); + s.execute("layer.setProps", &json!({"id": h.0, "visible": false})).unwrap(); + s.execute("layer.new", &json!({})).unwrap(); + let p = rect(&mut s, 0.0, 100.0, 10.0, 10.0); + let locked_layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let q = rect(&mut s, 20.0, 100.0, 10.0, 10.0); + s.execute("layer.setProps", &json!({"id": locked_layer.0, "locked": true})).unwrap(); + let shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::>(); + sel(&mut s, &[a, m, c2]); + let steps = undo_len(&s); + assert_eq!(s.execute("object.hide.unselected", &json!({})).unwrap(), json!({"count": 3})); + assert_eq!(shown(&s, &[b, n, p, h]), [false; 4]); + assert_eq!(shown(&s, &[a, m, g, c1, c2, c3, clip, k, q]), [true; 9]); + assert!(node(&s, k).locked); + assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(shown(&s, &[b, n, p]), [true; 3]); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("object.showAll", &json!({})).unwrap(); + assert_eq!(shown(&s, &[b, n, p, h]), [true; 4]); +} + +/// Hide › Unselected Artwork leaves the art on a locked artboard as it is, as Select All leaves it unselected. +#[test] +fn hide_unselected_artwork_leaves_the_art_on_a_locked_artboard() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 0.0, 10.0, 10.0)); + let on_locked = rect(&mut s, 2050.0, 50.0, 10.0, 10.0); + s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 400, "height": 400})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + sel(&mut s, &[a]); + assert_eq!(s.execute("object.hide.unselected", &json!({})).unwrap(), json!({"count": 1})); + assert_eq!([a, b, on_locked].map(|id| node(&s, id).visible), [true, false, true]); +} + +/// In isolation mode Hide › Unselected Artwork is disabled: the menu item is off, and the command returns an error that +/// names isolation mode and changes nothing. While Lock Guides is on it leaves the guides as they are. With nothing +/// left to hide it records no undo step and returns a count of 0. A compound path that holds a selected path stays as +/// it is. +#[test] +fn hide_unselected_artwork_keeps_locked_guides_and_is_disabled_in_isolation_mode() { + let mut s = session(); + let guide = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[guide]); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + let out = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + sel(&mut s, &[m, n]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let hide = |s: &mut Session| s.execute("object.hide.unselected", &json!({})).unwrap(); + let shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::>(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + select_with_a_step_to_redo(&mut s, &[m]); + let before = history(&s); + let info = find_command("object.hide.unselected").unwrap().info(&s); + assert!(!info.enabled && info.disabled_reason.as_deref() == Some("exit isolation mode first"), "{info:?}"); + let e = s.execute("object.hide.unselected", &json!({})).unwrap_err(); + assert!(matches!(e, EngineError::Disabled(..)) && e.to_string().contains("isolation mode"), "{e}"); + assert_eq!(shown(&s, &[m, n, out, guide]), [true; 4]); + assert_eq!((history(&s), selected(&s)), (before, vec![m])); + s.execute("object.exitIsolation", &json!({})).unwrap(); + sel(&mut s, &[m]); + assert!(find_command("object.hide.unselected").unwrap().info(&s).enabled); + assert_eq!(hide(&mut s), json!({"count": 2})); + assert_eq!(shown(&s, &[n, out, guide]), [false, false, true]); + select_with_a_step_to_redo(&mut s, &[m]); + let before = history(&s); + assert_eq!(hide(&mut s), json!({"count": 0})); + assert_eq!(history(&s), before); + // With Lock Guides off, the guide path is hidden like other art. + s.execute("view.guides.lock", &json!({"locked": false})).unwrap(); + sel(&mut s, &[m]); + assert_eq!(hide(&mut s), json!({"count": 1})); + assert_eq!(shown(&s, &[guide]), [false]); + // A compound path that holds the selected path stays as it is with its paths. + let outer = rect(&mut s, 0.0, 200.0, 100.0, 100.0); + let hole = rect(&mut s, 40.0, 240.0, 20.0, 20.0); + sel(&mut s, &[outer, hole]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + sel(&mut s, &[hole]); + assert_eq!(hide(&mut s), json!({"count": 1})); + assert_eq!(shown(&s, &[g, c, outer, hole]), [false, true, true, true]); +} + // ---------- Transform Each ---------- #[test] diff --git a/crates/engine/src/tests/opacitymask.rs b/crates/engine/src/tests/opacitymask.rs index 5f69f66071..74d6770e36 100644 --- a/crates/engine/src/tests/opacitymask.rs +++ b/crates/engine/src/tests/opacitymask.rs @@ -1,6 +1,7 @@ //! Opacity-mask editing and the Transparency panel's command reach (M3.25): commands act on the //! masked object while its mask is edited, accept ids without a selection, take opacity in percent, -//! report mixed values, and saves and exports never carry the editing layer. +//! report mixed values, and saves and exports never carry the editing layer. Mask editing is not +//! entered in isolation mode, and isolation mode is not entered while a mask is edited. use serde_json::{Value, json}; use vectorcraft_geom::Affine; @@ -228,6 +229,103 @@ fn one_object_gets_an_empty_mask_drawn_in_editing_mode() { assert_eq!((s.doc().unwrap().isolation, s.doc().unwrap().active_layer), (Some(layer), Some(layer))); } +/// The command `cmd` with `p` is off, for `why`. +fn off(s: &mut Session, cmd: &str, p: Value, why: &str) { + let err = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}"); +} + +/// Isolation mode is not entered while an opacity mask is edited: Enter Isolation Mode is off for +/// a group in the mask art and for one outside it, and mask editing goes on. After Stop Editing +/// Opacity Mask it isolates again. +#[test] +fn isolation_mode_is_not_entered_while_an_opacity_mask_is_edited() { + let mut s = session(); + let group = |s: &mut Session, x: f64| { + let (a, b) = (rect(s, x, 20.0), rect(s, x + 30.0, 20.0)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()) + }; + let outside = group(&mut s, 220.0); + editing_mask(&mut s); + let layer = s.doc().unwrap().isolation; + let inside = group(&mut s, 0.0); + for id in [inside, outside] { + off(&mut s, "object.isolate", json!({"id": id.0}), "stop editing the opacity mask first"); + assert_eq!(s.doc().unwrap().isolation, layer); + assert!(doc(&s).mask_edit.is_some(), "still editing"); + } + s.execute("select.set", &json!({"ids": [inside.0]})).unwrap(); + off(&mut s, "object.isolate", json!({}), "stop editing the opacity mask first"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": outside.0})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(outside)); +} + +/// Opacity mask editing is not entered in isolation mode: Edit Opacity Mask and View Opacity Mask +/// are off, and so is Make Opacity Mask for one object, which would edit its new empty mask. Make +/// Opacity Mask with two objects still masks one with the other. After Exit Isolation Mode the mask +/// is edited again. +#[test] +fn opacity_mask_editing_is_not_entered_in_isolation_mode() { + let mut s = session(); + let (obj, m) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0)); + s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap(); + let other = rect(&mut s, 220.0, 30.0); + s.execute("select.set", &json!({"ids": [obj.0, other.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.set", &json!({"ids": [obj.0]})).unwrap(); + let why = "exit isolation mode first"; + off(&mut s, "transparency.editOpacityMask", json!({}), why); + off(&mut s, "transparency.editOpacityMask", json!({"id": obj.0}), why); + off(&mut s, "transparency.viewOpacityMask", json!({}), why); + off(&mut s, "transparency.viewOpacityMask", json!({"on": true, "id": obj.0}), why); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + off(&mut s, "transparency.makeOpacityMask", json!({}), why); + off(&mut s, "transparency.makeOpacityMask", json!({"ids": [other.0]}), why); + // A layer given as the one target is refused too. + let layer = doc(&s).layers[0].id; + let err = s.execute("transparency.makeOpacityMask", &json!({"ids": [layer.0]})).unwrap_err(); + assert!(err.to_string().ends_with(why), "{err}"); + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.mask_view.is_none() && mask(&s, other).is_none()); + assert_eq!(s.doc().unwrap().isolation, Some(g)); + // Two objects: the top one masks the other, without mask editing. + let top = rect(&mut s, 220.0, 10.0); + let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [other.0, top.0]})).unwrap(); + assert_eq!(r, json!({"id": other.0, "editing": false})); + assert_eq!(s.doc().unwrap().isolation, Some(g)); + // Two members of a compound path given in `ids` are two objects: one masks the other. + let (c1, c2) = (rect(&mut s, 250.0, 10.0), rect(&mut s, 265.0, 10.0)); + s.execute("select.set", &json!({"ids": [c1.0, c2.0]})).unwrap(); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [c1.0, c2.0]})).unwrap(); + assert_eq!(r["editing"], json!(false)); + assert!(doc(&s).mask_edit.is_none()); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap(); + assert_eq!(doc(&s).mask_edit.map(|e| e.object), Some(obj)); +} + +/// In isolation mode, Make Opacity Mask is off for a targeted sublayer that holds two objects: +/// the sublayer is its one target, which would get an empty mask and enter mask editing. +#[test] +fn make_opacity_mask_is_off_for_a_targeted_sublayer_in_isolation_mode() { + let mut s = session(); + let layer = doc(&s).layers[0].id; + let sub = NodeId(s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()["id"].as_u64().unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let (a, b) = (rect(&mut s, 0.0, 20.0), rect(&mut s, 40.0, 20.0)); + s.execute("object.isolate", &json!({"id": layer.0})).unwrap(); + s.execute("layer.target", &json!({"id": sub.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Some(sub)); + off(&mut s, "transparency.makeOpacityMask", json!({}), "exit isolation mode first"); + // Two objects given in `ids` still mask one with the other. + let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(r["editing"], json!(false)); +} + #[test] fn mask_and_transparency_commands_take_ids_without_a_selection() { let mut s = session(); diff --git a/crates/engine/src/tests/persp_text.rs b/crates/engine/src/tests/persp_text.rs index 91d7b70eb0..0a32e81387 100644 --- a/crates/engine/src/tests/persp_text.rs +++ b/crates/engine/src/tests/persp_text.rs @@ -141,6 +141,23 @@ fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() { assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err()); } +/// Edit Text, which shows the type in isolation mode, is off while an opacity mask is edited. +#[test] +fn edit_text_is_off_while_an_opacity_mask_is_edited() { + let (mut s, rect) = persp_session(); + let id = persp_text(&mut s); + s.execute("transparency.makeOpacityMask", &json!({"ids": [rect.0]})).unwrap(); + let layer = s.doc().unwrap().isolation; + assert!(layer.is_some(), "editing the new empty mask"); + let err = s.execute("perspective.editText", &json!({"id": id.0})).unwrap_err(); + assert!(matches!(&err, EngineError::Disabled(_, why) if why == "stop editing the opacity mask first"), "{err}"); + assert_eq!(s.doc().unwrap().isolation, layer); + assert!(node(&s, id).projection().is_some(), "still projected"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + s.execute("perspective.editText", &json!({"id": id.0})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(id)); +} + /// Edit Text ending while a background save writes the file leaves the document saved once the /// file is written: the flat view is never saved. Saved while editing and edited after: undoing /// the edit once Edit Text has ended is back at what the file holds. diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index c1b5c73ce2..d165ce79e1 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -998,6 +998,114 @@ fn whole_objects_moved_with_selected_anchors_follow_transform_pattern_tiles() { } } +/// A Direct Selection or Group Selection drag that starts on the fill of a path selected whole, +/// while another path has an anchor selected, moves the path whole and only that anchor of the +/// other path, in one undo step, as a drag of the anchor does. With Transform Pattern Tiles on, +/// the tiles of the path selected whole move with it. Alt pressed during the drag copies the +/// selected objects instead. +#[test] +fn a_drag_from_a_fill_moves_only_the_selected_anchors_of_partly_selected_paths() { + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let mut s = new_doc(); + let filled = patterned_square(&mut s); + let other = square(&mut s, 300.0, 100.0); + let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() }; + set_pref(&mut s, "transformPatternTiles", json!(true)); + let xs = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p.x).collect() + }; + let before = xs(&s); + for tool in ["directSelection", "groupSelection"] { + s.select_tool(tool, v).unwrap(); + s.execute("select.set", &json!({"ids": [filled.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]); + assert_eq!(top_left(&s, filled), (110.0, 100.0), "{tool}: the square moves whole"); + let mut moved = before.clone(); + moved[0] += 10.0; + assert_eq!(xs(&s), moved, "{tool}: the selected anchor moves alone"); + assert_eq!(s.journal.last().unwrap().0, "path.moveAnchors", "{tool}"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Move"), "{tool}"); + assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "{tool}: the tiles move with the square"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((top_left(&s, filled), xs(&s)), ((100.0, 100.0), before.clone()), "{tool}: one undo step"); + // Alt pressed during the drag copies the selected objects and leaves the originals. + let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()); + let objects = count(&s); + s.execute("select.set", &json!({"ids": [filled.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + let alt = vectorcraft_tools::Mods { alt: true, ..vectorcraft_tools::Mods::default() }; + s.pointer(&PointerEvent::new(Down, 125.0, 125.0), v).unwrap(); + s.pointer(&PointerEvent::new(Drag, 130.0, 125.0), v).unwrap(); + s.pointer(&PointerEvent::new(Drag, 135.0, 125.0).with_mods(alt), v).unwrap(); + s.pointer(&PointerEvent::new(Up, 135.0, 125.0).with_mods(alt), v).unwrap(); + assert_eq!((top_left(&s, filled), xs(&s), count(&s)), ((100.0, 100.0), before.clone(), objects + 2), "{tool}: copied"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy"), "{tool}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count(&s), objects, "{tool}"); + } +} + +/// A path selected whole inside a selected group moves once, with the group, when the selection +/// also holds a direct-selected anchor: a Group Selection drag from the path's fill (which adds +/// the group) and an arrow key move both by 10 pt. +#[test] +fn a_path_in_a_selected_group_moves_once_with_selected_anchors() { + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let mut s = new_doc(); + let (a, b) = (square(&mut s, 100.0, 100.0), square(&mut s, 160.0, 100.0)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let other = square(&mut s, 300.0, 100.0); + let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() }; + let first_x = |s: &Session| s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors[0].p.x; + s.select_tool("groupSelection", v).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]); + assert!(s.doc().unwrap().selection.contains(g), "the press added the group"); + assert_eq!((top_left(&s, a), top_left(&s, b), first_x(&s)), ((110.0, 100.0), (170.0, 100.0), 310.0), "the drag"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0, a.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "big": true})).unwrap(); + assert_eq!((top_left(&s, a), top_left(&s, b), first_x(&s)), ((110.0, 100.0), (170.0, 100.0), 310.0), "the arrow key"); +} + +/// Transform Again after a Direct Selection drag from a fill with an anchor of another path +/// selected moves the path selected whole and only that anchor again, by the same distance. A +/// drag back to where it began leaves that move to repeat, and `path.moveAnchors` run as a +/// command is repeated too. +#[test] +fn transform_again_repeats_a_drag_from_a_fill_with_anchors_selected() { + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let mut s = new_doc(); + let filled = square(&mut s, 100.0, 100.0); + let other = square(&mut s, 300.0, 100.0); + let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() }; + let xs = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p.x).collect() + }; + let mut moved = xs(&s); + s.select_tool("directSelection", v).unwrap(); + s.execute("select.set", &json!({"ids": [filled.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]); + s.execute("object.transformAgain", &json!({})).unwrap(); + moved[0] += 20.0; + assert_eq!((top_left(&s, filled), xs(&s)), ((120.0, 100.0), moved.clone())); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Transform Again")); + // A drag that ends where it began leaves the move to repeat. + gesture(&mut s, v, &[(Down, 145.0, 125.0), (Drag, 155.0, 125.0), (Drag, 145.0, 125.0), (Up, 145.0, 125.0)]); + s.execute("object.transformAgain", &json!({})).unwrap(); + moved[0] += 10.0; + assert_eq!((top_left(&s, filled), xs(&s)), ((130.0, 100.0), moved), "after a drag back to its start"); + // The command, as an action plays it back, is repeated the same way. + s.execute("path.moveAnchors", &json!({"dx": 0, "dy": 5})).unwrap(); + s.execute("object.transformAgain", &json!({})).unwrap(); + assert_eq!(top_left(&s, filled), (130.0, 110.0), "path.moveAnchors"); +} + /// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the /// swatch; on, only the same tint. #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index a24ee0f2fd..e4828375ee 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -84,7 +84,7 @@ fn save_as_takes_on_path_format_and_options() { let svg = s.execute("file.formatOptions", &json!({"format": "svg"})).unwrap(); assert_eq!(svg["options"]["outlineText"]["value"], false, "another format shows its defaults"); let ids: Vec<&str> = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]); + assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"]); // SVGZ is gzip-compressed SVG and reopens as SVGZ. let svgz = path(&d, "doc.svgz"); s.execute("file.saveAs", &json!({"path": svgz})).unwrap(); @@ -405,3 +405,141 @@ fn transparency_grid_round_trips_without_dirtying() { assert!(!s.doc().unwrap().transparency_grid); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { + let d = dir("eps-original-format"); + let eps_path = path(&d, "Artwork.eps"); + // Original, contributor-authored PostScript: the artwork occupies only part of the page. + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nshowpage\n%%EOF\n"; + std::fs::write(&eps_path, source).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert_eq!(s.doc().unwrap().format, "eps"); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + let board = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (200.0, 100.0)); + rect(&mut s); + let edited_layers = s.doc().unwrap().doc.layers.clone(); + let r = s.execute("document.save", &json!({"options": {"previewFormat": "none", "thumbnails": false}})).unwrap(); + assert_eq!((r["path"].as_str(), r["format"].as_str()), (Some(eps_path.as_str()), Some("eps"))); + let bytes = std::fs::read(&eps_path).unwrap(); + assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); + assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); + let editing = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(editing.artboards[0].rect, board); + assert_eq!(editing.layers, edited_layers); + assert!(!s.doc().unwrap().is_dirty()); + let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert_eq!(opened["restored"], true); + assert_eq!(s.doc().unwrap().doc.layers, edited_layers); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board); + rect(&mut s); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_keeps_all_editing_artboards_while_printing_one_page() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let r = s.execute("document.save", &json!({"format": "eps", "artboard": 1, "previewFormat": "none", "thumbnails": false})).unwrap(); + let bytes = b64(&r); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards.len(), 2); + assert_eq!(native.layers, s.doc().unwrap().doc.layers); + assert!(s.execute("document.save", &json!({"format": "eps", "range": "all"})).is_err(), "EPS is one printed page"); + for options in [json!({"useArtboards": false}), json!({"selectedOnly": true})] { + assert!(s.execute("document.save", &json!({"format": "eps", "options": options})).is_err(), "cropping is Export's job"); + } +} + +#[test] +fn eps_save_retains_lossy_import_overwrite_protection_and_transparency_warning() { + let d = dir("eps-import-warning"); + let eps_path = path(&d, "Artwork.eps"); + let mut s = session(); + rect(&mut s); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let r = s.execute("document.save", &json!({"path": eps_path, "previewFormat": "none", "thumbnails": false})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flatten")), "{r}"); + let original = std::fs::read(&eps_path).unwrap(); + let st = s.doc_mut().unwrap(); + st.imported_from = Some(eps_path.clone()); + st.import_losses.push("test import omitted artwork".into()); + rect(&mut s); + assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); + assert_eq!(std::fs::read(&eps_path).unwrap(), original); + s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap(); + assert_ne!(std::fs::read(&eps_path).unwrap(), original); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_supports_an_empty_artboard_without_cropping_its_page() { + let mut s = session(); + let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap(); + let bytes = b64(&r); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards[0].rect, s.doc().unwrap().doc.artboards[0].rect); + assert_eq!(native.node_count(), s.doc().unwrap().doc.node_count()); + assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); +} + +#[test] +fn partially_read_eps_requires_confirmation_before_save_back() { + let d = dir("eps-partial-import"); + let eps_path = path(&d, "partial.eps"); + let original = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nunsupportedMockyOperator\n1 0 0 setrgbcolor 80 20 40 30 rectfill\nshowpage\n%%EOF\n"; + std::fs::write(&eps_path, original).unwrap(); + let mut s = Session::new(); + let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().starts_with("the file was read up to an error")), "{opened}"); + rect(&mut s); + assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); + assert_eq!(std::fs::read(&eps_path).unwrap(), original); + s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap(); + assert!(vectorcraft_eps::native(&std::fs::read(&eps_path).unwrap()).is_some()); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_with_damaged_editing_data_requires_confirmation_before_save_back() { + let d = dir("eps-damaged-editing"); + let eps_path = path(&d, "damaged.eps"); + let mut s = session(); + rect(&mut s); + let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false})).unwrap(); + let mut bytes = b64(&r); + let at = bytes.windows(9).position(|w| w == b"native\n% ").unwrap() + 12; + bytes[at] = b'z'; + std::fs::write(&eps_path, &bytes).unwrap(); + let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap(); + assert_eq!(opened["restored"], false); + assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("editing data can't be read")), "{opened}"); + rect(&mut s); + assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss")); + assert_eq!(std::fs::read(&eps_path).unwrap(), bytes); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 4000, "height": 4000})).unwrap(); + rect(&mut s); + // Explicit colour preview remains a colour preview; Save bounds its resolution, not the art. + let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap(); + let bytes = b64(&r); + let (ps, tiff) = vectorcraft_eps::sections(&bytes).unwrap(); + let tiff = tiff.unwrap(); + assert!(tiff.len() < 4 * 1024 * 1024 + 64 * 1024, "compatibility preview grew to {} bytes", tiff.len()); + let preview = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap(); + assert_eq!((preview.width(), preview.height()), (1024, 1024)); + assert!(std::str::from_utf8(ps).unwrap().contains("%%BoundingBox: 0 0 4000 4000")); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards, s.doc().unwrap().doc.artboards); + assert_eq!(native.layers, s.doc().unwrap().doc.layers); +} diff --git a/crates/engine/src/tests/shaper.rs b/crates/engine/src/tests/shaper.rs index c0b4ad8880..891d03e7a7 100644 --- a/crates/engine/src/tests/shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -356,6 +356,50 @@ fn shaper_isolate_refuses_a_locked_or_hidden_source_group() { } } +/// While an opacity mask or a pattern is edited, its editing layer stays isolated: a Shaper source +/// pick selects the original shape without isolating it, and a scribble, Release and Expand keep the +/// layer isolated. Leaving the mode then ends isolation. +#[test] +fn shaper_keeps_the_mask_and_pattern_editing_layers_isolated() { + for mode in ["mask", "pattern"] { + let mut s = session(); + let object = rectangle(&mut s, 300.0, "#000000"); + let layer = if mode == "mask" { + let m = rectangle(&mut s, 300.0, "#ffffff"); + s.execute("select.set", &json!({"ids": [object.0, m.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap()["layer"].as_u64().map(NodeId) + } else { + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + s.doc().unwrap().doc.pattern_edit.as_ref().map(|e| e.layer) + }; + let isolation = |s: &Session| s.doc().unwrap().isolation; + assert!(layer.is_some() && isolation(&s) == layer, "{mode}"); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let make = |s: &mut Session| { + let pts = [[25, 20], [75, 35], [30, 50], [125, 65]]; + NodeId(s.execute("shaper.scribble", &json!({"points": pts, "ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap()) + }; + let g = make(&mut s); + assert_eq!(s.doc().unwrap().doc.parent_of(g), layer, "{mode}"); + assert_eq!(isolation(&s), layer, "{mode}: scribble"); + let picked = s.execute("shaper.select", &json!({"point": [25, 50], "source": true})).unwrap(); + assert_eq!(picked["id"], json!(a.0), "{mode}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{mode}"); + assert_eq!(isolation(&s), layer, "{mode}: source pick"); + s.execute("shaper.release", &json!({"id": g.0})).unwrap(); + assert_eq!(isolation(&s), layer, "{mode}: release"); + let g = make(&mut s); + s.execute("shaper.expand", &json!({"id": g.0})).unwrap(); + assert_eq!(isolation(&s), layer, "{mode}: expand"); + let leave = if mode == "mask" { "transparency.stopEditingOpacityMask" } else { "object.pattern.done" }; + s.execute(leave, &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.doc.pattern_edit.is_none() && st.isolation.is_none(), "{mode}"); + } +} + #[test] fn shaper_lines_keep_free_angles_with_or_without_shift() { for shift in [false, true] { @@ -567,3 +611,162 @@ fn shaper_joined_outline_keeps_its_stroke_paint_after_source_edits() { assert_ne!(node(&s, id).appearance.stroke_paint().label(), "#00ff00"); } } + +fn source_ids(s: &Session, g: NodeId) -> Vec { + node(s, g).children().unwrap()[0].children().unwrap().iter().map(|n| n.id).collect() +} +/// A Selection-tool drag from `from` to `to`. +fn drag(s: &mut Session, v: ViewInfo, from: [f64; 2], to: [f64; 2]) { + s.pointer(&PointerEvent::new(PointerKind::Down, from[0], from[1]), v).unwrap(); + for t in [0.25, 0.5, 1.0] { + let p = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t]; + s.pointer(&PointerEvent::new(PointerKind::Drag, p[0], p[1]), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to[0], to[1]), v).unwrap(); +} +/// Two overlapping rectangles in a Shaper Group, in Construction mode with the left one selected +/// and the Selection tool chosen → (left, right, group). +fn construction(s: &mut Session, v: ViewInfo) -> (NodeId, NodeId, NodeId) { + let a = rectangle(s, 0.0, "#ff0000"); + let b = rectangle(s, 50.0, "#0000ff"); + let g = scribble(s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.select_tool("selection", v).unwrap(); + (a, b, g) +} + +/// In Construction mode, a Selection-tool drag that drops an original shape wholly outside the +/// bounds of the group's original shapes takes it out of the group, in one undo step: it becomes a +/// path just above the group with its own look, and stays selected. Undo puts it back. With +/// Real-time Drawing off the drop does the same. +#[test] +fn shaper_dragging_an_original_shape_out_of_the_bounds_takes_it_out_of_the_group() { + for real_time in [true, false] { + let mut s = session(); + s.prefs.real_time_drawing = real_time; + let v = ViewInfo { smart_guides: false, ..Default::default() }; + let (a, b, g) = construction(&mut s, v); + let look = node(&s, a).appearance.clone(); + let steps = s.doc().unwrap().history.undo.len(); + drag(&mut s, v, [25.0, 50.0], [25.0, 200.0]); + let st = s.doc().unwrap(); + assert_eq!(source_ids(&s, g), vec![b], "real-time {real_time}"); + let (parent, index, _) = st.doc.position(a).unwrap(); + assert_eq!((parent, index), { st.doc.position(g).map(|(p, i, _)| (p, i + 1)).unwrap() }); + assert_eq!(node(&s, a).appearance, look); + assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 150.0); + assert_eq!(st.selection.objects, vec![a]); + assert_eq!(st.isolation, None); + assert_eq!(st.history.undo.len(), steps + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Remove from Shaper Group"); + // The group's result is the right rectangle alone. + assert_eq!(fill(&s, g, 25.0, 50.0), None); + assert_eq!(fill(&s, g, 125.0, 50.0).as_deref(), Some("#0000ff")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(source_ids(&s, g), vec![a, b]); + assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 0.0); + assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000")); + } +} + +/// A drop that leaves the original shape across the bounds of the group's original shapes only +/// moves it, as before. +#[test] +fn shaper_dragging_an_original_shape_across_the_bounds_only_moves_it() { + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + let (a, b, g) = construction(&mut s, v); + let sources = node(&s, g).children().unwrap()[0].id; + drag(&mut s, v, [25.0, 50.0], [25.0, 100.0]); + let st = s.doc().unwrap(); + assert_eq!(source_ids(&s, g), vec![a, b]); + assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 50.0); + assert_eq!(st.isolation, Some(sources)); + assert_eq!(st.history.undo.last().unwrap().label, "Move"); + // Outside Construction mode the same drop moves the group as usual. + s.execute("object.exitIsolation", &json!({})).unwrap(); + drag(&mut s, v, [25.0, 100.0], [25.0, 400.0]); + assert_eq!(source_ids(&s, g), vec![a, b]); +} + +/// Taking the last original shape out removes the group, and the native file keeps the result. +#[test] +fn shaper_taking_the_last_original_shape_out_removes_the_group_and_saves() { + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + let (a, b, g) = construction(&mut s, v); + drag(&mut s, v, [25.0, 50.0], [25.0, 200.0]); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + drag(&mut s, v, [125.0, 50.0], [400.0, 50.0]); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(g).is_none()); + assert_eq!(doc.parent_of(a), doc.parent_of(b)); + assert_eq!(node(&s, b).geometric_bounds().unwrap().x0, 325.0); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(back.layers, doc.layers); +} + +/// Flat bounds of horizontal or vertical lines on the line of a group's flat bounds, and bounds that +/// touch the group's bounds, are not outside them. +#[test] +fn shaper_outside_test_handles_flat_and_touching_bounds() { + use crate::cmd::shaper::wholly_outside; + use vectorcraft_geom::Rect; + let row = Rect::new(0.0, 150.0, 200.0, 150.0); + assert!(!wholly_outside(Rect::new(50.0, 150.0, 120.0, 150.0), row)); + assert!(!wholly_outside(Rect::new(150.0, 150.0, 300.0, 150.0), row)); + assert!(wholly_outside(Rect::new(250.0, 150.0, 300.0, 150.0), row)); + assert!(wholly_outside(Rect::new(50.0, 160.0, 120.0, 160.0), row)); + let column = Rect::new(80.0, 0.0, 80.0, 100.0); + assert!(!wholly_outside(Rect::new(80.0, 50.0, 80.0, 150.0), column)); + assert!(wholly_outside(Rect::new(90.0, 50.0, 90.0, 150.0), column)); + let square = Rect::new(0.0, 0.0, 100.0, 100.0); + assert!(!wholly_outside(Rect::new(100.0, 0.0, 200.0, 100.0), square)); + assert!(wholly_outside(Rect::new(100.5, 0.0, 200.0, 100.0), square)); +} + +/// `shaper.releaseSource` takes the named original shape out, refuses a shape of another group or +/// none, and `object.transform`'s `releaseOutside` makes one undo step outside a drag too. +#[test] +fn shaper_release_source_command_and_transform_flag() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let other = rectangle(&mut s, 300.0, "#00ff00"); + let untouched = s.doc().unwrap().doc.layers.clone(); + for p in [json!({"source": other.0}), json!({"source": a.0, "group": other.0}), json!({"source": "x"}), json!({})] { + assert!(s.execute("shaper.releaseSource", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc.layers, untouched); + } + s.execute("shaper.releaseSource", &json!({"source": b.0, "group": g.0})).unwrap(); + assert_eq!(source_ids(&s, g), vec![a]); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove from Shaper Group"); + s.execute("edit.undo", &json!({})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 0, 150], "ids": [a.0], "releaseOutside": true})).unwrap(); + assert_eq!(source_ids(&s, g), vec![b]); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove from Shaper Group"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(source_ids(&s, g), vec![a, b]); + assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 0.0); + // With a history of one step, that step still goes back to before the move. + s.doc_mut().unwrap().history.limit = 1; + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 0, 150], "ids": [a.0], "releaseOutside": true})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(source_ids(&s, g), vec![a, b]); + assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 0.0); + // Shapes taken out together keep their stacking order, whatever order they are named in. + for ids in [[a.0, b.0], [b.0, a.0]] { + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 0, 150], "ids": ids, "releaseOutside": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(g).is_none()); + assert!(doc.position(a).unwrap().1 < doc.position(b).unwrap().1, "{ids:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(source_ids(&s, g), vec![a, b]); + } +} diff --git a/crates/engine/src/tests/smooth_path.rs b/crates/engine/src/tests/smooth_path.rs new file mode 100644 index 0000000000..cf946cbe4e --- /dev/null +++ b/crates/engine/src/tests/smooth_path.rs @@ -0,0 +1,163 @@ +//! Object › Path › Smooth (`object.path.smooth`): the slider from minimum to maximum smoothing and +//! Auto-Smooth, as the Smooth slider help page describes +//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html). + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::kurbo::ParamCurveNearest; +use vectorcraft_geom::{ParamCurve, PathData, Point}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path_of(s: &Session, id: NodeId) -> PathData { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Path { path, .. } => path.clone(), + _ => panic!("not a path"), + } +} + +/// A path through `points`, selected: its id. +fn polyline(s: &mut Session, points: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = points.iter().map(|&(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()) +} + +/// A sine wave 300 pt long with an anchor every 3 pt, its anchors 1.5 pt above and below it in turn. +fn jagged_wave(s: &mut Session) -> NodeId { + let pts: Vec<(f64, f64)> = (0..=100u32) + .map(|i| (f64::from(i) * 3.0, 200.0 + 40.0 * (f64::from(i) * 3.0 / 50.0).sin() + if i.is_multiple_of(2) { 1.5 } else { -1.5 })) + .collect(); + polyline(s, &pts, false) +} + +/// A closed ring of radius `r` around (`cx`, 300) with 120 anchors, `jag` pt in and out in turn. +fn jagged_ring(s: &mut Session, cx: f64, r: f64, jag: f64) -> NodeId { + let pts: Vec<(f64, f64)> = (0..120u32) + .map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 120.0; + let rr = r + if i.is_multiple_of(2) { jag } else { -jag }; + (cx + rr * a.cos(), 300.0 + rr * a.sin()) + }) + .collect(); + polyline(s, &pts, true) +} + +/// The farthest that 11 points on each segment of `from` lie from the nearest point of `to`. +fn farthest(from: &PathData, to: &PathData) -> f64 { + let off = |p: Point| { + to.subpaths + .iter() + .flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i).nearest(p, 1e-9).distance_sq.sqrt())) + .fold(f64::INFINITY, f64::min) + }; + from.subpaths + .iter() + .flat_map(|sp| (0..sp.segment_count()).flat_map(move |i| (0..=10).map(move |k| sp.segment(i).eval(f64::from(k) / 10.0)))) + .map(off) + .fold(0.0, f64::max) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn minimum_smoothing_leaves_the_path_where_it_was() { + let mut s = session(); + let id = jagged_wave(&mut s); + let before = path_of(&s, id); + let out = s.execute("object.path.smooth", &json!({"level": 0})).unwrap(); + assert_eq!(out["level"], json!(0.0)); + let after = path_of(&s, id); + // 0.1 % of the longer side (300 pt), both ways. + assert!(farthest(&before, &after) <= 0.3 + 1e-6 && farthest(&after, &before) <= 0.3 + 1e-6); +} + +#[test] +fn more_smoothing_removes_the_jags_and_keeps_the_shape() { + let mut s = session(); + let id = jagged_wave(&mut s); + let before = path_of(&s, id); + let mut counts = vec![]; + for level in [25, 50, 100] { + let out = s.execute("object.path.smooth", &json!({ "level": level })).unwrap(); + let after = path_of(&s, id); + assert_eq!(out["after"].as_u64().unwrap() as usize, after.anchor_count()); + counts.push(after.anchor_count()); + // Within the tolerance (2.5 % of 300 pt at most) plus the jag the refit smooths away. + assert!(farthest(&before, &after) <= 7.5 + 1.5, "level {level}: {}", farthest(&before, &after)); + // The end points of an open path stay put. + let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors); + assert_eq!((a[0].p, a[a.len() - 1].p), (b[0].p, b[b.len() - 1].p)); + assert!(!after.subpaths[0].closed); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(counts[0] < 101 / 2 && counts[2] <= 10, "{counts:?}"); + assert!(counts.windows(2).all(|w| w[0] >= w[1]), "{counts:?}"); +} + +#[test] +fn auto_smooth_uses_the_middle_level() { + let mut s = session(); + let id = jagged_wave(&mut s); + let out = s.execute("object.path.smooth", &json!({"auto": true, "level": 0})).unwrap(); + assert_eq!(out["level"], json!(50.0)); + let auto = path_of(&s, id); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.path.smooth", &json!({"level": 50})).unwrap(); + assert_eq!(path_of(&s, id), auto); +} + +#[test] +fn closed_paths_and_compound_path_members_smooth_in_one_undo_step() { + let mut s = session(); + let outer = jagged_ring(&mut s, 300.0, 100.0, 2.0); + let inner = jagged_ring(&mut s, 300.0, 40.0, 1.0); + s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap(); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + let (o, i) = (path_of(&s, outer), path_of(&s, inner)); + let steps = undo_len(&s); + let out = s.execute("object.path.smooth", &json!({"level": 100})).unwrap(); + assert_eq!(out["ids"].as_array().unwrap().len(), 2); + assert_eq!(undo_len(&s), steps + 1); + for id in [outer, inner] { + let now = path_of(&s, id); + assert!(now.subpaths[0].closed); + assert!(now.anchor_count() < 120 / 4, "{}", now.anchor_count()); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path_of(&s, outer), path_of(&s, inner)), (o, i)); +} + +#[test] +fn smooths_the_objects_given() { + let mut s = session(); + let a = jagged_wave(&mut s); + let b = jagged_ring(&mut s, 300.0, 50.0, 1.0); + let ring = path_of(&s, b); + // `b` is selected; `ids` names `a` instead. + let out = s.execute("object.path.smooth", &json!({"ids": [a.0], "level": 100})).unwrap(); + assert_eq!(out["ids"], json!([a.0])); + assert_eq!(path_of(&s, b), ring); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let mut s = session(); + let id = jagged_wave(&mut s); + let before = path_of(&s, id); + let steps = undo_len(&s); + for p in [json!({"level": "50"}), json!({"auto": true, "level": "max"}), json!({"auto": "yes"}), json!({"ids": "all"}), json!({"ids": [999_999]})] + { + assert!(s.execute("object.path.smooth", &p).is_err(), "{p}"); + } + assert_eq!((path_of(&s, id), undo_len(&s)), (before, steps)); + // Levels past the ends count as the ends. + assert_eq!(s.execute("object.path.smooth", &json!({"level": 250})).unwrap()["level"], json!(100.0)); +} diff --git a/crates/engine/src/tests/swatches.rs b/crates/engine/src/tests/swatches.rs index 031b5026d2..406c269262 100644 --- a/crates/engine/src/tests/swatches.rs +++ b/crates/engine/src/tests/swatches.rs @@ -47,6 +47,151 @@ fn document_color_mode_converts_grouped_swatches() { assert!(matches!(doc(&s).swatch("Bright Red").unwrap().paint.color(), Some(Color::Cmyk { .. }))); } +/// The first solid fill color in the subtree of `n`. +fn first_fill(n: &vectorcraft_doc::Node) -> Color { + let mut out = None; + n.walk(&mut |m| { + if out.is_none() { + out = m.appearance.fill_paint().color(); + } + }); + out.unwrap_or_else(|| panic!("no fill in {n:?}")) +} + +/// The fill of graph design "Bar", the fill of brush "Holder"'s art and its Hue Shift key color, +/// the fill of graphic style "Red" and the fill of the art of `masked`'s opacity mask. +fn colors_outside_the_layers(s: &Session, masked: NodeId) -> Vec { + let d = doc(s); + let brush = vectorcraft_brush::find(d, "Holder").unwrap(); + let vectorcraft_brush::Colorization::HueShift { key } = brush.kind.colorization() else { panic!("not Hue Shift") }; + vec![ + first_fill(&d.graph_designs.iter().find(|g| g.name == "Bar").unwrap().art), + first_fill(brush.art()[0]), + key, + d.graphic_style("Red").unwrap().appearance.fill_paint().color().unwrap(), + first_fill(&d.node(masked).unwrap().extras().mask.as_deref().unwrap().art), + ] +} + +/// Document Color Mode converts the colors of graph designs, of the art of brushes (with a Hue +/// Shift brush's key color), of graphic styles and of opacity-mask art as it converts the +/// layers' art, tints of global swatches included, in one undo step. +#[test] +fn document_color_mode_converts_art_outside_the_layers() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true})); + let hue_shift = json!({"colorization": {"method": "hueShift"}}); + for (cmd, p, paint) in [ + ("graph.design", json!({"new": "Bar"}), json!({"swatch": "Tone", "tint": 40})), + ("brush.new", json!({"type": "art", "name": "Holder", "params": hue_shift}), json!({"color": "#ff0000"})), + ("graphicStyle.new", json!({"name": "Red"}), json!({"color": "#ff0000"})), + ] { + let id = rect(&mut s); + let mut paint = paint; + paint["ids"] = json!([id.0]); + run(&mut s, "paint.setFill", paint); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, cmd, p); + run(&mut s, "edit.clear", json!({"ids": [id.0]})); + } + let masked = rect(&mut s); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [masked.0]})); + let m = rect(&mut s); + run(&mut s, "paint.setFill", json!({"color": "#808080", "ids": [m.0]})); + run(&mut s, "select.set", json!({"ids": [masked.0, m.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false})); + let before = colors_outside_the_layers(&s, masked); + assert!(before.iter().all(|c| matches!(c, Color::Rgb { .. })), "{before:?}"); + let undo = s.doc().unwrap().history.undo.len(); + + run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); + let after = colors_outside_the_layers(&s, masked); + assert!(after.iter().all(|c| matches!(c, Color::Cmyk { .. })), "{after:?}"); + // The same red converts to the same inks wherever it is. + let red = first_fill(doc(&s).node(masked).unwrap()); + assert_eq!(after[1..4], [red; 3]); + // A tint of a global swatch is the converted swatch's color at that tint. + let tone = doc(&s).swatch("Tone").unwrap().paint.color().unwrap(); + assert_eq!(after[0], tone.tinted(0.4)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(colors_outside_the_layers(&s, masked), before); +} + +/// A Hue Shift brush whose art is painted with a tint of a global swatch keeps its key color equal +/// to that art color after Document Color Mode. +#[test] +fn document_color_mode_keeps_a_hue_shift_key_on_its_tinted_art() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true})); + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Tone", "tint": 40, "ids": [id.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "params": {"colorization": {"method": "hueShift"}}})); + run(&mut s, "edit.clear", json!({"ids": [id.0]})); + let key_and_art = |s: &Session| { + let brush = vectorcraft_brush::find(doc(s), "Holder").unwrap(); + let vectorcraft_brush::Colorization::HueShift { key } = brush.kind.colorization() else { panic!("not Hue Shift") }; + (key, first_fill(brush.art()[0])) + }; + let (key, art) = key_and_art(&s); + assert_eq!(key, art); + run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); + let (key, art) = key_and_art(&s); + assert!(matches!(art, Color::Cmyk { .. }), "{art:?}"); + assert_eq!(key, art); +} + +/// The reply's `changed` counts the colors of the document's art; the colors of brush art and +/// graphic styles convert without being counted. +#[test] +fn document_color_mode_counts_the_colors_of_the_art_only() { + let changed = |library: bool| { + let mut s = session(); + if library { + for (cmd, p) in [("brush.new", json!({"type": "art", "name": "Holder"})), ("graphicStyle.new", json!({"name": "Red"}))] { + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [id.0]})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, cmd, p); + run(&mut s, "edit.clear", json!({"ids": [id.0]})); + } + } + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [id.0]})); + let r = run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); + if library { + let brush = vectorcraft_brush::find(doc(&s), "Holder").unwrap(); + assert!(matches!(first_fill(brush.art()[0]), Color::Cmyk { .. })); + } + r["changed"].as_u64().unwrap() + }; + let in_art = changed(false); + assert!(in_art > 0); + assert_eq!(changed(true), in_art); +} + +/// Document Color Mode chosen while a pattern is being edited also converts the tile that pattern +/// editing's Cancel puts back. +#[test] +fn document_color_mode_converts_the_tile_that_pattern_cancel_puts_back() { + let mut s = session(); + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [id.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + let pattern = run(&mut s, "object.pattern.make", json!({"edit": false}))["name"].as_str().unwrap().to_string(); + run(&mut s, "object.pattern.edit", json!({"name": pattern})); + run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); + run(&mut s, "object.pattern.cancel", json!({})); + let red = first_fill(doc(&s).node(id).unwrap()); + assert!(matches!(red, Color::Cmyk { .. }), "{red:?}"); + let tile: Vec = doc(&s).pattern(&pattern).unwrap().art.iter().map(|n| first_fill(n)).collect(); + assert_eq!(tile, [red]); +} + #[test] fn grouped_spot_swatches_print_on_their_own_plate() { let mut s = session(); @@ -383,3 +528,203 @@ fn global_swatch_edits_reach_opacity_mask_art() { run(&mut s, "edit.undo", json!({})); assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0))); } + +/// The fills of graph design "Bar" and of the art of brush "Holder", as `place: color, link`. +fn fills_outside_the_layers(s: &Session) -> Vec { + let d = doc(s); + let mut out = vec![]; + let mut tree = |place: &str, n: &vectorcraft_doc::Node| { + n.walk(&mut |m| { + if let Paint::Solid { color, swatch, .. } = m.appearance.fill_paint() { + out.push(format!("{place}: {}, {}", color.to_hex(), swatch.as_deref().unwrap_or("-"))); + } + }) + }; + tree("design", &d.graph_designs.iter().find(|g| g.name == "Bar").unwrap().art); + vectorcraft_brush::find(d, "Holder").unwrap().art().into_iter().for_each(|n| tree("brush art", n)); + out +} + +/// A global swatch edit that recolors a Hue Shift brush's key color in its art moves the key to the +/// new color, so strokes still paint that art with the stroke color. +#[test] +fn global_swatch_edits_move_a_hue_shift_key() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true})); + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Key", "ids": [id.0], "params": {"colorization": {"method": "hueShift"}}})); + let key = |s: &mut Session| run(s, "brush.get", json!({"name": "Key"}))["colorization"]["key"].clone(); + let (red, blue) = (json!(Color::rgb(1.0, 0.0, 0.0)), json!(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(key(&mut s), red); + run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"})); + assert_eq!(key(&mut s), blue); + run(&mut s, "edit.undo", json!({})); + assert_eq!(key(&mut s), red); + + // A paint in the key color that only an opacity mask uses leaves the key where it is. + run(&mut s, "swatch.new", json!({"name": "MaskTone", "color": "#ff0000", "global": true})); + let obj = rect(&mut s); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [obj.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]})); + let m = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "MaskTone", "ids": [m.0]})); + run(&mut s, "select.set", json!({"ids": [obj.0, m.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Masked", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}})); + let masked = |s: &mut Session| run(s, "brush.get", json!({"name": "Masked"}))["colorization"]["key"].clone(); + assert_eq!(masked(&mut s), red); + run(&mut s, "swatch.edit", json!({"name": "MaskTone", "color": "#0000ff"})); + assert_eq!(masked(&mut s), red); + + // With the art and its mask on one swatch, the key takes the art's new color, not the mask's. + run(&mut s, "swatch.new", json!({"name": "Both", "color": "#ffffff", "global": true})); + let obj = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Both", "ids": [obj.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]})); + let m = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Both", "tint": 50, "ids": [m.0]})); + run(&mut s, "select.set", json!({"ids": [obj.0, m.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Both", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}})); + run(&mut s, "swatch.edit", json!({"name": "Both", "color": "#0000ff"})); + assert_eq!(run(&mut s, "brush.get", json!({"name": "Both"}))["colorization"]["key"], blue); +} + +/// A global swatch edit that recolors brush art keeps the stored library entries that do not parse +/// as brushes. +#[test] +fn global_swatch_edits_keep_unreadable_brush_entries() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true})); + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]})); + let junk = json!({"name": "Unreadable", "kind": 7}); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") }; + entries.push(junk.clone()); + run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"})); + let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY]; + assert!(stored.as_array().is_some_and(|a| a.contains(&junk)), "{stored}"); + assert!(vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").is_some()); +} + +/// A global swatch edit that recolors brush art keeps the fields of stored brush entries that the +/// brush definition does not read, both on the brush it recolors and on the brushes it leaves alone, +/// and inside the art too. A swatch delete that unlinks the art drops the link and keeps those fields. +#[test] +fn global_swatch_edits_keep_unread_brush_fields() { + // Adds `"extra": 8` to every object under `v` that links a swatch. + fn tag_links(v: &mut Value) { + match v { + Value::Object(o) => { + if o.contains_key("swatch") { + o.insert("extra".into(), json!(8)); + } + o.values_mut().for_each(tag_links); + } + Value::Array(a) => a.iter_mut().for_each(tag_links), + _ => {} + } + } + // The objects under `v` that carry `"extra": 8`. + fn tagged(v: &Value) -> Vec<&Value> { + match v { + Value::Object(o) => o.get("extra").filter(|e| **e == json!(8)).map(|_| v).into_iter().chain(o.values().flat_map(tagged)).collect(), + Value::Array(a) => a.iter().flat_map(tagged).collect(), + _ => vec![], + } + } + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true})); + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]})); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") }; + for e in entries.iter_mut() { + if let Some(o) = e.as_object_mut() { + o.insert("extra".into(), json!(7)); + } + tag_links(e); + } + let links = tagged(&doc.unknown[vectorcraft_brush::DOC_KEY]).len(); + assert!(links > 0, "{}", doc.unknown[vectorcraft_brush::DOC_KEY]); + run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"})); + let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY]; + let entries = stored.as_array().map(Vec::as_slice).unwrap_or_default(); + assert!(entries.len() > 1, "{stored}"); + assert!(entries.iter().all(|e| e["extra"] == json!(7)), "{stored}"); + assert_eq!(tagged(stored).len(), links, "{stored}"); + let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap(); + let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex())); + assert_eq!(fill, Some(Some("#0000ff".to_string()))); + + run(&mut s, "swatch.delete", json!({"name": "Tone"})); + let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY]; + let unlinked = tagged(stored); + assert_eq!(unlinked.len(), links, "{stored}"); + assert!(unlinked.iter().all(|o| o.get("swatch").is_none()), "{stored}"); + let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap(); + let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex())); + assert_eq!(fill, Some(Some("#0000ff".to_string()))); +} + +/// A global swatch edit, rename, delete and Default Swatches with replace change the art of graph +/// designs and brushes: the art follows the swatch's color and name, or keeps its color unlinked, +/// each in one undo step. +#[test] +fn global_swatch_edits_reach_graph_designs_and_brush_art() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true})); + for (cmd, p) in [("graph.design", json!({"new": "Bar"})), ("brush.new", json!({"type": "art", "name": "Holder"}))] { + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, cmd, p); + run(&mut s, "edit.clear", json!({"ids": [id.0]})); + } + let both = |fill: &str| vec![format!("design: {fill}"), format!("brush art: {fill}")]; + assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone")); + let undo = s.doc().unwrap().history.undo.len(); + + let r = run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"})); + assert_eq!(r["relinked"], 2, "{r}"); + assert_eq!(fills_outside_the_layers(&s), both("#0000ff, Tone")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone")); + + run(&mut s, "swatch.edit", json!({"name": "Tone", "newName": "Hue"})); + assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Hue")); + // Default Swatches with replace drops the links to the swatches it removes. + run(&mut s, "swatch.resetDefaults", json!({"replace": true})); + assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -")); + run(&mut s, "edit.undo", json!({})); + let r = run(&mut s, "swatch.delete", json!({"name": "Hue"})); + assert_eq!(r["unlinked"], 2, "{r}"); + assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 2); +} + +/// A global swatch edited while a pattern is being edited also changes the tile that pattern +/// editing's Cancel puts back. +#[test] +fn global_swatch_edits_reach_the_tile_that_pattern_cancel_puts_back() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true})); + let id = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + let pattern = run(&mut s, "object.pattern.make", json!({"edit": false}))["name"].as_str().unwrap().to_string(); + run(&mut s, "object.pattern.edit", json!({"name": pattern})); + run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"})); + run(&mut s, "object.pattern.cancel", json!({})); + let mut fills = vec![]; + for n in &doc(&s).pattern(&pattern).unwrap().art { + n.walk(&mut |m| fills.push(m.appearance.fill_paint())); + } + assert_eq!(fills, [Paint::Solid { color: Color::rgb(0.0, 0.0, 1.0), swatch: Some("Tone".into()), tint: 1.0 }]); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9a4e529949..0cca50c6d2 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -27,7 +27,8 @@ pub struct ViewInfo { pub snap_to_pixel: bool, /// View → Snap to Point: picked points (a transform's reference point) land on anchors. pub snap_to_point: bool, - /// View → Snap to Glyph: drawing snaps to live-text baselines and glyph bounds (needs Smart Guides). + /// View → Snap to Glyph: drawing snaps to live-text glyph bounds, and on a single line of type to + /// its baseline, x-height, character widths and glyph anchor points (needs Smart Guides). pub snap_to_glyph: bool, /// View → Show Corner Widget. pub corner_widgets: bool, diff --git a/crates/engine/tests/it/beyond_canvas.rs b/crates/engine/tests/it/beyond_canvas.rs new file mode 100644 index 0000000000..9dee868d54 --- /dev/null +++ b/crates/engine/tests/it/beyond_canvas.rs @@ -0,0 +1,69 @@ +//! #3575: a native file holding an object, artboard, guide or ruler origin beyond the canvas +//! (`MAX_COORD`) opened, but every move then failed with "result would exceed the canvas": edits +//! check those. Opening it now removes them (or resets the ruler origin) and says so. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +/// A saved two-rectangle document as JSON, with its first rectangle 1e17 pt down when `far_object`. +fn document(far_object: bool) -> Value { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap(); + let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + if far_object { + let far = &mut v["document"]["layers"][0]["kind"]["children"][0]["kind"]; + far["path"]["subpaths"][0]["anchors"].as_array_mut().unwrap().iter_mut().for_each(|a| a["p"][1] = json!(a["p"][1].as_f64().unwrap() + 1e17)); + far["live"]["xf"][5] = json!(far["live"]["xf"][5].as_f64().unwrap() + 1e17); + } + v +} + +/// Open `v` from a file, select everything and move it: → the open reply's warnings. +fn open_and_move(v: &Value, tag: &str) -> (Session, Value) { + let path = std::env::temp_dir().join(format!("vectorcraft-beyond-canvas-{tag}-{}.vectorcraft", std::process::id())); + std::fs::write(&path, serde_json::to_vec(v).unwrap()).unwrap(); + let mut s = Session::new(); + let opened = s.execute("document.open", &json!({"path": path})); + let _ = std::fs::remove_file(&path); + let opened = opened.unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 5})).expect("everything left can be moved"); + (s, opened["warnings"].clone()) +} + +#[test] +fn an_object_beyond_the_canvas_is_removed_on_open() { + let (s, warnings) = open_and_move(&document(true), "object"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1, "the far rectangle is gone, the other stays"); + assert!(warnings.to_string().contains("1 object beyond the canvas"), "{warnings}"); +} + +#[test] +fn an_artboard_a_guide_or_a_ruler_origin_beyond_the_canvas_no_longer_blocks_edits() { + let mut v = document(false); + let mut far = v["document"]["artboards"][0].clone(); + far["id"] = json!(9); + far["rect"] = json!({"x0": 0.0, "y0": 1e17, "x1": 100.0, "y1": 1e17 + 100.0}); + v["document"]["artboards"].as_array_mut().unwrap().push(far); + v["document"]["guides"] = json!([{"vertical": false, "pos": 1e17}, {"vertical": true, "pos": 50.0}]); + v["document"]["ruler_origin"] = json!([1e17, 0.0]); + let (s, warnings) = open_and_move(&v, "setup"); + let d = &s.doc().unwrap().doc; + assert_eq!((d.artboards.len(), d.guides.len(), d.ruler_origin.x), (1, 1, 0.0)); + assert!(warnings.to_string().contains("1 artboard, 1 guide beyond the canvas"), "{warnings}"); + assert!(warnings.to_string().contains("ruler origin"), "{warnings}"); +} + +#[test] +fn a_file_whose_artboards_all_lie_beyond_the_canvas_is_refused() { + let mut v = document(false); + v["document"]["artboards"][0]["rect"] = json!({"x0": 0.0, "y0": 1e17, "x1": 100.0, "y1": 1e17 + 100.0}); + let path = std::env::temp_dir().join(format!("vectorcraft-beyond-canvas-pages-{}.vectorcraft", std::process::id())); + std::fs::write(&path, serde_json::to_vec(&v).unwrap()).unwrap(); + let r = Session::new().execute("document.open", &json!({"path": path})); + let _ = std::fs::remove_file(&path); + let e = r.unwrap_err().to_string(); + assert!(e.contains("artboards all lie beyond the canvas"), "{e}"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index b0409cffd2..5b9dc39c6c 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -10,6 +10,7 @@ mod text_fixture; mod affinity_features; mod affinity_local; mod artboard_click; +mod beyond_canvas; mod clear_commands; mod compound_attributes; mod dimensions; diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 798fcf312c..ad9df39ffd 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -522,7 +522,8 @@ pub fn load(bytes: &[u8]) -> Result { pub struct NativeFile { pub doc: Document, /// What was done to bring the document within the nesting caps - /// ([`Document::limit_depth_noting`]: a file saved before them, or a damaged one). + /// ([`Document::limit_depth_noting`]: a file saved before them, or a damaged one) and within + /// the canvas ([`Document::drop_beyond_canvas`]: a damaged or hand-made file). pub depth_notes: Vec, pub info: FileInfo, /// ICC files by profile name (saved with Embed ICC Profiles; undecodable ones are left out). @@ -552,10 +553,22 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { /// [`load`], with everything else the file carries. pub fn load_file(bytes: &[u8]) -> Result { + load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) +} + +/// [`load_file`] reading at most `max_nodes` objects and buffering at most `max_values` values +/// before objects' types, so a test can exercise the budgets without documents that large +/// ([`vectorcraft_doc::node::MAX_READ_NODES`], [`vectorcraft_doc::node::MAX_BUFFERED_VALUES`]). +fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result { let file = unpack::Unpacked::new(bytes)?; - // The JSON reader stops at 128 levels (a document's objects use three each), and the paths - // read at `MAX_READ_ANCHORS` anchors together. - let read = || serde_json::from_slice::(file.json()); + // The JSON reader stops at 128 levels (a document's objects use three each), the paths read + // at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the + // fields read before an object's type at `MAX_BUFFERED_VALUES` values. + let read = || { + vectorcraft_doc::node::with_node_budget(max_nodes, || { + vectorcraft_doc::node::with_buffered_value_budget(max_values, || serde_json::from_slice::(file.json())) + }) + }; let f: File = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| { let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() }; FormatError::NotVectorcraft(why) @@ -605,6 +618,17 @@ pub fn load_file(bytes: &[u8]) -> Result { doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); + // Untrusted: nothing beyond the canvas, where no edit may leave anything (after the depth + // limit: the walk recurses). A file whose artboards all lie there has no page to show (#3575). + let had_artboards = !doc.artboards.is_empty(); + let beyond = doc.drop_beyond_canvas(); + if had_artboards && doc.artboards.is_empty() { + return Err(FormatError::NotVectorcraft(format!( + "its artboards all lie beyond the canvas (more than {} pt from the origin)", + vectorcraft_doc::MAX_COORD + ))); + } + depth_notes.extend(beyond.note()); // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). doc.keep_type_values_in_range(); @@ -776,6 +800,55 @@ mod tests { assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data()); } + /// #3587: objects are read within a budget. A compressed file of many small objects stays + /// small on disk while they take far more memory once read, so past the budget it is refused + /// with a message naming it, before the rest of the document is read. + #[test] + fn a_documents_objects_are_capped() { + assert_eq!(vectorcraft_doc::node::MAX_READ_NODES, if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 }); + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + for i in 0..200 { + let id = d.alloc_id(); + d.insert(Some(l), i, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + } + let n = d.node_count(); + let bytes = save_with(&d, &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap(); + assert!(is_compressed(&bytes)); + assert_eq!( + load_file_within(&bytes, n, vectorcraft_doc::node::MAX_BUFFERED_VALUES).unwrap().doc.node_count(), + n, + "a document of exactly the budget opens" + ); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one object past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + assert_eq!(load(&bytes).unwrap().node_count(), n, "the real budget opens ordinary documents"); + } + + /// #3587: a reordered file (its objects' fields before their `type`, as a JSON tool that + /// sorts keys writes them) still opens; one whose fields before a `type` hold more values than + /// the budget, such as a long array of numbers, is refused as they are read. + #[test] + fn values_buffered_before_an_objects_type_are_capped() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + // Through `serde_json::Value`, whose objects sort their keys: `children` before `type`. + let sorted = serde_json::to_vec(&serde_json::from_slice::(&save(&d, false)).unwrap()).unwrap(); + assert_eq!(load(&sorted).unwrap().node_count(), d.node_count()); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let zeros = vec![0; 1000]; + v["document"]["layers"][0]["kind"]["children"] = serde_json::json!([{"id": 90, "kind": {"children": zeros, "type": "group"}}]); + let bomb = serde_json::to_vec(&v).unwrap(); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bomb, vectorcraft_doc::node::MAX_READ_NODES, 100) else { + panic!("past the budget must be refused") + }; + assert!(why.contains("more than 100 values before its type"), "{why}"); + } + #[test] fn rejects_foreign_and_future() { assert!(load(b"{}").is_err()); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 61d3e55924..e1a3599744 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -831,13 +831,18 @@ fn answer_dialog_needs_the_desktop_app() { assert!(text_of(&r).contains("needs the desktop app"), "{r}"); } +/// How long a fake app waits for [`Remote`] to connect or to send, and how long a test waits for +/// a connection to end or a line to arrive. The wait only ends a test whose client or app never +/// does. A machine busy with other builds can hold a thread for seconds. +const WAIT: std::time::Duration = std::time::Duration::from_secs(20); + #[test] fn remote_does_not_replay_when_edit_reply_is_lost() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let addr = listener.local_addr().unwrap().to_string(); let h = std::thread::spawn(move || { - let timeout = std::time::Duration::from_secs(5); + let timeout = WAIT; let mut backend = Headless::with_document(); let base = backend.call("document.inspect", json!({})).unwrap()["objects"].as_u64().unwrap(); for connection in 0..2 { @@ -997,7 +1002,7 @@ fn remote_rejects_uncorrelated_replies_without_replaying() { listener.set_nonblocking(true).unwrap(); let addr = listener.local_addr().unwrap().to_string(); let h = std::thread::spawn(move || { - let timeout = std::time::Duration::from_secs(5); + let timeout = WAIT; let mut seen = vec![]; for connection in 0..2 { let deadline = std::time::Instant::now() + timeout; @@ -1052,7 +1057,7 @@ fn remote_rejects_a_reply_with_a_mismatched_id_and_reconnects() { let h = std::thread::spawn(move || { let mut seen = vec![]; for stream in listener.incoming().flatten().take(2) { - stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + stream.set_read_timeout(Some(WAIT)).unwrap(); let mut out = stream.try_clone().unwrap(); let mut reader = BufReader::new(stream); let mut line = String::new(); @@ -1088,12 +1093,12 @@ fn remote_reports_the_reason_of_a_refusal_without_an_id() { let h = std::thread::spawn(move || { let (mut refused, _) = listener.accept().unwrap(); // The request is read first, so the refusal answers it and the close is orderly. - refused.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + refused.set_read_timeout(Some(WAIT)).unwrap(); let _ = BufReader::new(refused.try_clone().unwrap()).read_line(&mut String::new()); writeln!(refused, "{}", json!({"ok": false, "error": "too many control connections"})).unwrap(); drop(refused); let (stream, _) = listener.accept().unwrap(); - stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + stream.set_read_timeout(Some(WAIT)).unwrap(); let mut out = stream.try_clone().unwrap(); let mut line = String::new(); BufReader::new(stream).read_line(&mut line).unwrap(); @@ -1108,6 +1113,26 @@ fn remote_reports_the_reason_of_a_refusal_without_an_id() { h.join().unwrap(); } +/// How long a refused connect to the app may take: longer than the 800 ms connect timeout of +/// [`Remote`], and shorter than the 2 s a refused local connect takes on Windows without one. +const FAILED_CONNECT: std::time::Duration = std::time::Duration::from_millis(1500); + +/// Runs `attempt` until one run takes less than `bound`, at most five times, and returns how long +/// each run took. A machine busy with other builds can hold the test's thread for seconds in one +/// run; a connect that waits longer than its own timeout is slow in every run. +fn runs_until_faster_than(bound: std::time::Duration, mut attempt: impl FnMut()) -> Vec { + let mut took = Vec::new(); + while took.len() < 5 { + let start = std::time::Instant::now(); + attempt(); + took.push(start.elapsed()); + if took.last() < Some(&bound) { + break; + } + } + took +} + #[test] fn remote_connect_fails_fast() { // A port this test holds, bound but not listening, so connecting to it is refused. (A port @@ -1115,9 +1140,8 @@ fn remote_connect_fails_fast() { let held = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); held.bind(&std::net::SocketAddr::from(([127, 0, 0, 1], 0)).into()).unwrap(); let port = held.local_addr().unwrap().as_socket().unwrap().port(); - let start = std::time::Instant::now(); - assert!(Remote::connect(&format!("127.0.0.1:{port}")).is_err()); - assert!(start.elapsed() < std::time::Duration::from_secs(5), "took {:?}", start.elapsed()); + let took = runs_until_faster_than(FAILED_CONNECT, || assert!(Remote::connect(&format!("127.0.0.1:{port}")).is_err())); + assert!(took.last() < Some(&FAILED_CONNECT), "took {took:?}"); // Nothing else could listen there meanwhile. assert!(std::net::TcpListener::bind(format!("127.0.0.1:{port}")).is_err()); drop(held); @@ -1133,7 +1157,7 @@ fn reusable_listener(addr: std::net::SocketAddr) -> std::net::TcpListener { fn wait_for_remote_eof(remote: &Remote) { let probe = remote.connection_probe(); - probe.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + probe.set_read_timeout(Some(WAIT)).unwrap(); assert_eq!(probe.peek(&mut [0]).unwrap(), 0); } @@ -1144,7 +1168,7 @@ fn remote_edit_succeeds_once_when_app_restarts() { let mut remote = Remote::connect(&addr.to_string()).unwrap(); let (old_connection, _) = listener.accept().unwrap(); let old_handle = std::thread::spawn(move || { - let timeout = std::time::Duration::from_secs(2); + let timeout = WAIT; old_connection.set_read_timeout(Some(timeout)).unwrap(); old_connection.set_write_timeout(Some(timeout)).unwrap(); let mut reader = BufReader::new(old_connection.try_clone().unwrap()); @@ -1166,7 +1190,7 @@ fn remote_edit_succeeds_once_when_app_restarts() { let listener = reusable_listener(addr); listener.set_nonblocking(true).unwrap(); let handle = std::thread::spawn(move || { - let timeout = std::time::Duration::from_secs(2); + let timeout = WAIT; let deadline = std::time::Instant::now() + timeout; let stream = loop { match listener.accept() { @@ -1213,15 +1237,15 @@ fn remote_does_not_send_on_a_connection_with_a_stray_line() { let addr = listener.local_addr().unwrap().to_string(); let mut remote = Remote::connect(&addr).unwrap(); let (mut stale, _) = listener.accept().unwrap(); - stale.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + stale.set_read_timeout(Some(WAIT)).unwrap(); writeln!(stale, "{}", json!({"ok": false, "error": "idle"})).unwrap(); let probe = remote.connection_probe(); - probe.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + probe.set_read_timeout(Some(WAIT)).unwrap(); assert_eq!(probe.peek(&mut [0]).unwrap(), 1, "the stray line arrived"); drop(probe); let h = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); - stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + stream.set_read_timeout(Some(WAIT)).unwrap(); let mut out = stream.try_clone().unwrap(); let mut line = String::new(); BufReader::new(stream).read_line(&mut line).unwrap(); @@ -1237,24 +1261,40 @@ fn remote_does_not_send_on_a_connection_with_a_stray_line() { #[test] fn remote_call_reports_nothing_sent_when_reconnect_is_refused() { - let listener = reusable_listener(std::net::SocketAddr::from(([127, 0, 0, 1], 0))); - let addr = listener.local_addr().unwrap(); - let mut remote = Remote::connect(&addr.to_string()).unwrap(); - let (old_connection, _) = listener.accept().unwrap(); - drop(old_connection); - drop(listener); - wait_for_remote_eof(&remote); - let held = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); - held.set_reuse_address(true).unwrap(); - held.bind(&addr.into()).unwrap(); + // A remote whose connection the app closed, and the app's port bound but not listening. + let closed_remote = || { + let listener = reusable_listener(std::net::SocketAddr::from(([127, 0, 0, 1], 0))); + let addr = listener.local_addr().unwrap(); + let remote = Remote::connect(&addr.to_string()).unwrap(); + let (old_connection, _) = listener.accept().unwrap(); + drop(old_connection); + drop(listener); + wait_for_remote_eof(&remote); + let held = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap(); + held.set_reuse_address(true).unwrap(); + held.bind(&addr.into()).unwrap(); + (remote, held) + }; - let start = std::time::Instant::now(); - let error = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 20, "height": 20}})).unwrap_err(); + // Each run finds the old connection closed and connects again; only the call is timed. + let mut errors = Vec::new(); + let mut took = Vec::new(); + while took.len() < 5 { + let (mut remote, _held) = closed_remote(); + let start = std::time::Instant::now(); + errors.push(remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 20, "height": 20}})).unwrap_err()); + took.push(start.elapsed()); + if took.last() < Some(&FAILED_CONNECT) { + break; + } + } - assert!(start.elapsed() < std::time::Duration::from_secs(2)); - assert!(error.contains("not reachable"), "{error}"); - assert!(error.contains("nothing was sent"), "{error}"); - assert!(!error.contains("operation may have completed"), "{error}"); + assert!(took.last() < Some(&FAILED_CONNECT), "took {took:?}"); + for error in errors { + assert!(error.contains("not reachable"), "{error}"); + assert!(error.contains("nothing was sent"), "{error}"); + assert!(!error.contains("operation may have completed"), "{error}"); + } } #[test] diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 75a2017535..94788f8d1c 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -314,6 +314,55 @@ pub fn smooth(path: &PathData, amount: f64) -> PathData { out } +/// The Smooth slider's level Auto-Smooth picks, halfway between minimum and maximum smoothing. +/// VectorCraft's own rule: the help page says only that Auto-Smooth smooths the path automatically +/// (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html). +/// [`smooth_level`] already scales with each path's size, so one level suits every path. +pub const SMOOTH_AUTO_LEVEL: f64 = 50.0; + +/// Object › Path › Smooth at slider level `level`, from 0 (minimum smoothing: the path stays within +/// 0.1 % of its longer side of where it was) to 100 (maximum). The help page names only the two +/// ends; the rest is VectorCraft's own rule. Each subpath is refitted with curves as Simplify does, +/// but keeping a corner only where the path turns back on itself (so jags and kinks become curves), +/// within a tolerance from 0.1 % of the path's longer side at level 0 to 2.5 % at level 100. A refit +/// never has more anchor points than the subpath had: a shape whose corners the tolerance can't +/// round, such as a rectangle, stays as it is. Closed subpaths stay closed, with no kink left where +/// they start, and open ones keep their end points. A level outside 0..=100 counts as the nearer end, and one that is not a number as +/// [`SMOOTH_AUTO_LEVEL`]. +pub fn smooth_level(path: &PathData, level: f64) -> PathData { + let level = if level.is_finite() { level.clamp(0.0, 100.0) } else { SMOOTH_AUTO_LEVEL }; + let size = path.bounds().map_or(0.0, |b| b.width().max(b.height())); + if !size.is_finite() { + return path.clone(); + } + let opts = SimplifyOptions { tolerance: size * (0.001 + 0.024 * level / 100.0), corner_angle_deg: SMOOTH_CORNER_DEG, straight_lines: false }; + let smoothed = |sp: &SubPath| { + let Some(mut fit) = simplify_subpath(sp, &opts).filter(|f| f != sp) else { return sp.clone() }; + smooth_seam(&mut fit); + fit + }; + PathData::new(path.subpaths.iter().map(smoothed).collect()) +} + +/// [`smooth_level`] keeps a corner only where the path turns by more than this (turns back). +const SMOOTH_CORNER_DEG: f64 = 179.0; + +/// A closed subpath's refit leaves its start anchor with the tangents of the original segments on +/// either side of it, a kink when the path had one there: turn it into a smooth anchor with the same +/// handle lengths, unless the path turns back there. +fn smooth_seam(sp: &mut SubPath) { + if !sp.closed { + return; + } + let Some(a) = sp.anchors.first_mut() else { return }; + let (Some(i), Some(o)) = (crate::fit::unit(a.p - a.h_in), crate::fit::unit(a.h_out - a.p)) else { return }; + if i.dot(o) < SMOOTH_CORNER_DEG.to_radians().cos() { + return; + } + let Some(dir) = crate::fit::unit(i + o) else { return }; + *a = Anchor::with_handles(a.p, a.p - dir * a.h_in.distance(a.p), a.p + dir * a.h_out.distance(a.p)); +} + /// Remove anchors that can be deleted without changing the shape by more than `tolerance` /// (collinear corner points, duplicate points, and curve joints that one cubic can replace). pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index ed53ff8332..4a656f98fd 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -20,8 +20,8 @@ pub use boolean::{ try_unite_all_with, unite_all, unite_all_with, }; pub use edit::{ - AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, - remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid, + AverageAxis, SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, + remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, split_into_grid, }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions}; diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 3d4d9d7615..9feaa79a8b 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -1,7 +1,7 @@ use std::f64::consts::PI; use kurbo::{ParamCurve, Point, Rect, Shape as _}; -use vectorcraft_geom::{FillRule, PathData, SubPath}; +use vectorcraft_geom::{AnchorKind, FillRule, PathData, SubPath}; use vectorcraft_pathops::*; const NZ: FillRule = FillRule::NonZero; @@ -430,6 +430,21 @@ fn smooth_rounds_corners() { assert_eq!(same, rect(0.0, 0.0, 10.0, 10.0)); } +/// Object › Path › Smooth: a rectangle, whose corners no curve rounds within the tolerance, stays as +/// it is even at maximum smoothing; a polygon of 16 sides becomes a closed curve of fewer anchors once the tolerance +/// passes its 0.96 pt sagitta, smooth all round. +#[test] +fn smooth_level_keeps_corners_it_cannot_round_and_curves_polygons() { + let r = rect(0.0, 0.0, 100.0, 50.0); + assert_eq!(smooth_level(&r, 100.0), r); + let pts: Vec = (0..16).map(|i| Point::new(50.0 * (f64::from(i) * PI / 8.0).cos(), 50.0 * (f64::from(i) * PI / 8.0).sin())).collect(); + let polygon = PathData::single(SubPath::polyline(&pts, true)); + let s = smooth_level(&polygon, SMOOTH_AUTO_LEVEL); + assert!(s.subpaths.len() == 1 && s.subpaths[0].closed && s.anchor_count() < 8, "{}", s.anchor_count()); + assert!(s.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth), "no kink, its start included: {s:?}"); + assert_eq!(smooth_level(&polygon, 0.0), polygon, "0.1 pt is less than the sagitta"); +} + #[test] fn remove_redundant_collinear_and_curve() { let p = PathData::single(SubPath::polyline( diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index fbbf6c71c5..4b6560a858 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -4,17 +4,21 @@ use super::{BlendMode, Exporter, Image, NodeKind, Rect, Size, Surface}; impl Exporter<'_> { /// Image `im` placed `size` points wide and high, resampled and compressed as the Compression /// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images); - /// cached by key (and size when images are resampled). `None`, with [`UNDECODED`] among the - /// warnings, for an image that is missing, can't be decoded or is in [`Self::left_out`]. - fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64)) -> Option { + /// cached by key (and size when images are resampled). With `interpolate` (and the export's + /// own setting), viewers may smooth it. `None`, with [`UNDECODED`] among the warnings, for an + /// image that is missing, can't be decoded or is in [`Self::left_out`]. + fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64), interpolate: bool) -> Option { let key = im.key.as_str(); let c = self.compression; let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); - let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; + let interpolate = interpolate && self.interpolate; + let mut cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; + if !interpolate && self.interpolate { + cache.push_str("\u{0}pixelated"); + } if let Some(i) = self.images.get(&cache) { return i.clone(); } - let interpolate = self.interpolate; let doc = self.doc; let out = self.out.clone(); let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { @@ -43,8 +47,8 @@ impl Exporter<'_> { /// on the image's own pixel grid (an image's soft mask as it opens): the alpha is written as the /// image's soft mask, as an RGBA image's is, rather than as a luminosity soft mask over it, /// through which CoreGraphics draws CMYK images on black boxes (#2159). `None` for other - /// images and masks; cached like [`Self::load_image`]. - pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask) -> Option { + /// images and masks; cached like [`Self::load_image`], `interpolate` as there. + pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask, interpolate: bool) -> Option { let art = &m.art; let NodeKind::Image(mi) = &art.kind else { return None }; let plain = @@ -53,14 +57,18 @@ impl Exporter<'_> { return None; } let size = placed_size(im); - let cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); + let interpolate = interpolate && self.interpolate; + let mut cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); + if !interpolate && self.interpolate { + cache.push_str("\u{0}pixelated"); + } if let Some(i) = self.images.get(&cache) { return i.clone(); } let doc = self.doc; let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; - let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha)); + let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha)); if let (Some(w), Some(_)) = (r.warning, &r.image) { self.warn(w); } @@ -70,13 +78,14 @@ impl Exporter<'_> { img } - /// Draw image `im`: `img` when given (its pixels already written), else as it is loaded. - pub(super) fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option) { + /// Draw image `im`: `img` when given (its pixels already written), else as it is loaded; with + /// `pixelated` ([`vectorcraft_doc::NodeExtras::pixelated`]) viewers are asked not to smooth it. + pub(super) fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option, pixelated: bool) { // An image placed or sized beyond [`MAX_COORD`] is left out. if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { return; } - let Some(img) = img.or_else(|| self.load_image(im, placed_size(im))) else { return }; + let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else { return }; let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index b4e64c58f5..732ac9f808 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -137,7 +137,7 @@ impl Exporter<'_> { } let mask = n.extras().mask.as_deref().filter(|m| !m.disabled); let alpha = match (&n.kind, mask) { - (NodeKind::Image(im), Some(m)) => self.masked_image(im, m), + (NodeKind::Image(im), Some(m)) => self.masked_image(im, m, !n.extras().pixelated), _ => None, }; if let Some(m) = mask @@ -182,7 +182,7 @@ impl Exporter<'_> { self.shape(s, n, &bp, *rule, page); } NodeKind::Text(t) => self.text(s, n, t, page), - NodeKind::Image(im) => self.image(s, im, alpha), + NodeKind::Image(im) => self.image(s, im, alpha, n.extras().pixelated), NodeKind::SymbolInstance { symbol, xf } => { // Within the objects a frame may draw of symbol instances, as on screen: nested // instances multiply. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index aa0acc36f2..3289545cd3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -617,6 +617,31 @@ fn image_roundtrip() { assert_eq!(decoded.get_pixel(3, 0).0, [0, 0, 0, 255]); } +/// Images are written with /Interpolate true, so viewers may smooth them; a pixelated image (an +/// envelope's with Anti-Alias off) is written without it. +#[test] +fn a_pixelated_image_asks_viewers_not_to_smooth_it() { + for pixelated in [false, true] { + let mut d = doc(200.0, 200.0); + d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png())); + let im = ImageObject { + key: "img1".into(), + width: 4, + height: 2, + xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(NodeId(0), NodeKind::Image(Box::new(im))); + n.extras_mut().pixelated = pixelated; + add(&mut d, n); + let pdf = uncompressed(&d); + assert_eq!(pdf.matches("/Width 4").count(), 1, "the image is written"); + assert_eq!(pdf.matches("/Interpolate").count(), usize::from(!pixelated), "pixelated: {pixelated}"); + } +} + /// A 4 × 2 red image encoded as `format`, cut short `keep` bytes past the first `tag` in it: its /// header reads, and its pixel data can't be decoded. fn cut_image(format: image::ImageFormat, tag: &[u8], keep: usize) -> Vec { diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index e7c08f5aa5..8dc694e484 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -439,6 +439,38 @@ fn a_cmyk_image_masked_by_a_grey_image_of_its_grid_writes_it_as_its_smask() { assert!(String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "a luminosity soft mask"); } +/// A pixelated CMYK image (an envelope's with Anti-Alias off) masked by a grey image of its grid +/// is written without /Interpolate, as one without a mask is. +#[test] +fn a_pixelated_cmyk_image_with_its_grid_mask_asks_viewers_not_to_smooth_it() { + for pixelated in [false, true] { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, flat([0u8, 102, 255, 0], 4)).unwrap()).unwrap()); + d.images.insert("alpha".into(), ImageBlob::new("image/png", png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()))); + let image = |key: &str| { + let im = ImageObject { + key: key.into(), + width: 2, + height: 2, + xf: Affine::scale(20.0), + link: None, + placement: Default::default(), + pixels: None, + }; + NodeKind::Image(Box::new(im)) + }; + let mask = vectorcraft_doc::OpacityMask::new(Node::new(d.alloc_id(), image("alpha")), true); + let mut n = Node::new(d.alloc_id(), image("img")).with_mask(Some(Box::new(mask))); + n.extras_mut().pixelated = pixelated; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export_with_report(&d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap().bytes; + let text = String::from_utf8_lossy(&out); + assert!(!text.contains("/S/Luminosity"), "the mask is the image's /SMask"); + assert_eq!(text.contains("/Interpolate"), !pixelated, "pixelated: {pixelated}"); + } +} + /// #2950: Press Quality converts to the document's profile with its numbers kept, so in a CMYK /// document an RGB image comes out CMYK, as the press needs; with no conversion (the default /// preset) it stays RGB, as before. diff --git a/crates/render/src/blobs.rs b/crates/render/src/blobs.rs index 2cecb13246..82de7e71d3 100644 --- a/crates/render/src/blobs.rs +++ b/crates/render/src/blobs.rs @@ -1,4 +1,5 @@ use super::{Affine, Arc, BezPath, Ink, Node, Pixmap, Shape, images, ink, paint}; +use vectorcraft_doc::{AppearanceItem, NodeKind}; /// `key`'s pixels in the shared image store; `touch` marks them used. pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option> { @@ -90,12 +91,64 @@ pub(crate) fn plain_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: & if !(n.visible && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty()) { return false; } + inside_frame(im, region, 1.0) +} + +/// Is `region` inside image `im`'s frame (within `slack` pixels)? +fn inside_frame(im: &vectorcraft_doc::ImageObject, region: &BezPath, slack: f64) -> bool { let det = im.xf.determinant(); if !(det.abs() > 1e-12 && det.is_finite()) { return false; } let b = im.xf.inverse().transform_rect_bbox(region.bounding_box()); - b.x0 >= -1.0 && b.y0 >= -1.0 && b.x1 <= im.width as f64 + 1.0 && b.y1 <= im.height as f64 + 1.0 + b.x0 >= -slack && b.y0 >= -slack && b.x1 <= im.width as f64 + slack && b.y1 <= im.height as f64 + slack +} + +/// Can image node `n` (`im`), whose opacity mask is one plain white path with Clip on (see +/// [`white_shape_mask`]) shaped like `region`, be painted straight into the region: the mask shows +/// the image fully inside the shape and hides it outside, as a clipping path does. The region must +/// lie inside the image's frame, since outside the frame the mask shows nothing. +pub(crate) fn shape_masked_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &BezPath) -> bool { + n.visible + && n.opacity >= 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && !n.isolate + && n.knockout == vectorcraft_doc::Knockout::Neutral + && !n.knockout_shape + && n.appearance.items.is_empty() + && n.appearance.effects.is_empty() + && inside_frame(im, region, 1e-6) +} + +/// Is opacity mask `m` one path filled with opaque white and nothing else, with Clip on and +/// Invert off (each piece of an envelope's distorted image with Preserve Shape Using +/// Transparency)? +pub(crate) fn white_shape_mask(m: &vectorcraft_doc::OpacityMask) -> bool { + use vectorcraft_color::{BlendMode, Color, Paint}; + let a = &m.art; + let shows = |p: &Paint| !matches!(p, Paint::None); + let white = |p: &Paint| matches!(p, Paint::Solid { color, tint, .. } if *color == Color::WHITE && *tint >= 1.0); + let mut fills = 0; + for i in &a.appearance.items { + match i { + AppearanceItem::Fill(f) if f.visible && shows(&f.paint) => { + if !(white(&f.paint) && f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty()) { + return false; + } + fills += 1; + } + AppearanceItem::Stroke(s) if s.visible && shows(&s.paint) => return false, + _ => {} + } + } + m.clip + && !m.invert + && !m.disabled + && fills == 1 + && a.visible + && a.has_default_transparency() + && a.appearance.effects.is_empty() + && matches!(a.kind, NodeKind::Path { guide: false, .. }) } /// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask diff --git a/crates/render/src/dither.rs b/crates/render/src/dither.rs index 9d214616b2..0dd887089b 100644 --- a/crates/render/src/dither.rs +++ b/crates/render/src/dither.rs @@ -93,6 +93,9 @@ fn window(xf: Affine, w: u16, h: u16, b: Rect, cols: u16, rows: u16) -> Option<[ /// premultiplied pixmap, colours as `ink` paints them. pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: [Range; 2], ink: Ink) -> Pixmap { let (w, h) = (win[0].len() as u16, win[1].len() as u16); + let adjusted = ink.gradient(&g.gradient).map(|gradient| GradientPaint { gradient, ..g.clone() }); + let ink = if adjusted.is_some() { ink.base() } else { ink }; + let g = adjusted.as_ref().unwrap_or(g); let data = g .pixels_within(b, b, cols, rows, win, true, &|c| ink.rgb(c)) .map(|[r, gr, bl, a]| { diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index 2191bc06ad..92f288643c 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -95,6 +95,10 @@ pub(crate) static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sy pub struct FrameStats { pub drawn: usize, pub culled: usize, + /// How many times the frame's symbol boxes measured symbol art ([`SymbolBoxes::art_measured`]): + /// the art boxes of symbols and how far the effects in their art paint past it. Pattern tiles + /// and placed documents measure with symbol boxes of their own, which are not counted. + pub symbol_art: usize, /// The drop shadows, outer glows and filtered contents the frame drew from rasters made before /// their object moved, or made over such rasters ([`RenderOptions::interacting`]), and those it /// drew at a drag's lower resolution ([`Self::previewed`]). diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index e73e708dde..900e435bc8 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -94,11 +94,11 @@ impl Renderer { let whole = fits && k >= 1.0; let in_view = (!whole).then(|| ViewCrop::of(f.view, room)); let stamp = self.stamp; - let channels = match f.ink { + let channels = match f.ink.base() { Ink::Cmy => effects::pixel::Channels::CmyPlane, Ink::K => effects::pixel::Channels::KPlane, Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, - Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, + _ => effects::pixel::Channels::Rgb, }; // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). let ppi = doc_raster_ppi(f.doc); @@ -367,11 +367,12 @@ impl Renderer { /// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and, /// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and /// brush library it reads; for images and placed documents their pixels and files, the - /// layer's Dim Images and the options they are drawn with; for type the fonts, whether - /// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution - /// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and - /// when it shows symbol or pattern art and an object in the document's symbol or pattern art - /// has one. + /// layer's Dim Images and the options they are drawn with (with + /// [`crate::RenderOptions::kept_images_only`], also which pixels and undecodable notes the + /// image store holds); for type the fonts, whether overprinting shows + /// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the + /// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or + /// pattern art and an object in the document's symbol or pattern art has one. pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs { let doc = self.doc_inputs(f.doc); let mut h = DefaultHasher::new(); @@ -395,6 +396,9 @@ impl Renderer { (doc.images, self.dim_images.map(f32::to_bits), o.smooth_images, o.image_mips, o.trace_views, o.progressive_placed).hash(&mut h); o.trace_source.as_deref().hash(&mut h); o.mark_undecoded_images.hash(&mut h); + // With only kept pixels drawn, a raster is made again once the store holds more pixels + // or notes an image as undecodable. + o.kept_images_only.then(|| crate::images::store().added()).hash(&mut h); } if reads.brushes { self.brushes.library_key(f.doc, self.stamp).hash(&mut h); diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index 6648b321d9..ca6a2fd375 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -11,21 +11,11 @@ pub(super) fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) - }) } -/// The layer filter of a drop shadow with Darkness: the object's own pixels blurred. +/// The layer filter of a drop shadow with Darkness: source paints are adjusted before blur. pub(super) fn shade_filter(blur: f64) -> Filter { Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: (blur / 2.0).max(0.0) as f32, edge_mode: EdgeMode::None }) } -/// How much darker (0..1) a drop shadow with Darkness `darkness` makes the values `ink` paints: -/// black added leaves the cyan, magenta and yellow plane of a CMYK document as it is and scales -/// the complemented black plane, as it scales screen colors, by 1 − `darkness`. -pub(super) fn shade(ink: Ink, darkness: f32) -> f32 { - match ink { - Ink::Cmy => 0.0, - Ink::K | Ink::Display | Ink::RichBlack => darkness, - } -} - /// What a shadow or glow raster is made from ([`Made::pixels`]): the silhouette's alpha, or its /// premultiplied RGBA when the shadow takes the object's own colors (Darkness). pub(super) fn silhouette(pm: &vello_cpu::Pixmap, colored: bool) -> Vec { diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 7924f51a6b..6e6fa1cabd 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -2,7 +2,7 @@ use super::bounds::{cull_bounds, effected_path}; use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, Kept, Made, Reads, Shadow, ShadowEntry}; use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; use super::raster::{ - DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, shade, + DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, shade_filter, shadow_filter, silhouette, }; use super::{ @@ -115,7 +115,8 @@ impl Renderer { let view = Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view; // Art is culled against the raster's own area: members of a group outside the view cast their shadows in it. let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); - let crop = Frame { mt: false, view, visible, ..*f }; + let ink = darkness.map_or(f.ink, |d| f.ink.darkened(d)); + let crop = Frame { mt: false, view, visible, ink, ..*f }; let before = self.stats.moved; self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); // A silhouette drawn over moved rasters makes a moved shadow. @@ -128,9 +129,8 @@ impl Renderer { Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; let (wu, hu) = (w as usize, h as usize); let sigma = blur / 2.0 / f.px / scale; - if let Some(darkness) = darkness { + if darkness.is_some() { // The object's own colors, blurred, with the black Darkness adds (premultiplied). - let keep = 1.0 - shade(f.ink, darkness); let mut planes: [Vec; 4] = [ pm.data().iter().map(|p| p.r as f32).collect(), pm.data().iter().map(|p| p.g as f32).collect(), @@ -143,7 +143,7 @@ impl Renderer { let [r, g, b, a] = &planes; for (k, px) in pm.data_mut().iter_mut().enumerate() { let al = (a[k] * opacity).clamp(0.0, 255.0).round(); - let value = |plane: &Vec| (plane[k] * opacity * keep).round().clamp(0.0, al) as u8; + let value = |plane: &Vec| (plane[k] * opacity).round().clamp(0.0, al) as u8; *px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 }; } } else { @@ -219,15 +219,9 @@ impl Renderer { self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { c.set_transform(fr.view); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); - paint(r, c, &shifted(fr, dx, dy)); - // Darkness: black over the object's own colors, keeping their alpha. - if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) { - c.set_transform(fr.view); - c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop)); - c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black])); - c.fill_rect(&reach); - c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); - } + let ink = darkness.map_or(fr.ink, |d| fr.ink.darkened(d)); + let source = Frame { ink, ..*fr }; + paint(r, c, &shifted(&source, dx, dy)); c.pop_layer(); }); } @@ -278,7 +272,11 @@ impl Renderer { let view = Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; // Culled against the raster's area, as `shadow_raster` draws the silhouette. let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); - let crop = Frame { mt: false, view, visible, ..*f }; + let ink = match fx { + RasterFx::DropShadow { darkness: Some(d), .. } => f.ink.darkened(*d), + _ => f.ink, + }; + let crop = Frame { mt: false, view, visible, ink, ..*f }; self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); @@ -696,3 +694,44 @@ impl Renderer { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::RenderOptions; + use vectorcraft_color::Color; + use vectorcraft_doc::{Document, color::BlendMode}; + + #[test] + fn uncached_darkness_shadow_subtracts_before_separate_opacity() { + let doc = Document::new(100.0, 100.0); + let symbols = doc.symbol_boxes().quick(); + let opts = RenderOptions::default(); + let frame = Frame { + mt: false, + doc: &doc, + symbols: &symbols, + view: Affine::IDENTITY, + visible: Rect::new(0.0, 0.0, 100.0, 100.0), + px: 1.0, + opts: &opts, + ink: Ink::Display, + }; + let mut ctx = frame.offscreen_context(100, 100); + let mut r = Renderer::new(); + let fx = + RasterFx::DropShadow { mode: BlendMode::Normal, opacity: 0.5, dx: 40.0, dy: 0.0, blur: 0.0, color: Color::BLACK, darkness: Some(0.25) }; + let source = Rect::new(10.0, 10.0, 30.0, 30.0); + r.draw_shadow_filter(&mut ctx, &frame, &fx, source, &mut |_, c, f| { + c.set_transform(f.view); + c.set_paint(f.ink.color(&Color::rgb(0.8, 0.4, 0.2), 1.0)); + c.fill_rect(&source); + }); + ctx.flush(); + let mut pm = vello_cpu::Pixmap::new(100, 100); + ctx.render(&mut pm, &mut r.resources); + let px = pm.data()[20 * 100 + 60]; + assert!(px.r.abs_diff(70) <= 1 && px.g.abs_diff(19) <= 1 && px.b == 0 && px.a.abs_diff(128) <= 1, "{px:?}"); + assert!(r.shadows.is_empty()); + } +} diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 8c2cd0a3c1..e7a46af247 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -4,7 +4,7 @@ use vectorcraft_doc::{Appearance, Document, Effect, Node}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin}; -use crate::tests_fontchange::fonts_steady; +use crate::tests_fontchange::{fonts_steady, with_fonts_unchanged}; use crate::{RenderOptions, Rendered, Renderer}; /// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however @@ -278,7 +278,7 @@ fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkne d } -/// Darkness: the shadow is the object's own color, each channel times 1 − darkness in an RGB +/// Darkness: the shadow is the object's own color, with darkness subtracted from each RGB channel in an RGB /// document (the reference app's guide gives the object's color at 0%, /// page 428), in the cached /// raster and, at 6000 %, in the filter layer that a raster too large to cache is drawn with. @@ -298,9 +298,8 @@ fn drop_shadow_darkness_darkens_the_objects_own_color() { } } -/// In a CMYK document Darkness adds black ink and keeps the object's cyan, magenta and yellow -/// (same page of the guide: at 100% an object with colors other than black gets a shadow of those colors under -/// full black, and a black object's shadow is 100% black). +/// Intermediate Darkness adds black ink while preserving C/M/Y. The top endpoint becomes +/// gray black before ink conversion; its output below uses this application's gray-color model. #[test] fn drop_shadow_darkness_adds_black_in_a_cmyk_document() { let cmyk = vectorcraft_doc::ColorMode::Cmyk; @@ -312,15 +311,31 @@ fn drop_shadow_darkness_adds_black_in_a_cmyk_document() { let red = Color::cmyk(0.0, 1.0, 1.0, 0.0); for (darkness, k) in [(0.0, 0), (50.0, 128), (100.0, 255)] { let got = inks(&shaded_doc(cmyk, red, "darkness", darkness)); - assert!(got[0] == 0 && got[1] == 255 && got[2] == 255 && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}"); + let my = if darkness == 100.0 { 0 } else { 255 }; + assert!(got[0] == 0 && got[1] == my && got[2] == my && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}"); } - // Half black ink, darkened by half: K 50 + 50 × 50% = K 75. + // Half black ink plus 50 percentage points clamps at full black. let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 0.5), "darkness", 50.0)); - assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(191) <= 1, "{got:?}"); + assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(255) <= 1, "{got:?}"); let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 1.0), "darkness", 100.0)); assert_eq!(got, [0, 0, 0, 255]); } +/// The top endpoint is gray black before document/profile conversion, independently of source model. +#[test] +fn darkness_black_endpoint_and_gray_source_follow_their_stored_models() { + for mode in [vectorcraft_doc::ColorMode::Rgb, vectorcraft_doc::ColorMode::Cmyk] { + for source in [Color::rgb(0.8, 0.4, 0.2), Color::cmyk(0.1, 0.3, 0.4, 0.2), Color::gray(0.2)] { + let got = render(&shaded_doc(mode, source, "darkness", 100.0)).pixel(66, 66); + let want = render(&shaded_doc(mode, Color::gray(1.0), "darkness", 0.0)).pixel(66, 66); + assert_eq!(got, want, "{mode:?}, {source:?}"); + } + let got = render(&shaded_doc(mode, Color::gray(0.2), "darkness", 25.0)).pixel(66, 66); + let want = render(&shaded_doc(mode, Color::gray(0.45), "darkness", 0.0)).pixel(66, 66); + assert_eq!(got, want, "gray intermediate in {mode:?}"); + } +} + /// Color stays the default: a shadow without the new keys, and one with Color chosen and a /// Darkness value, draw the same pixels, which a shadow with Darkness does not. #[test] @@ -758,17 +773,26 @@ fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting() let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); let live = RenderOptions { interacting: true, ..still.clone() }; let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still); - let mut r = Renderer::new(); - let first = r.render(&base, 200, 200, view, &still); + // Content that shows a symbol reads the fonts, so the frames come from a run in which the + // fonts stayed the same. + let moves = [(3.0, 0.0), (5.5, 2.0)]; + let (first, frames) = with_fonts_unchanged(|| { + let mut r = Renderer::new(); + let first = r.render(&base, 200, 200, view, &still); + let frames = moves.map(|(dx, dy)| { + let d = moved(dx, dy); + let frame = r.render(&d, 200, 200, view, &live).pixels; + (r.stats.moved, frame == fresh(&d).pixels) + }); + (first, frames) + }); assert!(first.pixels == fresh(&base).pixels); // The shadow, 4 pt right of the art's right edge at x 50, at (104, 70) in pixels. assert!(lum(first.pixel(104, 70)) < WHITE, "{:?}", first.pixel(104, 70)); // Moves on whole pixels (2 a point): the moved shadow lies where a fresh renderer draws it. - for (dx, dy) in [(3.0, 0.0), (5.5, 2.0)] { - let d = moved(dx, dy); - let frame = r.render(&d, 200, 200, view, &live).pixels; - assert_eq!(r.stats.moved, 1, "the shadow moved by ({dx}, {dy})"); - assert!(frame == fresh(&d).pixels, "moved by ({dx}, {dy})"); + for ((dx, dy), (count, same)) in moves.into_iter().zip(frames) { + assert_eq!(count, 1, "the shadow moved by ({dx}, {dy})"); + assert!(same, "moved by ({dx}, {dy})"); } } @@ -783,12 +807,15 @@ fn moved_rasters_need_the_same_content_and_effects() { let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); let live = RenderOptions { interacting: true, ..still.clone() }; // `before` drawn, then `after` while interacting: the rasters drawn moved, and whether the - // frame is what a fresh renderer draws. + // frame is what a fresh renderer draws. Content painted with a pattern reads the fonts, so + // the counts come from a run in which the fonts stayed the same. let drag = |before: &Document, after: &Document| { - let mut r = Renderer::new(); - let _ = r.render(before, 200, 200, view, &still); - let frame = r.render(after, 200, 200, view, &live).pixels; - (r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels) + with_fonts_unchanged(|| { + let mut r = Renderer::new(); + let _ = r.render(before, 200, 200, view, &still); + let frame = r.render(after, 200, 200, view, &live).pixels; + (r.stats.moved, frame == Renderer::new().render(after, 200, 200, view, &still).pixels) + }) }; // `d`'s object moved 5, 3 (10, 6 pixels), then changed by `edit`. let moved = |d: &Document, patterns: bool, edit: &dyn Fn(&mut Node)| { diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 271278b48b..d70b517d47 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -117,6 +117,9 @@ pub(crate) struct ImageStore { budget: usize, /// Request counter: the least recently used entries go first. tick: u64, + /// How many entries and undecodable notes have been added: it changes whenever the store + /// holds pixels, or notes an image as undecodable, that it did not before. + added: u64, /// [`BlobId`]s per blob byte buffer, by address (the `Weak` keeps it from being reused). ids: HashMap>, BlobId)>, /// The content keys of images whose pixels can't be decoded ([`Undecoded::Unreadable`]), so @@ -132,7 +135,12 @@ fn pixmap_bytes(pm: &Pixmap) -> usize { impl ImageStore { pub(crate) fn new(budget: usize) -> Self { - Self { entries: HashMap::new(), bytes: 0, budget, tick: 0, ids: HashMap::new(), undecodable: HashMap::new() } + Self { entries: HashMap::new(), bytes: 0, budget, tick: 0, added: 0, ids: HashMap::new(), undecodable: HashMap::new() } + } + + /// How many entries and undecodable notes have been added to the store. + pub(crate) fn added(&self) -> u64 { + self.added } /// Whether the image with content key `content` is known not to decode. @@ -146,7 +154,10 @@ impl ImageStore { if self.undecodable.len() >= 1024 { self.undecodable.clear(); } - self.undecodable.entry(content).or_insert(false); + if !self.undecodable.contains_key(&content) { + self.added += 1; + self.undecodable.insert(content, false); + } } /// The pixels of `key`, marked as just used. @@ -181,6 +192,7 @@ impl ImageStore { return e.pm.clone(); } self.bytes += pixmap_bytes(&pm); + self.added += 1; self.entries.insert(key, Entry { pm: pm.clone(), used: tick }); self.evict(); pm diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 1dd894b4a0..69d2657d20 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -18,9 +18,9 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use vectorcraft_color::Color; use vectorcraft_color::blend::{cmyk_planes, planes_cmyk}; use vectorcraft_color::cms::{self, Cms, CmykLut, ProofLut, lab}; +use vectorcraft_color::{Color, Gradient}; use vectorcraft_doc::cmyk::Inks; use vectorcraft_doc::{ColorMode, Document, ImageBlob}; use vello_cpu::Pixmap; @@ -42,11 +42,95 @@ pub(crate) enum Ink { K, /// Screen colours, with pure CMYK black as RGB black ([`RenderOptions::rich_black`]). RichBlack, + /// Source-color adjustment before the display/ink conversion. Float bits keep cache keys exact. + Darkened(InkSpace, u32, bool), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum InkSpace { + Display, + Cmy, + K, + RichBlack, } impl Ink { + pub(crate) fn base(self) -> Self { + match self { + Self::Darkened(InkSpace::Display, _, _) => Self::Display, + Self::Darkened(InkSpace::Cmy, _, _) => Self::Cmy, + Self::Darkened(InkSpace::K, _, _) => Self::K, + Self::Darkened(InkSpace::RichBlack, _, _) => Self::RichBlack, + other => other, + } + } + + pub(crate) fn index(self) -> usize { + match self.base() { + Self::Cmy => 1, + Self::K => 2, + Self::RichBlack => 3, + _ => 0, + } + } + + fn darkness(self) -> Option { + match self { + Self::Darkened(_, bits, _) => Some(f32::from_bits(bits)), + _ => None, + } + } + + fn black_endpoint(self) -> bool { + matches!(self, Self::Darkened(_, _, true)) + } + + pub(crate) fn darkened(self, amount: f32) -> Self { + let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 }; + let black = self.black_endpoint() || amount >= 0.9999; + let amount = (self.darkness().unwrap_or(0.0) + amount).min(1.0); + if amount == 0.0 { + return self.base(); + } + let space = match self.base() { + Self::Cmy => InkSpace::Cmy, + Self::K => InkSpace::K, + Self::RichBlack => InkSpace::RichBlack, + _ => InkSpace::Display, + }; + Self::Darkened(space, amount.to_bits(), black) + } + + /// Fixed changes use the stored color's model, before profile conversion or interpolation. + fn darkened_color(c: &Color, amount: f32, black: bool) -> Color { + if black { + return Color::gray(1.0); + } + match *c { + Color::Cmyk { c, m, y, k } => Color::cmyk(c, m, y, (k + amount).min(1.0)), + Color::Gray { k } => Color::gray((k + amount).min(1.0)), + _ => { + let [r, g, b] = c.to_rgb(); + Color::rgb((r - amount).max(0.0), (g - amount).max(0.0), (b - amount).max(0.0)) + } + } + } + + /// Transform original stops before a gradient expands its midpoint/perceptual colors. + pub(crate) fn gradient(self, g: &Gradient) -> Option { + let amount = self.darkness()?; + let mut g = g.clone(); + for stop in &mut g.stops { + stop.color = Self::darkened_color(&stop.color, amount, self.black_endpoint()); + } + Some(g) + } + /// The three values painted for colour `c`. pub(crate) fn rgb(self, c: &Color) -> [f32; 3] { + if let Some(amount) = self.darkness() { + return self.base().rgb(&Self::darkened_color(c, amount, self.black_endpoint())); + } match (self, *c) { (Ink::RichBlack, Color::Cmyk { c, m, y, k }) if is_pure_black([c, m, y, k]) => [0.0; 3], (Ink::Display | Ink::RichBlack, _) => c.to_rgb(), @@ -67,7 +151,7 @@ impl Ink { } fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] { - match self { + match self.base() { Ink::Display | Ink::RichBlack => [r, g, b, a], _ => { let lab = lab::srgb_to_lab([r, g, b].map(|v| v as f32 / 255.0)); @@ -78,7 +162,7 @@ impl Ink { } fn plane(self, inks: [f32; 4]) -> [f32; 3] { - cmyk_planes(inks)[(self == Ink::K) as usize] + cmyk_planes(inks)[(self.base() == Ink::K) as usize] } } @@ -326,12 +410,68 @@ fn plane_key(key: &ImageKey, plane: Ink, generation: u64) -> ImageKey { /// The key [`ink_image`] gives image pixels `key` painted with `ink`. pub(crate) fn ink_key(key: &ImageKey, ink: Ink) -> ImageKey { - if matches!(ink, Ink::Display | Ink::RichBlack) { key.clone() } else { plane_key(key, ink, separation().1) } + if ink.darkness().is_some() { + let generation = if matches!(ink.base(), Ink::Display | Ink::RichBlack) { 0 } else { separation().1 }; + plane_key(key, ink, generation) + } else if matches!(ink, Ink::Display | Ink::RichBlack) { + key.clone() + } else { + plane_key(key, ink, separation().1) + } +} + +/// Color-adjusted image pixels, cached separately for each source adjustment and output plane. +fn darkened_image(key: &ImageKey, pm: &Arc, ink: Ink, amount: f32, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc) { + let want = ink_key(key, ink); + let hit = { + let mut s = images::store(); + if touch { s.get(&want) } else { s.peek(&want) } + }; + if let Some(p) = hit { + return (want, p); + } + let base = ink.base(); + let display = matches!(base, Ink::Display | Ink::RichBlack); + let raw_cmyk = (key.luts.0 != 0 && key.adjust.is_none() && !key.grey) + .then(|| blob.and_then(ImageBlob::cmyk)) + .flatten() + .filter(|i| (i.width, i.height) == (u32::from(pm.width()), u32::from(pm.height()))); + let sep = (!display).then(separation); + let shown = (display && raw_cmyk.is_some()).then(display_lut); + let mut out = Pixmap::new(pm.width(), pm.height()); + for (i, (o, p)) in out.data_mut().iter_mut().zip(pm.data()).enumerate() { + if p.a == 0 { + continue; + } + let rgb = if ink.black_endpoint() { + base.rgb(&Color::gray(1.0)) + } else if let Some(raw) = raw_cmyk.as_ref().and_then(|v| v.data.as_chunks::<4>().0.get(i)) { + let mut inks = raw.map(|v| f32::from(v) / 255.0); + inks[3] = (inks[3] + amount).min(1.0); + if base == Ink::RichBlack && is_pure_black(inks) { + [0.0; 3] + } else if let Some((lut, _)) = &shown { + lut.apply(inks) + } else { + base.plane(inks) + } + } else { + let rgb = [p.r, p.g, p.b].map(|v| (f32::from(v) / f32::from(p.a) - amount).clamp(0.0, 1.0)); + if let Some((luts, _)) = &sep { luts[(base == Ink::K) as usize].apply(rgb) } else { rgb } + }; + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * f32::from(p.a)).round() as u8); + *o = PremulRgba8 { r, g, b, a: p.a }; + } + let out = images::store().insert(want.clone(), Arc::new(out)); + (want, out) } /// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as /// painted with `ink`, cached with the images, and their key. pub(crate) fn ink_image(key: &ImageKey, pm: &Arc, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc) { + if let Some(amount) = ink.darkness() { + return darkened_image(key, pm, ink, amount, blob, touch); + } if matches!(ink, Ink::Display | Ink::RichBlack) { return (key.clone(), pm.clone()); } @@ -381,6 +521,23 @@ pub(crate) fn ink_image(key: &ImageKey, pm: &Arc, ink: Ink, blob: Option mod tests { use super::*; + #[test] + fn darkness_image_cache_keeps_amount_and_coverage() { + let key = + ImageKey { content: Arc::from("darkness-rgb-cache-regression"), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 }; + let pm = + Arc::new(Pixmap::from_parts(vec![PremulRgba8 { r: 204, g: 102, b: 51, a: 255 }, PremulRgba8 { r: 102, g: 51, b: 26, a: 128 }], 2, 1)); + let (k25, p25) = ink_image(&key, &pm, Ink::Display.darkened(0.25), None, false); + let (k50, p50) = ink_image(&key, &pm, Ink::Display.darkened(0.5), None, false); + assert_ne!(k25, k50); + assert_eq!((p25.data()[0].r, p25.data()[0].g, p25.data()[0].b), (140, 38, 0)); + assert_eq!((p25.data()[1].r, p25.data()[1].g, p25.data()[1].b, p25.data()[1].a), (70, 19, 0, 128)); + assert_eq!((p50.data()[0].r, p50.data()[0].g, p50.data()[0].b), (77, 0, 0)); + let (_, warm) = ink_image(&key, &pm, Ink::Display.darkened(0.25), None, false); + assert_eq!(p25.data(), warm.data()); + assert_eq!(pm.data()[0].r, 204, "source pixels remain unchanged"); + } + #[test] fn the_ink_table_matches_the_profile() { let cms = cms::active(); diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs index 036c85da02..595f9797b3 100644 --- a/crates/render/src/options.rs +++ b/crates/render/src/options.rs @@ -111,6 +111,14 @@ pub struct RenderOptions { /// Screen views: an image whose pixels can't be decoded draws as a light gray box crossed /// from corner to corner. Off, it draws nothing (exports, which report it instead). pub mark_undecoded_images: bool, + /// Screen views that must render quickly (the canvas's low-resolution fill): an image object + /// draws from pixels the shared store already holds for it: the mip + /// [`RenderOptions::image_mips`] would draw if the store holds it, else the nearest finer one, + /// else the nearest coarser one. An image object the store holds no pixels of draws as a + /// light gray box, not crossed, unless the store notes it as undecodable: then it draws as it + /// does with this option off ([`RenderOptions::mark_undecoded_images`]). No image object is decoded and + /// no mip of one is made. Off, image objects are decoded as they are drawn. + pub kept_images_only: bool, /// Screen views while an interaction is in progress (a drag, a dialog's preview): an object /// whose content only moved since an earlier frame draws its cached drop shadows, outer glows /// and filtered content from the rasters made then, at the nearest whole pixel, without @@ -207,6 +215,7 @@ impl Default for RenderOptions { supersample: false, image_mips: false, mark_undecoded_images: false, + kept_images_only: false, interacting: false, dragging: false, rich_black: false, diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 7b3b3ec72d..dcaffadbc0 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -20,7 +20,10 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds, f.ink), Paint::Gradient(g) => { let geom = g.resolve(bounds); - let stops: Vec = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, f.ink.color(c, *a)))).collect(); + let adjusted = f.ink.gradient(&g.gradient); + let gradient = adjusted.as_ref().unwrap_or(&g.gradient); + let ink = if adjusted.is_some() { f.ink.base() } else { f.ink }; + let stops: Vec = gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, ink.color(c, *a)))).collect(); if stops.is_empty() { return false; } diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 4602ae8ff7..f4ea1d0597 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -35,7 +35,7 @@ const MAX_TILE_PX: f64 = 2048.0; /// What a tile is kept under: the pattern's name and the bits of its raster scale /// ([`raster_scale`]). -type TileKey = (String, u64); +type TileKey = (String, u64, Ink); /// What a tile was drawn from besides its pattern's own art, for art that shows more than itself: /// the art of every pattern and symbol and the pixels of every image in the document, the @@ -241,12 +241,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let (pw, ph) = def.period(); let scale = raster_scale(s, (pw, ph)); let params = (def.tile, def.tile_type, def.overlap); - let key = (name.to_string(), scale.to_bits()); + let key = (name.to_string(), scale.to_bits(), ink); let cms = vectorcraft_color::cms::active(); let hit = CACHE.with(|c| { let mut c = c.borrow_mut(); c.check_plugins(); - let tiles = &mut c.tiles[ink as usize]; + let tiles = &mut c.tiles[ink.index()]; let t = tiles.tick(); tiles .get(&key, t) @@ -281,7 +281,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, return; } let mut c = c.borrow_mut(); - let tiles = &mut c.tiles[ink as usize]; + let tiles = &mut c.tiles[ink.index()]; let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed }; tiles.insert(key, entry, bytes, t); diff --git a/crates/render/src/pattern/tests.rs b/crates/render/src/pattern/tests.rs index 7a577d757a..76aad7fe6f 100644 --- a/crates/render/src/pattern/tests.rs +++ b/crates/render/src/pattern/tests.rs @@ -219,11 +219,11 @@ fn kept_tiles_stay_within_the_budget() { let d = doc_with_two_scales(0.25); let tile = |side: usize| size_of::() + side * side * 4; let budget = tile(80) + tile(40) + tile(10); - CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX)); + CACHE.with(|c| c.borrow_mut().tiles[Ink::Display.index()] = FrameCache::new(budget, usize::MAX)); let kept = || { CACHE.with(|c| { let c = c.borrow(); - let tiles = &c.tiles[Ink::Display as usize]; + let tiles = &c.tiles[Ink::Display.index()]; (tiles.len(), tiles.bytes()) }) }; diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 81fb0c5737..7d7b72d564 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -1,4 +1,4 @@ -use super::blobs::{blob_key, blob_pixmap, image_mip, lookup, mask_value, mip_level, plain_image}; +use super::blobs::{blob_key, blob_pixmap, image_mip, lookup, mask_value, mip_level, plain_image, shape_masked_image, white_shape_mask}; use super::clock::now; use super::frame::{ ClipEntry, ClipPaintEntry, Frame, FrameStats, GeomEntry, MAX_INLINE_DEPTH, MISSING_INLINE_LOGGED, PlacedEntry, PlacedKey, PtrMap, SliceEntry, @@ -121,6 +121,7 @@ impl Renderer { if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) { let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); self.end_frame(); + self.stats.symbol_art = symbols.art_measured(); self.stats.micros = now().saturating_sub(start); return Rendered { width: w as u32, height: h as u32, pixels }; } @@ -144,6 +145,7 @@ impl Renderer { if !inks { proof::post(&mut pixels, opts); } + self.stats.symbol_art = symbols.art_measured(); self.stats.micros = now().saturating_sub(start); Rendered { width: w as u32, height: h as u32, pixels } } diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs index 372b6103b9..480d3d6458 100644 --- a/crates/render/src/renderer/images.rs +++ b/crates/render/src/renderer/images.rs @@ -6,28 +6,54 @@ use crate::images; impl Renderer { pub(crate) fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) { - self.draw_image_in(ctx, f, im, None); + self.draw_image_in(ctx, f, im, None, false); } /// Draw an image, or with `area` (a region in the document inside the image's frame, see - /// [`plain_image`]) the image painted into that region. + /// [`plain_image`]) the image painted into that region; with `pixelated` + /// ([`vectorcraft_doc::NodeExtras::pixelated`]) without smoothing. pub(super) fn draw_image_in( &mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, area: Option<&(BezPath, FillRule)>, + pixelated: bool, ) { let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline // Mode its pixels in greyscale under the frame. let outline = f.opts.outline; // With mips the full pixels are only looked at (not marked used) until they're drawn, and - // a mip the store holds is drawn without them: they may have been dropped (#2820). - let mips = f.opts.image_mips; + // a mip the store holds is drawn without them: they may have been dropped (#2820). A + // pixelated image is drawn from its full pixels: a mip averages them. + let mips = f.opts.image_mips && !pixelated; let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64); + // With `kept_images_only`, an image still to be decoded: it draws as a light box, not as + // an undecoded image. + let mut pending = false; let shown = if outline && !f.doc.setup.outline_images { None + } else if f.opts.kept_images_only { + let kept = self.nearest_kept(f, &im.key, im.pixels.as_deref(), outline, unit); + pending = kept.is_none() && !self.noted_undecodable(f.doc, &im.key, im.pixels.as_deref()); + if pending { + // Not decoded: a light box where the image will be, as a placed document's + // bitmap shows while it is made (`placed_document`). + ctx.set_paint(f.ink.fixed([128, 128, 128, 40])); + match area { + Some((bp, rule)) => { + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(*rule)); + ctx.fill_path(bp); + } + None => { + ctx.set_transform(f.view * im.xf); + ctx.fill_rect(&rect); + } + } + } + kept } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() { Some(pm) } else { @@ -39,7 +65,7 @@ impl Renderer { if let Some(pm) = shown { let sx = im.width as f64 / pm.width().max(1) as f64; let sy = im.height as f64 / pm.height().max(1) as f64; - let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; + let quality = if f.opts.smooth_images && !pixelated { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; let sampler = peniko::ImageSampler { quality, ..Default::default() }; ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); match area { @@ -71,7 +97,7 @@ impl Renderer { } } } - } else if f.opts.mark_undecoded_images && (!outline || f.doc.setup.outline_images) { + } else if f.opts.mark_undecoded_images && !pending && (!outline || f.doc.setup.outline_images) { self.draw_undecoded_mark(ctx, f, im, area); } if outline { @@ -205,4 +231,29 @@ impl Renderer { } images::store().get(&images::ImageKey { mip: level, ..key }) } + + /// The pixels of image `key` (or `own`) that [`RenderOptions::kept_images_only`] draws through + /// `m`, in greyscale with `grey` (the outline view's): of the mips the store holds, the one + /// [`image_mip`] would draw, else the nearest finer one (the full pixels last), else the + /// nearest coarser one. `None` when the store holds none. Nothing is marked used. + fn nearest_kept(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option> { + let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?; + let key = if grey { key } else { ink::ink_key(&key, f.ink) }; + let level = mip_level(m, w, h); + let s = images::store(); + let at = |l: u8| s.peek(&images::ImageKey { mip: l, ..key.clone() }); + (0..=level).rev().find_map(at).or_else(|| (level + 1..=16).find_map(at)) + } + + /// Whether the shared store notes image `key` (or `own`), or the image an adjusted image is + /// made from, as one whose pixels can't be decoded. Nothing is decoded. + fn noted_undecodable(&self, doc: &Document, key: &str, own: Option<&vectorcraft_doc::ImageBlob>) -> bool { + match own.or_else(|| doc.images.get(key)) { + Some(blob) => { + let content = blob_key(blob).0.content; + images::store().is_undecodable(&content) + } + None => self.adjusts.get(key).is_some_and(|(src, ..)| self.noted_undecodable(doc, src, None)), + } + } } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 7e1fd1f118..7a82d96ee2 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -1,8 +1,8 @@ use super::{ Affine, Arc, BezPath, ClipEntry, ClipPaintEntry, Compose, Composite, Document, FillRule, Frame, GeomEntry, ISOLATION_DIM, Ink, MAX_GUIDE_DASHES, Node, NodeId, NodeKind, Rect, RenderContext, RenderOptions, Renderer, Shape, SymbolBoxes, TraceView, blend_mode, brush_fx, effects, fx, group, - holds_instance, holds_plugin_effect, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, single_plain_fill, - text_geom_snapped, + holds_instance, holds_plugin_effect, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, shape_masked_image, + single_plain_fill, text_geom_snapped, white_shape_mask, }; impl Renderer { @@ -142,6 +142,21 @@ impl Renderer { if self.skipped(f, a) { return; } + // An image masked by one white shape painted straight into the shape, as an image in a + // clip group is (envelopes with Preserve Shape Using Transparency cut distorted images into + // many such pieces). + if let Some(m) = a.extras().mask.as_deref() + && !f.opts.outline + && !self.is_shape(a) + && let NodeKind::Image(im) = &a.kind + && white_shape_mask(m) + && let Some(region) = self.clip_of(&m.art) + && shape_masked_image(a, im, ®ion.0) + { + self.draw_image_in(ctx, f, im, Some(®ion), a.extras().pixelated); + self.stats.drawn += 1; + return; + } if let Some(m) = a.extras().mask.as_deref() && !m.disabled && !f.opts.outline @@ -373,7 +388,7 @@ impl Renderer { && let NodeKind::Image(im) = &image.kind && plain_image(image, im, ®ion.0) { - self.draw_image_in(ctx, f, im, Some(®ion)); + self.draw_image_in(ctx, f, im, Some(®ion), image.extras().pixelated); self.stats.drawn += 1; return; } @@ -427,7 +442,7 @@ impl Renderer { self.draw_shape(ctx, f, n, &bp, *rule); } NodeKind::Text(t) => self.draw_text(ctx, f, n, t), - NodeKind::Image(im) => self.draw_image(ctx, f, im), + NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, n.extras().pixelated), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) && self.charge_symbol(&sym.art) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9502ef3ff4..e299a7d1f2 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -306,7 +306,10 @@ fn past_the_measured_placements_an_instance_is_culled_by_an_estimate_of_its_art_ /// Symbols 0 to 29 each hold two instances of the next, sheared two ways, so that the 2^30 /// instances drawn from the first are all placed differently. Symbol 30 holds a square with a drop /// shadow. An instance of symbol 0 lies far left of the view. With `effect`, every instance has -/// that effect of its own. The renderer must cull the instance of symbol 0 within 30 s. +/// that effect of its own. The renderer must cull the instance of symbol 0 measuring symbol art at +/// most 20,000 times ([`FrameStats::symbol_art`]); measuring the art for every placement would take +/// 2^30 measurements or more. The frame runs on a thread of its own, and the test fails when it +/// has not finished after 300 s. fn nested_instances_are_culled_in_bounded_time(effect: Option) { let mut d = Document::new(100.0, 100.0); let mut art = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0); @@ -334,11 +337,13 @@ fn nested_instances_are_culled_in_bounded_time(effect: Option 0); + assert!(measured > 0, "the frame stats report no symbol art measures"); + assert!(measured <= 20_000, "symbol art measured {measured} times"); } #[test] @@ -913,12 +918,92 @@ fn images_take_their_nearest_pixel_without_smoothing() { let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None }; d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); // How many pixels across the image are neither black nor white. - let greys = |smooth_images| { - let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() }); + let greys = |d: &Document, smooth_images| { + let r = Renderer::new().render(d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() }); (0..40).filter(|x| (16..240).contains(&r.pixel(*x, 10)[0])).count() }; - assert!(greys(true) >= 4, "smooth: a ramp from black to white"); - assert_eq!(greys(false), 0, "nearest: black, then white"); + assert!(greys(&d, true) >= 4, "smooth: a ramp from black to white"); + assert_eq!(greys(&d, false), 0, "nearest: black, then white"); + // A pixelated image (an envelope's with Anti-Alias off) takes its nearest pixel either way. + d.node_mut(id).unwrap().extras_mut().pixelated = true; + assert_eq!(greys(&d, true), 0, "pixelated: black, then white"); +} + +/// An image masked by one white shape with Clip on draws as an image clipped by that shape, with +/// nothing drawn offscreen; any other mask goes through the offscreen mask. +#[test] +fn an_image_masked_by_a_white_shape_draws_as_if_clipped() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let doc_masked = |mask_color: Color, shape: Rect| { + let mut d = Document::new(100.0, 100.0); + d.images.insert("red".into(), ImageBlob::new("image/png", png.clone())); + let id = d.alloc_id(); + let im = ImageObject { + key: "red".into(), + width: 2, + height: 2, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(40.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let shape = rect_node(&mut d, shape, Paint::solid(mask_color), Paint::None, 0.0); + let n = Node::new(id, NodeKind::Image(Box::new(im))).with_mask(Some(Box::new(OpacityMask::new(shape, true)))); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + d + }; + let doc = |mask_color: Color| doc_masked(mask_color, Rect::new(10.0, 10.0, 50.0, 90.0)); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let draw = |d: &Document| { + let mut r = Renderer::new(); + r.threads = 0; + OFFSCREEN_PIXELS.with(|n| n.set(0)); + let img = r.render(d, 100, 100, Affine::IDENTITY, &opts); + (img, OFFSCREEN_PIXELS.with(|n| n.get())) + }; + let (img, px) = draw(&doc(Color::WHITE)); + assert_eq!(px, 0, "no offscreen mask"); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "inside the shape"); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "outside it"); + let (img, px) = draw(&doc(Color::rgb(0.5, 0.5, 0.5))); + assert!(px > 0, "a grey mask is drawn offscreen"); + assert!((100..160).contains(&img.pixel(30, 50)[1]), "at about half opacity: {:?}", img.pixel(30, 50)); + // A white mask reaching past the image's frame shows nothing outside the frame. + let (img, _) = draw(&doc_masked(Color::WHITE, Rect::new(0.0, 10.0, 50.0, 90.0))); + assert_eq!(img.pixel(5, 50), [255, 255, 255, 255], "left of the frame"); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]); + // In isolation mode, outside the isolated object, it is dimmed like the art around it. + let mut d = doc(Color::WHITE); + let other = rect_node(&mut d, Rect::new(60.0, 60.0, 90.0, 90.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let other = d.insert(Some(d.layers[0].id), usize::MAX, other).unwrap(); + let isolated = RenderOptions { isolated: Some(other), ..opts.clone() }; + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &isolated); + assert!(img.pixel(30, 50)[1] > 60, "dimmed: {:?}", img.pixel(30, 50)); +} + +/// A pixelated image drawn reduced with `image_mips` on takes its nearest full pixel, not a +/// mip's average. +#[test] +fn a_pixelated_image_draws_reduced_without_mips() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + let img = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba(if (x + y) % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = Document::new(64.0, 64.0); + d.images.insert("check".into(), ImageBlob::new("image/png", png)); + let id = d.alloc_id(); + let im = + ImageObject { key: "check".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(id, NodeKind::Image(Box::new(im))); + n.extras_mut().pixelated = true; + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let on = RenderOptions { image_mips: true, ..Default::default() }; + let r = Renderer::new().render(&d, 16, 16, Affine::scale(0.25), &on); + let mixed = (0..16).flat_map(|y| (0..16).map(move |x| (x, y))).filter(|(x, y)| r.pixel(*x, *y)[0] > 40 && r.pixel(*x, *y)[2] > 40).count(); + assert_eq!(mixed, 0, "each pixel red or blue"); } /// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows). diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 70e6554d33..a4c9820556 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -26,6 +26,23 @@ pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> { FONTS.write().unwrap_or_else(PoisonError::into_inner) } +/// What `step` returns from a run in which the fonts stayed the same. The fonts can change while +/// [`fonts_steady`] is held: the first lookup by family name catalogs the installed fonts, and +/// resolving a cataloged family, or a character no loaded font has, loads a face. `step` runs up +/// to 100 times, until [`vectorcraft_text::FontDb::generation`] is the same after a run as before +/// it. +pub(crate) fn with_fonts_unchanged(mut step: impl FnMut() -> T) -> T { + let db = vectorcraft_text::FontDb::global(); + for _ in 0..100 { + let before = db.generation(); + let out = step(); + if db.generation() == before { + return out; + } + } + panic!("the fonts changed during each of 100 runs"); +} + /// The bundled Source Serif 4 renamed `family` (as long as the original name). fn serif_as(family: &str) -> Vec { let mut data = include_bytes!("../../../../assets/fonts/SourceSerif4-Regular.ttf").to_vec(); diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 34215f909f..f1cf1da5ad 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -6,6 +6,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use crate::blobs::blob_key; use crate::tests_fontchange::fonts_steady; use crate::*; @@ -133,6 +134,96 @@ fn kept_mips_draw_without_decoding_the_full_pixels_again() { assert!(frame.pixels == Renderer::new().render(&moved, 32, 16, quarter, &on).pixels); } +/// With `kept_images_only` (the canvas's low-resolution fill), an image the store holds no pixels +/// of draws as a light gray box and is not decoded. Once decoded, it draws from the pixels the +/// store holds: the full pixels, when the mip for its size is not kept, and that mip is not made. +#[test] +fn kept_images_only_draw_from_the_store_without_decoding() { + let _fonts = fonts_steady(); + let d = doc_with_image("img", png(64, 64, 105), 64, 64); + let kept = RenderOptions { kept_images_only: true, image_mips: true, ..Default::default() }; + let quarter = Affine::scale(0.25); + let full = blob_key(&d.images["img"]).0; + let mut r = Renderer::new(); + let boxed = r.render(&d, 16, 16, quarter, &kept); + assert!(images::store().peek(&full).is_none(), "not decoded"); + assert!(boxed.pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 40), "a light box where the image is"); + // A frame at full size decodes the full pixels and makes no mip. + let _ = Renderer::new().render(&d, 64, 64, Affine::IDENTITY, &RenderOptions { image_mips: true, ..Default::default() }); + let quarter_mip = images::ImageKey { mip: 2, ..full.clone() }; + assert!(images::store().peek(&full).is_some() && images::store().peek(&quarter_mip).is_none()); + let drawn = r.render(&d, 16, 16, quarter, &kept); + assert!(images::store().peek(&quarter_mip).is_none(), "no mip made"); + assert!(drawn.pixels == Renderer::new().render(&d, 16, 16, quarter, &RenderOptions::default()).pixels, "the full pixels, drawn small"); +} + +/// With `kept_images_only`, a blur on an image the store holds no pixels of blurs the light gray +/// box. Once the image is decoded, the same renderer blurs the image's pixels and does not draw +/// the blurred box again from its raster cache. +#[test] +fn kept_images_only_make_effect_rasters_again_once_the_image_is_decoded() { + let _fonts = fonts_steady(); + let mut d = Document::new(64.0, 64.0); + d.images.insert("img".into(), ImageBlob::new("image/png", png(64, 64, 107))); + let (id, layer) = (d.alloc_id(), d.layers[0].id); + let im = ImageObject { key: "img".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(id, NodeKind::Image(Box::new(im))); + n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 2.0}))); + d.insert(Some(layer), 0, n).unwrap(); + let kept = RenderOptions { kept_images_only: true, image_mips: true, dragging: true, ..Default::default() }; + let quarter = Affine::scale(0.25); + let full = blob_key(&d.images["img"]).0; + let mut r = Renderer::new(); + let boxed = r.render(&d, 16, 16, quarter, &kept); + assert!(images::store().peek(&full).is_none(), "not decoded"); + let _ = Renderer::new().render(&d, 64, 64, Affine::IDENTITY, &RenderOptions { image_mips: true, ..Default::default() }); + let drawn = r.render(&d, 16, 16, quarter, &kept); + assert!(drawn.pixels != boxed.pixels, "the image's pixels, not the box"); + assert!(drawn.pixels == Renderer::new().render(&d, 16, 16, quarter, &kept).pixels, "as a new renderer draws it"); +} + +/// With `kept_images_only`, an image still to be decoded draws as a light gray box, not crossed +/// with `mark_undecoded_images`. An image the store notes as undecodable is not tried again and +/// draws without that box: as it draws with the option off, +/// nothing, or the crossed box with `mark_undecoded_images`. A blur on such an image, cached while +/// the image was still to be decoded, is made again once the image is noted. +#[test] +fn kept_images_only_leave_out_an_image_noted_as_undecodable() { + let _fonts = fonts_steady(); + let mut cut = png(64, 64, 109); + cut.truncate(cut.len() / 2); + let d = doc_with_image("cut", cut.clone(), 64, 64); + let mut blurred = Document::new(64.0, 64.0); + blurred.images.insert("cut".into(), ImageBlob::new("image/png", cut)); + let (id, layer) = (blurred.alloc_id(), blurred.layers[0].id); + let im = ImageObject { key: "cut".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(id, NodeKind::Image(Box::new(im))); + n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 2.0}))); + blurred.insert(Some(layer), 0, n).unwrap(); + let kept = RenderOptions { kept_images_only: true, image_mips: true, dragging: true, ..Default::default() }; + let marked = RenderOptions { mark_undecoded_images: true, ..kept.clone() }; + let quarter = Affine::scale(0.25); + let attempts = || crate::paint::DECODE_ATTEMPTS.with(std::cell::Cell::get); + let tried = attempts(); + let (mut r, mut rb) = (Renderer::new(), Renderer::new()); + let boxed = r.render(&d, 16, 16, quarter, &kept); + assert!(boxed.pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 40), "a light box while it is still to be decoded"); + assert!(r.render(&d, 16, 16, quarter, &marked).pixels == boxed.pixels, "with mark_undecoded_images too, and not crossed"); + let blurred_box = rb.render(&blurred, 16, 16, quarter, &kept); + assert!(blurred_box.pixels.iter().any(|&b| b > 0), "the blurred box"); + assert_eq!(attempts(), tried, "not tried"); + // The frame for the view tries it once and notes it. + let _ = Renderer::new().render(&d, 64, 64, Affine::IDENTITY, &RenderOptions { image_mips: true, ..Default::default() }); + let tried = attempts(); + assert!(rb.render(&blurred, 16, 16, quarter, &kept).pixels.iter().all(|&b| b == 0), "no blurred box from the raster cache"); + assert!(r.render(&d, 16, 16, quarter, &kept).pixels.iter().all(|&b| b == 0), "no box"); + let off = RenderOptions { mark_undecoded_images: true, image_mips: true, dragging: true, ..Default::default() }; + let crossed = Renderer::new().render(&d, 16, 16, quarter, &off); + assert!(crossed.pixels != boxed.pixels, "the crossed box is not the light box"); + assert!(r.render(&d, 16, 16, quarter, &marked).pixels == crossed.pixels, "the crossed box, as with the option off"); + assert_eq!(attempts(), tried, "not tried again"); +} + /// A document of `n` point type objects reading `word`, in rows. fn text_doc(n: usize, word: &str) -> Document { use vectorcraft_doc::text::{CharStyle, TextObject}; diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 97170f9f03..f5c963c0de 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -778,3 +778,109 @@ fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() { } } } + +#[test] +fn darkness_subtracts_from_rgb_before_shadow_opacity() { + for opacity in [50.0, 100.0] { + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), + Appearance::basic(Paint::solid(Color::rgb(0.8, 0.4, 0.2)), Paint::None, 0.0), + ); + n.appearance + .effects + .push(fx("stylize.dropShadow", json!({"shade":"darkness", "darkness":25, "x":40, "y":0, "blur":0, "opacity":opacity, "mode":"normal"}))); + let d = doc_with(Document::new(100.0, 100.0), n); + for threads in [0, 2] { + let p = render_threads(&d, threads).pixel(60, 20); + let expected = if opacity == 100.0 { [140u8, 38, 0, 255] } else { [198, 147, 127, 255] }; + assert!(p.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 2), "{p:?}, expected {expected:?}"); + } + } +} + +#[test] +fn darkness_adds_black_without_changing_cmy_inks() { + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), + Appearance::basic(Paint::solid(Color::cmyk(0.15, 0.3, 0.4, 0.2)), Paint::None, 0.0), + ); + n.appearance + .effects + .push(fx("stylize.dropShadow", json!({"shade":"darkness", "darkness":25, "x":40, "y":0, "blur":0, "opacity":100, "mode":"normal"}))); + let mut d = Document::new(100.0, 100.0); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let d = doc_with(d, n); + let want = Color::cmyk(0.15, 0.3, 0.4, 0.45).to_rgba8(1.0); + for threads in [0, 2] { + let got = render_threads(&d, threads).pixel(60, 20); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 3), "{got:?}, expected {want:?}"); + } +} + +#[test] +fn darkness_uses_the_source_color_model_before_document_conversion() { + for (mode, source, dark) in [ + (vectorcraft_doc::ColorMode::Rgb, Color::cmyk(0.15, 0.3, 0.4, 0.2), Color::cmyk(0.15, 0.3, 0.4, 0.45)), + (vectorcraft_doc::ColorMode::Cmyk, Color::rgb(0.8, 0.4, 0.2), Color::rgb(0.55, 0.15, 0.0)), + ] { + let mut d = Document::new(100.0, 100.0); + d.color_mode = mode; + let mut n = + Node::path(NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(source), Paint::None, 0.0)); + n.appearance + .effects + .push(fx("stylize.dropShadow", json!({"shade":"darkness","darkness":25,"x":40,"y":0,"blur":0,"opacity":100,"mode":"normal"}))); + let actual = doc_with(d.clone(), n); + let mut control = + Node::path(NodeId(0), shapes::rectangle(Rect::new(50.0, 10.0, 70.0, 30.0)), Appearance::basic(Paint::solid(dark), Paint::None, 0.0)); + // The neutral effect selects the same CMYK compositing pipeline without changing paint. + control.appearance.effects.push(fx("blur.gaussian", json!({"radius":0}))); + let expected = doc_with(d, control); + for threads in [0, 2] { + let got = render_threads(&actual, threads).pixel(60, 20); + let want = render_threads(&expected, threads).pixel(60, 20); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 3), "{mode:?}, {source:?}: {got:?} vs {want:?}"); + } + } +} + +#[test] +fn darkness_adjusts_gradient_stop_colors_before_interpolation() { + use vectorcraft_color::GradientMethod; + for method in [GradientMethod::Classic, GradientMethod::Perceptual] { + for dither in [false, true] { + let ramp = |end: Color| { + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, end)]); + g.method = method; + g.dither = dither; + Paint::Gradient(Box::new(GradientPaint::new(g))) + }; + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), + Appearance::basic(ramp(Color::rgb(0.8, 0.4, 0.2)), Paint::None, 0.0), + ); + n.appearance + .effects + .push(fx("stylize.dropShadow", json!({"shade":"darkness","darkness":25,"x":40,"y":0,"blur":0,"opacity":100,"mode":"normal"}))); + let actual = doc_with(Document::new(100.0, 100.0), n); + let control = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(50.0, 10.0, 70.0, 30.0)), + Appearance::basic(ramp(Color::rgb(0.55, 0.15, 0.0)), Paint::None, 0.0), + ); + let expected = doc_with(Document::new(100.0, 100.0), control); + let (got, want) = (render(&actual), render(&expected)); + for x in 52..68 { + assert!( + got.pixel(x, 20).iter().zip(want.pixel(x, 20)).all(|(a, b)| a.abs_diff(b) <= if dither { 8 } else { 3 }), + "{method:?} dither={dither} x={x}: {:?} vs {:?}", + got.pixel(x, 20), + want.pixel(x, 20) + ); + } + } + } +} diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 6ce6a54274..dd7e918df9 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -562,7 +562,11 @@ impl Writer<'_> { NodeKind::Image(im) => { let id = self.id_attr(n); let m = self.matrix(self.xf * im.xf); - let a = self.attrs(&css::transparency(n)); + let mut props = css::transparency(n); + if n.extras().pixelated { + props.push(("image-rendering", "pixelated".into())); + } + let a = self.attrs(&props); if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { let shared = shared.clone(); self.line(&format!("")); diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index 6662978b02..b21d45eb62 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -368,7 +368,9 @@ impl Writer<'_> { /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow - /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style + /// with Darkness can use a live filter for a uniform RGB source. Other source colors and + /// gradients require rendered-effect preparation by the caller to preserve their appearance. + /// Photoshop-style /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn /// such objects into images first). pub(super) fn filtered(&mut self, n: &Node) { @@ -403,6 +405,19 @@ impl Writer<'_> { self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); continue; } + if matches!(f, RasterFx::DropShadow { darkness: Some(_), .. }) { + let mut paints = n.appearance.items.iter().filter(|i| vectorcraft_effects::paints(i)); + let uniform = paints.next().is_some_and(|i| { + matches!(i.paint(), vectorcraft_color::Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, swatch: None, .. }) + }) && paints.next().is_none(); + if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk + || !vectorcraft_color::cms::active().rgb_is_srgb() + || !uniform + || !matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. } | vectorcraft_doc::NodeKind::Compound { .. }) + { + self.warn("Some Darkness shadows require rendered images to preserve source colors, profiles and gradients"); + } + } let fid = self.fresh_id("filter"); let region_attr = match region { Some(r) => format!( @@ -419,14 +434,14 @@ impl Writer<'_> { format!("", c.to_hex(), fmt_num(o as f64, 3)) }; let body = match f { - // Darkness: the object blurred and moved, each color channel times 1 − darkness. + // Subtract in sRGB before blur, preserving coverage and separate opacity. RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( - "", + "", sd(*blur), self.num(*dx), self.num(*dy), fmt_num(*opacity as f64, 3), - keep = fmt_num(1.0 - *darkness as f64, 3) + offset = fmt_num(-f64::from(*darkness), 4) ), RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( "{}", diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 1b2f8ddb58..89fa0d0ddd 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -4,7 +4,7 @@ //! Outer Glow, Inner Glow or Feather. That covers `feGaussianBlur`, `feDropShadow`, the usual //! shadow chains (offset, blur, flood or color matrix, composite, merge), blur and shadow graphs //! with `feBlend` and transparent backdrops (as exported by Figma and others), the object moved and -//! blurred with its colors scaled by `feComponentTransfer` (a drop shadow with Darkness) and what +//! blurred with a fixed amount subtracted from its colors by `feComponentTransfer` (Darkness), and what //! our export writes, so export → import keeps the effects. use std::collections::HashMap; @@ -69,14 +69,16 @@ enum V { }, /// The source inside its own blurred silhouette (σ). Feathered(f64), - /// The source blurred (σ `sd`, on linear RGB values when `linear`), moved, its colors times - /// `keep` and its alpha times `opacity`: a drop shadow with Darkness 1 − `keep`. + /// The source blurred (σ `sd`, on linear RGB values when `linear`), moved, its colors + /// scaled by `keep` with `subtract` removed before blur, and alpha times `opacity`. + /// Only fixed subtraction and the black endpoint map to live Darkness. Shade { sd: f64, linear: bool, dx: f64, dy: f64, keep: f32, + subtract: f32, opacity: f32, }, /// Layers, bottom first. @@ -111,7 +113,9 @@ impl V { V::Paint { shape, color, opacity, clipped: false } => { V::Paint { shape: Shape { sd: add(shape.sd), ..shape }, color, opacity, clipped: false } } - V::Shade { sd: s, linear: l, dx, dy, keep, opacity } => V::Shade { sd: add(s), linear: l || linear, dx, dy, keep, opacity }, + V::Shade { sd: s, linear: l, dx, dy, keep, subtract, opacity } => { + V::Shade { sd: add(s), linear: l || linear, dx, dy, keep, subtract, opacity } + } _ => return None, }) } @@ -122,9 +126,11 @@ impl V { V::Alpha(s) => V::Alpha(moved(s)), V::Paint { shape, color, opacity, clipped: false } => V::Paint { shape: moved(shape), color, opacity, clipped: false }, v @ (V::Graphic | V::Blurred(..)) if dx == 0.0 && dy == 0.0 => v, - V::Graphic => V::Shade { sd: 0.0, linear: false, dx, dy, keep: 1.0, opacity: 1.0 }, - V::Blurred(sd, linear) => V::Shade { sd, linear, dx, dy, keep: 1.0, opacity: 1.0 }, - V::Shade { sd, linear, dx: x, dy: y, keep, opacity } => V::Shade { sd, linear, dx: x + dx, dy: y + dy, keep, opacity }, + V::Graphic => V::Shade { sd: 0.0, linear: false, dx, dy, keep: 1.0, subtract: 0.0, opacity: 1.0 }, + V::Blurred(sd, linear) => V::Shade { sd, linear, dx, dy, keep: 1.0, subtract: 0.0, opacity: 1.0 }, + V::Shade { sd, linear, dx: x, dy: y, keep, subtract, opacity } => { + V::Shade { sd, linear, dx: x + dx, dy: y + dy, keep, subtract, opacity } + } _ => return None, }) } @@ -132,13 +138,13 @@ impl V { /// The source, blurred or moved, with each color channel times `keep` and its alpha times /// `opacity` (`feComponentTransfer` with linear functions through 0). fn scaled(self, keep: f32, opacity: f32) -> Option { - let (sd, linear, dx, dy, k, o) = match self { - V::Graphic => (0.0, false, 0.0, 0.0, 1.0, 1.0), - V::Blurred(sd, linear) => (sd, linear, 0.0, 0.0, 1.0, 1.0), - V::Shade { sd, linear, dx, dy, keep, opacity } => (sd, linear, dx, dy, keep, opacity), + let (sd, linear, dx, dy, k, subtract, o) = match self { + V::Graphic => (0.0, false, 0.0, 0.0, 1.0, 0.0, 1.0), + V::Blurred(sd, linear) => (sd, linear, 0.0, 0.0, 1.0, 0.0, 1.0), + V::Shade { sd, linear, dx, dy, keep, subtract, opacity } => (sd, linear, dx, dy, keep, subtract, opacity), _ => return None, }; - Some(V::Shade { sd, linear, dx, dy, keep: k * keep, opacity: o * opacity }) + Some(V::Shade { sd, linear, dx, dy, keep: k * keep, subtract: subtract * keep, opacity: o * opacity }) } /// `self` in the alpha of `mask` (`feComposite operator="in"`). @@ -188,8 +194,48 @@ fn color(c: usvg::Color) -> Color { Color::rgb8(c.red, c.green, c.blue) } -/// Evaluate one filter's primitives, `k` the scale from the filter's user space to the document; -/// `None` when one does something the effects can't. +/// Transfer functions on the source or its silhouette, separately from filter graph traversal. +fn transferred(v: V, [r, g, b, a]: [&TransferFunction; 4], srgb: bool) -> Option { + let identity = [r, g, b].iter().all(|f| matches!(f, TransferFunction::Identity)); + let invert = match a { + TransferFunction::Table(t) => t.as_slice() == [1.0, 0.0], + TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0, + _ => false, + }; + let scale = |f: &TransferFunction| match f { + TransferFunction::Identity => Some(1.0), + TransferFunction::Linear { slope, intercept } if *intercept == 0.0 && (0.0..=1.0).contains(slope) => Some(*slope), + _ => None, + }; + let keep = match [r, g, b].map(scale) { + [Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r), + _ => None, + }; + let solid = matches!(a,TransferFunction::Linear {slope,intercept} if *slope>=255.0 && *intercept==0.0); + let subtract = |f: &TransferFunction| match f { + TransferFunction::Linear { slope, intercept } if *slope == 1.0 && (-1.0..=0.0).contains(intercept) => Some(-*intercept), + _ => None, + }; + let darkness = match [r, g, b].map(subtract) { + [Some(r), Some(g), Some(b)] if r == g && g == b && srgb => Some(r), + _ => None, + }; + let opacity = scale(a); + if matches!(v, V::Graphic) + && let (Some(d), Some(opacity)) = (darkness, opacity) + { + return Some(V::Shade { sd: 0.0, linear: false, dx: 0.0, dy: 0.0, keep: 1.0, subtract: d, opacity }); + } + match (v, keep, opacity) { + (V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => Some(V::Alpha(Shape { inverted: !s.inverted, ..s })), + (V::Alpha(s), _, _) if identity && solid && s.plain() => Some(V::Alpha(s)), + (v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity), + _ => None, + } +} + +/// Evaluate one filter's primitives, `k` the scale from its user space to the document. +/// `None` when one does something the effects cannot represent. fn eval(f: &Filter, k: f64) -> Option { let mut results: HashMap<&str, V> = HashMap::new(); let mut last = V::Graphic; @@ -259,33 +305,7 @@ fn eval(f: &Filter, k: f64) -> Option { _ => return None, }, Kind::ComponentTransfer(c) => { - let identity = [c.func_r(), c.func_g(), c.func_b()].iter().all(|f| matches!(f, TransferFunction::Identity)); - let invert = match c.func_a() { - TransferFunction::Table(t) => t.as_slice() == [1.0, 0.0], - TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0, - _ => false, - }; - // A scale (a linear function through 0, or none) of each channel. - let scale = |f: &TransferFunction| match f { - TransferFunction::Identity => Some(1.0), - TransferFunction::Linear { slope, intercept } if *intercept == 0.0 && (0.0..=1.0).contains(slope) => Some(*slope), - _ => None, - }; - // The same scale of R, G and B, on sRGB values (a scale of 0 or 1 is the same on - // linear ones). - let srgb = p.color_interpolation() == ColorInterpolation::SRGB; - let keep = match [c.func_r(), c.func_g(), c.func_b()].map(scale) { - [Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r), - _ => None, - }; - // Our Outer Glow's solid silhouette (alpha × 255): an object's silhouette as far as the effects go. - let solid = matches!(c.func_a(), TransferFunction::Linear { slope, intercept } if *slope >= 255.0 && *intercept == 0.0); - match (input(c.input())?, keep, scale(c.func_a())) { - (V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }), - (V::Alpha(s), _, _) if identity && solid && s.plain() => V::Alpha(s), - (v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity)?, - _ => return None, - } + transferred(input(c.input())?, [c.func_r(), c.func_g(), c.func_b(), c.func_a()], p.color_interpolation() == ColorInterpolation::SRGB)? } // Our Outer Glow's silhouette grown by a quarter raster pixel (`export`): a dilation of a // plain silhouette by up to that much at 72 ppi is that step. @@ -340,10 +360,13 @@ pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap { + // A fixed subtraction before blur maps to Darkness; proportional intermediate + // color filters must not be reinterpreted as a different-looking effect. + [V::Shade { sd, linear, dx, dy, keep, subtract, opacity }, V::Graphic] + if (!*linear || *keep == 0.0) && (*keep == 0.0 || *keep == 1.0) => + { let v = along(*dx, *dy); - let (darkness, opacity) = (num((1.0 - *keep as f64) * 100.0), num(*opacity as f64 * 100.0)); + let (darkness, opacity) = (num(if *keep == 0.0 { 100.0 } else { *subtract as f64 * 100.0 }), num(*opacity as f64 * 100.0)); let p = json!({ "x": num(v.x), "y": num(v.y), "blur": blur(*sd), "shade": "darkness", "darkness": darkness, "opacity": opacity, "mode": mode }); ("stylize.dropShadow", p) } diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 35b0c810d9..0bd80c5d45 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -248,12 +248,10 @@ fn drop_shadow_chains_become_drop_shadows() { } } -/// The object blurred, moved and its colors scaled under it is a drop shadow with Darkness when -/// the blur and the scale work on sRGB values, or when the scale is 0; otherwise, on linear RGB -/// values (the SVG default), it is reported. A blur on linear values whose result goes unused -/// does not count. +/// A proportional RGB filter is not additive Darkness. A fully black shadow remains +/// recognizable, while other proportional transforms are reported rather than misrepresented. #[test] -fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() { +fn proportional_filters_are_not_misread_as_additive_darkness() { let chain = |space: &str, transfer: &str, slope: f64| { format!( r##""## @@ -261,7 +259,7 @@ fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() { }; let srgb = r#" color-interpolation-filters="sRGB""#; // The scale 0 makes black whatever values it works on. - for (space, slope, darkness) in [(srgb, 0.5, 50.0), ("", 0.0, 100.0)] { + for (space, slope, darkness) in [(srgb, 0.0, 100.0), ("", 0.0, 100.0)] { let (d, w) = open(&chain(space, "", slope)); assert!(w.is_empty(), "{w:?}"); let fx = effects(&d); @@ -273,7 +271,7 @@ fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() { assert_eq!((&p["x"], &p["y"], &p["blur"]), (&json!(4.0), &json!(5.0), &json!(4.0))); } // Scaled on linear values, or blurred on them before an sRGB scale. - for (space, transfer) in [("", ""), ("", srgb)] { + for (space, transfer) in [(srgb, ""), ("", ""), ("", srgb)] { let (d, w) = open(&chain(space, transfer, 0.5)); assert!(w.iter().any(|w| w.contains("filter")), "{transfer}: {w:?}"); assert!(effects(&d).is_empty()); @@ -332,18 +330,18 @@ fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() { let mut n = Node::path( d.alloc_id(), shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), - Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + Appearance::basic(Paint::solid(Color::rgb(0.8, 0.4, 0.2)), Paint::None, 0.0), ); - let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 50.0}); + let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 25.0}); n.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }]; let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let svg = export(&d, &ExportOptions::default()); let (canvas, exported) = (render_artboard(&d), resvg_render(&svg, 200, 120)); - // Where only the shadow lies: the red at half its value. + // An intermediate color distinguishes subtraction from proportional darkening. for img in [&canvas, &exported] { let [r, g, b] = img.over_white(115, 90); - assert!(r.abs_diff(128) <= 2 && g <= 2 && b <= 2, "{:?}", [r, g, b]); + assert!(r.abs_diff(140) <= 2 && g.abs_diff(38) <= 2 && b <= 2, "{:?}", [r, g, b]); } assert_similar(&canvas, &exported, 40.0, 0.02); } diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 6276f86cdc..758acc2c2e 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -214,6 +214,11 @@ impl FontFace { pub fn vertical_metrics(&self) -> (f64, f64) { (self.ascent, self.descent) } + /// The height of the lowercase letters in font units, positive (estimated from the ascent + /// when the font has no OS/2 value). + pub fn x_height(&self) -> f64 { + self.x_height + } /// The line gap the font asks for between lines, in font units (from the same table as /// [`vertical_metrics`](Self::vertical_metrics); 0 when it has none). pub fn line_gap(&self) -> f64 { diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 9d1cc358c6..16aa674ca6 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -90,6 +90,26 @@ fn parse_exception(entry: &str) -> (String, Exception) { (word, Exception::Only(points)) } +/// Exception points count chars of the lowercased word; lowercasing can add chars ('İ' becomes +/// 'i' + U+0307), so map each point back to the char index in `word` it falls before. Points +/// inside one char's lowercase expansion are dropped; a point at the end of the word is kept. +fn lowercase_points_to_word(word: &str, pts: &[usize]) -> Vec { + let mut out = Vec::with_capacity(pts.len()); + let mut lower = 0; + let mut len = 0; + for (i, ch) in word.chars().enumerate() { + if pts.contains(&lower) { + out.push(i); + } + lower += ch.to_lowercase().count(); + len = i + 1; + } + if pts.contains(&lower) { + out.push(len); + } + out +} + fn normalize_word(word: &str) -> String { word.chars().flat_map(|c| c.to_lowercase()).collect() } @@ -140,7 +160,7 @@ pub fn hyphen_points_with(word: &str, opts: &HyphenationOptions) -> Vec { if let Some(ex) = exception_for(word) { return match ex { Exception::Never => vec![], - Exception::Only(pts) => pts, + Exception::Only(pts) => lowercase_points_to_word(word, &pts), }; } let c: Vec = word.chars().collect(); @@ -243,6 +263,17 @@ mod tests { assert_eq!(hyphen_points("typography"), pattern, "override cleared"); } + /// Exception break points are char indices into the word itself, even when lowercasing + /// changes the char count ('İ' lowercases to 'i' + U+0307). + #[test] + fn exception_points_index_the_original_word() { + with_exceptions("İs-tan-bul", || { + assert_eq!(hyphen_points("İstanbul"), vec![2, 5]); + assert_eq!(hyphenate_word("İstanbul"), "İs-tan-bul"); + assert_eq!(hyphenate_word("İSTANBUL"), "İS-TAN-BUL"); + }); + } + /// The default Hyphenation options are the limits of [`hyphen_points`]: words of 6 letters or /// more, 3 letters on each side of the hyphen. #[test] diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 838940ddb2..092a2516ed 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -216,6 +216,12 @@ impl Ctx<'_> { Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy) } }; + let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { + let placed = Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + placed.inverse() * Affine::rotate(g.rotation.to_radians()) * placed + } else { + Affine::IDENTITY + }; let mut m = pre * local; if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. @@ -271,6 +277,7 @@ impl Ctx<'_> { image, colour, bend: None, + unturned, }); } diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 4f2c76fe22..54681da89d 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -367,5 +367,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi image: None, colour: None, bend: None, + unturned: Affine::IDENTITY, } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4299e61d22..b401905bb2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -136,6 +136,9 @@ pub struct PositionedGlyph { /// the path ([`GlyphBend`]) so it meets its neighbours, and `xf` is only the straight glyph /// nearest it (for the caret, hit testing and tools). `None`: the glyph is drawn through `xf`. pub bend: Option, + /// Font units → font units: `xf * unturned` places the glyph as it would be placed without + /// its Character Rotation. The identity for a glyph without one. + pub unturned: Affine, } /// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index e833d95dbf..95d23e2619 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -5,12 +5,14 @@ use std::sync::Arc; use unicode_bidi::Level; use unicode_script::{Script, UnicodeScript}; +use unicode_segmentation::UnicodeSegmentation; use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; use skrifa::MetadataProvider; +use skrifa::charmap::Charmap; use skrifa::instance::Size; use vectorcraft_doc::text::INLINE_CHAR; -use vectorcraft_doc::{CharStyle, InlineArt}; +use vectorcraft_doc::{CharStyle, Digits, InlineArt}; use crate::features::{OtFeatures, ligatures_suppressed_by}; use crate::fontdb::{FontDb, FontFace}; @@ -393,24 +395,23 @@ pub(crate) fn shape_range( let small_caps = st.small_caps.is_some() && !st.all_caps; // Split into segments by font coverage (and case, for Small Caps). let mut seg = Segment { range: a..a, runs, source_runs, st, face: primary.clone(), small: false, level: level_at(a) }; - let mut cache: std::collections::HashMap> = std::collections::HashMap::new(); + let mut cache: std::collections::HashMap<&str, Arc> = std::collections::HashMap::new(); let mut script = Script::Common; - for (i, c) in text[a..b].char_indices() { + // A grapheme cluster (a letter and its marks, an emoji sequence) is set in one face. + for (i, cluster) in text[a..b].grapheme_indices(true) { let i = a + i; // Digits are drawn in the style's numeral system: the font must cover that digit. - let c = st.digits.map(c); + let Some(c) = cluster.chars().next().map(|c| st.digits.map(c)) else { continue }; let level = level_at(i); // Whitespace the primary font doesn't map goes to the fallback face like any other // character (#1844): an ideographic space set at zero width would remove an indent. - // What the layout handles itself (tab, line and paragraph breaks) stays. - let covered = c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some()); - let face = if covered { + let face = if drawn_chars(cluster, st.digits).all(|c| in_face(pmap.as_ref(), c)) { primary.clone() - } else if let Some(f) = cache.get(&c) { + } else if let Some(f) = cache.get(cluster) { f.clone() } else { - let f = db.fallback_for_style(c, primary.id(), primary.weight, primary.italic).unwrap_or_else(|| primary.clone()); - cache.insert(c, f.clone()); + let f = fallback_face(db, &primary, pmap.as_ref(), cluster, st.digits); + cache.insert(cluster, f.clone()); f }; let small = small_caps && c.is_lowercase(); @@ -516,6 +517,32 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking) } +/// The characters of a grapheme cluster that need a glyph of their own, in the numeral system +/// `digits`: not what the layout handles itself (tab, line and paragraph breaks), joiners or +/// variation selectors. +fn drawn_chars(cluster: &str, digits: Digits) -> impl Iterator + '_ { + cluster + .chars() + .map(move |c| digits.map(c)) + .filter(|&c| !(c.is_control() || matches!(c, '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'))) +} + +/// Does the face with charmap `map` have a glyph for `c` (any face does when it has no charmap)? +fn in_face(map: Option<&Charmap<'_>>, c: char) -> bool { + map.is_none_or(|m| m.map(c).is_some()) +} + +/// The face of a grapheme cluster the primary face (charmap `pmap`) lacks a character of: the +/// first fallback face with all of them, so that a letter's marks are shaped with it and attach to +/// it (#4491); when there is none, the face its first character takes on its own. +fn fallback_face(db: &FontDb, primary: &Arc, pmap: Option<&Charmap<'_>>, cluster: &str, digits: Digits) -> Arc { + let fallback = |c| db.fallback_for_style(c, primary.id(), primary.weight, primary.italic); + drawn_chars(cluster, digits) + .find_map(|c| fallback(c).filter(|f| drawn_chars(cluster, digits).all(|c| f.covers(c)))) + .or_else(|| drawn_chars(cluster, digits).next().filter(|&c| !in_face(pmap, c)).and_then(fallback)) + .unwrap_or_else(|| primary.clone()) +} + /// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and /// Bopomofo, shaped together (splitting them would cost a shaper call per change of script). fn shaping_script(c: char) -> Script { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 79ce4f5667..7ed2eb48ce 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -25,6 +25,7 @@ mod typo; mod variable; mod versions; mod vertical; +mod vietnamese; /// The bundled fonts only: what these tests measure doesn't change with the fonts installed. fn db() -> &'static FontDb { diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index af67365b0e..1ec51d15ac 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -163,6 +163,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout image: None, colour: None, bend: None, + unturned: Affine::IDENTITY, }); x += advance; } diff --git a/crates/text/src/tests/vietnamese.rs b/crates/text/src/tests/vietnamese.rs new file mode 100644 index 0000000000..29f98d0727 --- /dev/null +++ b/crates/text/src/tests/vietnamese.rs @@ -0,0 +1,123 @@ +//! Vietnamese letters with two diacritics (Ể, Ứ, Ự, Ẩ…) draw both marks on their base letter +//! (#4491), whichever form the text comes in: precomposed (NFC), decomposed (NFD), or as +//! Vietnamese keyboards type it (a precomposed vowel and a combining tone mark), in a font with +//! the precomposed letters, one that builds them from its marks (GPOS mark-to-base and +//! mark-to-mark), and one that has to borrow the marks from a fallback font. + +use kurbo::{Point, Rect, Shape}; +use skrifa::MetadataProvider; +use unicode_segmentation::UnicodeSegmentation; +use vectorcraft_doc::{CharStyle, TextObject}; +use write_fonts::FontBuilder; +use write_fonts::types::Tag; + +use crate::test_fonts::name_table; +use crate::{FontDb, TextLayout, layout}; + +/// Letters as (precomposed, decomposed, keyboard) text: the words "HỌC ĐỂ ỨNG DỤNG" and more +/// letters with two marks, capital and small. +const LETTERS: [[&str; 3]; 20] = [ + ["H", "H", "H"], + ["\u{1ECC}", "O\u{323}", "O\u{323}"], + ["C", "C", "C"], + ["\u{110}", "\u{110}", "\u{110}"], + ["\u{1EC2}", "E\u{302}\u{309}", "\u{CA}\u{309}"], + ["\u{1EE8}", "U\u{31B}\u{301}", "\u{1AF}\u{301}"], + ["N", "N", "N"], + ["G", "G", "G"], + ["D", "D", "D"], + ["\u{1EE4}", "U\u{323}", "U\u{323}"], + ["\u{1EF0}", "U\u{31B}\u{323}", "\u{1AF}\u{323}"], + ["\u{1EA8}", "A\u{302}\u{309}", "\u{C2}\u{309}"], + ["\u{1EBE}", "E\u{302}\u{301}", "\u{CA}\u{301}"], + ["\u{1ED8}", "O\u{302}\u{323}", "\u{D4}\u{323}"], + ["\u{1EDC}", "O\u{31B}\u{300}", "\u{1A0}\u{300}"], + ["\u{1EC3}", "e\u{302}\u{309}", "\u{EA}\u{309}"], + ["\u{1EE9}", "u\u{31B}\u{301}", "\u{1B0}\u{301}"], + ["\u{1EF1}", "u\u{31B}\u{323}", "\u{1B0}\u{323}"], + ["\u{1EA9}", "a\u{302}\u{309}", "\u{E2}\u{309}"], + ["\u{1EAB}", "a\u{306}\u{303}", "\u{103}\u{303}"], +]; + +const SIZE: f64 = 100.0; +const SOURCE_SANS: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); + +fn text(form: usize) -> String { + LETTERS.iter().filter_map(|l| l.get(form).copied()).collect::>().join(" ") +} + +/// Each letter's ink, relative to the pen position of its first glyph. +fn letter_inks(db: &FontDb, family: &str, text: &str) -> Vec { + let st = CharStyle { font_family: family.into(), font_style: "Regular".into(), size: SIZE, ..CharStyle::default() }; + let l: TextLayout = layout(db, &TextObject::point(Point::ZERO, text, st)); + text.grapheme_indices(true) + .filter(|(_, g)| *g != " ") + .map(|(start, g)| { + let glyphs: Vec<_> = l.glyphs.iter().filter(|gl| (start..start + g.len()).contains(&gl.byte)).collect(); + assert!(!glyphs.is_empty(), "{g:?} has glyphs"); + let pen = glyphs[0].origin.x; + let ink = glyphs.iter().map(|gl| gl.outline.bounding_box()).reduce(|a, b| a.union(b)).unwrap(); + assert!(ink.area() > 0.0, "{g:?} draws"); + assert!(glyphs.iter().all(|gl| gl.gid != 0), "{g:?} has no missing glyph"); + ink - kurbo::Vec2::new(pen, 0.0) + }) + .collect() +} + +/// Every letter of `form` (drawn in `family` of `db`) has the ink of the precomposed letter in +/// Source Sans 3: its marks are on the letter, not beside it. +fn assert_attached(db: &FontDb, family: &str, form: usize) { + let want = letter_inks(FontDb::global(), "Source Sans 3", &text(0)); + let got = letter_inks(db, family, &text(form)); + assert_eq!(want.len(), got.len()); + for ((w, g), letter) in want.iter().zip(&got).zip(LETTERS) { + let off = [w.x0 - g.x0, w.x1 - g.x1, w.y0 - g.y0, w.y1 - g.y1].into_iter().fold(0.0_f64, |m, d| m.max(d.abs())); + assert!(off < 0.08 * SIZE, "{family}, form {form}: {:?} drawn {g:?}, precomposed {w:?}", letter[form]); + } +} + +#[test] +fn stacked_marks_sit_on_their_letter_in_every_form() { + for form in 0..3 { + assert_attached(FontDb::global(), "Source Sans 3", form); + } +} + +/// Source Sans 3 renamed `family`, with only the characters `keep` takes in its cmap. +fn source_sans_with(family: &str, keep: impl Fn(u32) -> bool) -> Vec { + let font = skrifa::FontRef::new(SOURCE_SANS).unwrap(); + let map: Vec<(u32, u16)> = font.charmap().mappings().filter(|(c, _)| keep(*c)).map(|(c, g)| (c, u16::try_from(g.to_u32()).unwrap())).collect(); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"cmap"), crate::embed::cmap(&map).unwrap()); + builder.add_raw(Tag::new(b"name"), name_table(&[(1, family), (2, "Regular"), (4, family), (6, "Viettest-Regular")]).unwrap()); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + builder.build() +} + +/// A font without the precomposed letters builds them from its base letters and marks. +#[test] +fn stacked_marks_are_positioned_mark_to_mark() { + const FAMILY: &str = "Viettest Marks"; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x1E00 && !(0xC0..0x250).contains(&c))), 1); + for form in 0..3 { + assert_attached(&db, FAMILY, form); + } +} + +/// A font with the base letters but none of the marks takes the whole letter, marks included, +/// from a font that has them. +#[test] +fn letters_whose_marks_a_font_lacks_fall_back_whole() { + const FAMILY: &str = "Viettest Bare"; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x80)), 1); + for form in 0..3 { + assert_attached(&db, FAMILY, form); + } +} diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index ab00951816..4203a6b7c7 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -38,9 +38,9 @@ use live_paint::LpHover; use live_paint::lp_color; pub use live_paint::{LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool}; pub use lookup::{ - EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, drawn_outlines, edge_near, edges, face_at, faces, is_live_paint, - leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, - visible_edge_near, visible_face_at, + EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, + is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, + sources, visible_edge_near, visible_face_at, }; pub use map::{ BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs, @@ -145,8 +145,8 @@ fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option, forward: boo /// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges /// (outline pieces between the points where paths meet) it passes over. With Shift held at the /// press, the drag draws a rectangular marquee and touches the regions it reaches (see -/// [`BuilderMap::regions_in_marquee`]): they merge, or with Alt they are deleted. Its options are -/// [`ShapeBuilderOptions`]. +/// [`BuilderMap::regions_in_marquee`]): they merge, or with Alt they are deleted. Escape during a +/// drag cancels it. Its options are [`ShapeBuilderOptions`]. #[derive(Default)] pub struct ShapeBuilderTool { cache: RegionCache, @@ -703,9 +703,10 @@ impl Tool for ShapeBuilderTool { } /// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch - /// before or after the current fill. + /// before or after the current fill. Escape during a drag cancels it: the release after it + /// merges and deletes nothing. fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right) + (self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right)) || (self.dragging && key == ToolKey::Escape) } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { @@ -713,6 +714,13 @@ impl Tool for ShapeBuilderTool { if !self.claims_key(cx, key) || self.editing() { return vec![]; } + if key == ToolKey::Escape { + self.dragging = false; + self.points.clear(); + self.touched = Touched::default(); + (self.marquee, self.hover, self.far) = (None, None, false); + return vec![]; + } let list = color_swatches(cx.doc); let Some((i, s)) = step_swatch(&list, &cx.paint.fill, self.picked, key == ToolKey::Right).and_then(|i| Some((i, list.get(i)?))) else { return vec![]; diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index bb0617e5dc..6174645a7a 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,8 +1,9 @@ use super::cache::RegionCache; -use super::lookup::{is_live_paint, live_paint_at, visible_edge_near, visible_face_at}; +use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at}; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ - Action, Arc, BezPath, Cursor, Document, Mods, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Tool, ToolContext, Value, json, json_ids, + Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, + PointerKind, Rect, Tool, ToolContext, ToolKey, Value, json, json_ids, path_contains, }; // ---------- Live Paint Bucket ---------- @@ -22,9 +23,11 @@ pub(super) enum LpHover { /// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under /// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside /// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the -/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. -fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { - if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { +/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. Only +/// the groups for which `takes` is true are looked at: [`lp_visible`] for the Live Paint Bucket and +/// [`lp_selectable`] for Live Paint Selection. +fn lp_part_at<'a>(doc: &'a Document, p: Point, tol: f64, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> Option<(NodeId, Option<&'a Arc>)> { + if let Some(g) = live_paint_at_where(doc, p, takes).and_then(|id| doc.node(id)) { return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); } if !edge { @@ -36,7 +39,7 @@ fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, && let Some(e) = visible_edge_near(n, p, tol) && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) && best.is_none_or(|b| d <= b.0) - && doc.is_visible(n.id) + && takes(n.id) { best = Some((d, n.id, e)); } @@ -44,11 +47,25 @@ fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, best.map(|(_, g, e)| (g, Some(e))) } -/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`]), else -/// with `cache` the region of the selection under `p`. A large selection's regions are found on -/// another thread ([`RegionCache::ready`]), and until they are, no region is highlighted. -fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool) -> LpHover { - if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { +/// Whether the Live Paint Bucket takes Live Paint group `id`: it is visible, with every layer and +/// group around it. +fn lp_visible(cx: &ToolContext, id: NodeId) -> bool { + cx.doc.is_visible(id) +} + +/// Whether the Live Paint Selection tool takes Live Paint group `id`: it is editable +/// ([`Document::is_editable`]: visible, and not locked, on a locked layer or on a locked artboard), +/// and in isolation mode it is the isolated object or inside it ([`crate::direct::in_scope`]). +fn lp_selectable(cx: &ToolContext, id: NodeId) -> bool { + cx.doc.is_editable(id) && crate::direct::in_scope(cx.doc, cx.isolation, id) +} + +/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`], among +/// the groups `takes` takes), else with `cache` the region of the selection under `p`. A large +/// selection's regions are found on another thread ([`RegionCache::ready`]), and until they are, no +/// region is highlighted. +fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> LpHover { + if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, takes) { return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); } let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); @@ -95,14 +112,14 @@ impl Tool for LivePaintBucketTool { match ev.kind { PointerKind::Move => { self.at = Some((p, ev.mods.shift)); - self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift); + self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id)); vec![] } PointerKind::Down => { self.hover = LpHover::None; let point = json!([p.x, p.y]); if ev.mods.shift { - return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { + return match lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { Some((g, Some(_))) => { vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] } @@ -143,7 +160,7 @@ impl Tool for LivePaintBucketTool { loop { self.cache.settle(wait); if let Some((p, shift)) = self.at { - self.hover = lp_hover(cx, Some(&mut self.cache), p, shift); + self.hover = lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id)); } if !wait || !self.cache.waiting() { break; @@ -161,12 +178,18 @@ pub const LP_SELECT_WIDTH: f64 = 4.0; /// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes. pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); -/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool -/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight -/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select -/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills -/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click -/// selects no face or edge. +/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws +/// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection; +/// with Shift held at the press, each of them leaves the selection if it is selected and joins it +/// otherwise. A click and a marquee change the selection on release, so Escape between the press +/// and the release (which removes the marquee) leaves the selection as it was. The tool does not +/// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer +/// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options +/// (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight `color` +/// (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With +/// Select Strokes off, Alt-click selects the face under the pointer as a click does and a marquee +/// takes no edges; with Select Fills off, a click selects the edge nearest the pointer as Alt-click +/// does and a marquee takes no faces; with both off, a click or a marquee selects no face or edge. pub struct LivePaintSelectionTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -174,11 +197,44 @@ pub struct LivePaintSelectionTool { strokes: bool, color: [u8; 3], width: f64, + /// The drag in progress, from the press to the release. + drag: Option, +} + +/// A press and drag of the Live Paint Selection tool. +#[derive(Clone, Copy, Debug)] +struct LpDrag { + /// Where the press was. + start: Point, + /// Where the pointer is. + cur: Point, + /// Shift was held at the press. + shift: bool, + /// The face or edge under the press, which a click selects (or with Shift toggles). + hit: Option, +} + +impl LpDrag { + /// The marquee from the press to the pointer, None while the drag is shorter than + /// [`CLICK_TOL_PX`] screen pixels both across and down (a click). + fn marquee(&self, cx: &ToolContext) -> Option { + let r = Rect::from_points(self.start, self.cur); + let tol = cx.tol(CLICK_TOL_PX); + (r.width() >= tol || r.height() >= tol).then_some(r) + } } impl Default for LivePaintSelectionTool { fn default() -> Self { - Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH } + Self { + cache: RegionCache::live_paint(), + hover: LpHover::None, + fills: true, + strokes: true, + color: HIGHLIGHT_RED, + width: LP_SELECT_WIDTH, + drag: None, + } } } @@ -194,6 +250,30 @@ impl LivePaintSelectionTool { } } +/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and +/// edges that are not hidden of the Live Paint groups the tool takes ([`lp_selectable`]), in paint +/// order. A face is touched when its outline has an anchor inside `r` or crosses `r`'s sides, or +/// when it holds `r`'s center. An edge is touched when it has an anchor inside `r` or crosses +/// `r`'s sides. A face or edge partly inside `r` is touched. +fn lp_parts_in(cx: &ToolContext, r: Rect, faces: bool, edges: bool) -> Vec { + let mut out = vec![]; + cx.doc.walk(|n| { + if !is_live_paint(n) || !lp_selectable(cx, n.id) { + return; + } + for c in n.children().into_iter().flatten().filter(|c| c.visible) { + let Some(pd) = c.path_data() else { continue }; + let face = faces && c.name.as_deref() == Some(FACE_NAME); + let edge = edges && c.name.as_deref() == Some(EDGE_NAME); + let touched = vectorcraft_geom::hit::intersects_rect(pd, r) || (face && path_contains(pd, FillRule::NonZero, r.center())); + if (face || edge) && touched { + out.push(c.id); + } + } + }); + out +} + /// A highlight color option: `#rrggbb` or a layer color's name (any case). pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> { let s = v.as_str()?.trim(); @@ -215,26 +295,70 @@ impl Tool for LivePaintSelectionTool { match ev.kind { PointerKind::Move => { // Only the parts of Live Paint groups highlight, so no map of the selection is built. - self.hover = match edge.map(|e| lp_hover(cx, None, p, e)) { + self.hover = match edge.map(|e| lp_hover(cx, None, p, e, &|id| lp_selectable(cx, id))) { Some(h @ LpHover::Part(_)) => h, _ => LpHover::None, }; vec![] } PointerKind::Down => { - let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); - match (hit, ev.mods.shift) { - (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], - (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], - (None, true) => vec![], - (None, false) => vec![Action::Exec("select.none".into(), json!({}))], + let part = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e, &|id| lp_selectable(cx, id))); + let hit = part.and_then(|(_, part)| part).map(|n| n.id); + self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit }); + vec![] + } + PointerKind::Drag => { + if let Some(d) = &mut self.drag { + d.cur = p; + self.hover = LpHover::None; + } + vec![] + } + PointerKind::Up => { + let Some(d) = self.drag.take() else { return vec![] }; + let d = LpDrag { cur: p, ..d }; + let Some(r) = d.marquee(cx) else { + // A click selects what the press picked. + return match (d.hit, d.shift) { + (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], + (None, true) => vec![], + (None, false) => vec![Action::Exec("select.none".into(), json!({}))], + }; + }; + let ids = lp_parts_in(cx, r, self.fills, self.strokes); + match (ids.is_empty(), d.shift) { + (true, true) => vec![], + (true, false) => vec![Action::Exec("select.none".into(), json!({}))], + (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], + (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + // Escape during a press or drag drops it; the release then selects nothing. + if key == ToolKey::Escape { + self.drag = None; + } + vec![] + } + + fn busy(&self) -> bool { + self.drag.is_some() + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.drag = None; + vec![] + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + if let Some(r) = self.drag.and_then(|d| d.marquee(cx)) { + return vec![Overlay::Marquee(r)]; + } lp_overlays(&self.hover, &self.cache, self.color, self.width as f32) } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 221dbfcac9..5e85d9339d 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -106,9 +106,14 @@ fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, to /// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { + live_paint_at_where(doc, p, |id| doc.is_visible(id)) +} + +/// [`live_paint_at`] among the Live Paint groups for which `takes` is true. +pub(super) fn live_paint_at_where(doc: &Document, p: Point, takes: impl Fn(NodeId) -> bool) -> Option { let mut hit = None; doc.walk(|n| { - if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { + if is_live_paint(n) && visible_face_at(n, p).is_some() && takes(n.id) { hit = Some(n.id); } }); @@ -121,28 +126,48 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { collect_leaves(n, false, out); } -/// [`leaf_shapes`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn collect_leaves<'a>(n: &'a Node, filled_faces: bool, out: &mut Vec<&'a Node>) { - if !n.visible { +/// [`leaf_shapes`]; with `builder` (the Shape Builder's inputs), a Live Paint group contributes +/// only its filled faces and its stroked edges, and locked objects contribute nothing. +fn collect_leaves<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) { + collect_members(n, builder, out); + if builder { + out.retain(|m| m.visible && !m.locked); + } +} + +/// The leaves the Shape Builder takes from `n` ([`collect_leaves`]), in paint order, with each +/// hidden or locked object it skips in its place. +pub fn builder_members(n: &Node) -> Vec<&Node> { + let mut out = vec![]; + collect_members(n, true, &mut out); + out +} + +/// [`collect_leaves`] with, for `builder`, each hidden or locked object in place of what it holds. +fn collect_members<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) { + if !n.visible && !builder { + return; + } + if builder && (!n.visible || n.locked) { + out.push(n); return; } if n.extras().shaper.is_some() { for c in n.children().into_iter().flatten().skip(1) { - collect_leaves(c, filled_faces, out); + collect_members(c, builder, out); } return; } match &n.kind { NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), - NodeKind::Group { .. } if filled_faces && is_live_paint(n) => { - out.extend(faces(n).into_iter().filter(|f| f.visible && !f.appearance.fill_paint().is_none()).map(|f| &**f)); - out.extend(edges(n).into_iter().filter(|e| e.visible && !e.appearance.stroke_paint().is_none()).map(|e| &**e)); + NodeKind::Group { .. } if builder && is_live_paint(n) => { + out.extend(faces(n).into_iter().filter(|f| !f.visible || f.locked || !f.appearance.fill_paint().is_none()).map(|f| &**f)); + out.extend(edges(n).into_iter().filter(|e| !e.visible || e.locked || !e.appearance.stroke_paint().is_none()).map(|e| &**e)); } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { for c in children { - collect_leaves(c, filled_faces, out); + collect_members(c, builder, out); } } _ => {} @@ -191,13 +216,15 @@ pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, V shapes_of(doc, roots, false) } -/// [`shapes_for`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Vec, Vec<&'a Node>) { +/// [`shapes_for`]; with `builder`, the leaves [`collect_leaves`] takes for the Shape Builder, and +/// nothing from a root in a locked or hidden group or layer. +fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], builder: bool) -> (Vec, Vec<&'a Node>) { let mut lv = vec![]; for id in roots { - if let Some(n) = doc.node(*id) { - collect_leaves(n, filled_faces, &mut lv); + if let Some(n) = doc.node(*id) + && (!builder || doc.is_editable(*id)) + { + collect_leaves(n, builder, &mut lv); } } let mut shapes = vec![]; @@ -219,7 +246,8 @@ fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Ve /// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions /// they cross, and so do filled open paths without `open_filled_as_closed`. A Live Paint group /// contributes its filled faces as shapes and its stroked edges as open lines; its unfilled faces -/// and its edges without a stroke are left out. +/// and its edges without a stroke are left out. Locked objects, and objects in a locked group or +/// layer, give nothing. pub fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { let (mut shapes, leaves) = shapes_of(doc, roots, true); for (s, l) in shapes.iter_mut().zip(&leaves) { diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 49cce1dbc2..5f69acce1b 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -60,6 +60,40 @@ fn shape_builder_drag_emits_merge() { assert!(v["points"].as_array().unwrap().len() >= 2); } +/// Escape during a merge drag, an Alt erase drag or a Shift marquee cancels it: the tool takes +/// the key, nothing stays highlighted, and the release merges and deletes nothing. Enter leaves +/// the drag alone, and with no drag Escape is not the tool's. +#[test] +fn escape_cancels_a_shape_builder_drag() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let shift = Mods { shift: true, ..Mods::default() }; + for (what, mods) in [("merge", Mods::default()), ("erase", alt), ("marquee", shift)] { + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0).with_mods(mods)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 60.0).with_mods(mods)); + assert!(!t.touched.is_empty(), "{what}"); + assert!(t.key(&cx, ToolKey::Enter, Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert!(t.claims_key(&cx, ToolKey::Escape), "{what}: Escape is the tool's during the drag"); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty(), "{what}: no interaction began, so none is canceled"); + assert!(!t.busy(), "{what}"); + assert!(t.overlays(&cx).is_empty(), "{what}: nothing stays highlighted"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 125.0, 70.0).with_mods(mods)).is_empty(), "{what}"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 125.0, 70.0).with_mods(mods)).is_empty(), "{what}: the release merges nothing"); + assert!(!t.claims_key(&cx, ToolKey::Escape), "{what}: with no drag, Escape is not the tool's"); + // The next drag merges as before. + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 50.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0)); + assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{what}: {acts:?}"); + } +} + #[test] fn shape_builder_rebuilds_its_map_for_a_new_revision_at_the_same_address() { let (mut d, a, b) = two_rects(); @@ -1051,6 +1085,13 @@ fn live_paint_selection_hover_builds_no_map_of_the_selection() { assert!(!t.waiting()); } +/// A Live Paint Selection click pressed with `down`: the press does nothing, and the release does +/// what is returned. +fn lp_click(t: &mut LivePaintSelectionTool, cx: &ToolContext, down: PointerEvent) -> Vec { + assert!(t.pointer(cx, &down).is_empty(), "the press changes nothing"); + t.pointer(cx, &PointerEvent { kind: PointerKind::Up, ..down }) +} + #[test] fn live_paint_selection_selects_faces() { let (d, _gid, f2, e) = live_group_doc(); @@ -1058,12 +1099,12 @@ fn live_paint_selection_selects_faces() { let p = paint(); let cx = cx(&d, &s, &p); let mut t = LivePaintSelectionTool::default(); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0)); assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]); let alt = Mods { alt: true, ..Default::default() }; - let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)); assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 300.0, 300.0)); assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]); } @@ -1080,15 +1121,15 @@ fn live_paint_selection_options_choose_what_a_click_selects() { assert_eq!(t.options(), json!({"selectFills": true, "selectStrokes": true, "color": "#ff2a2a", "width": 4.0})); // Select Fills off: a click inside a face selects nothing, and a click by an edge selects it. t.set_option("selectFills", &json!(false)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)), none); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0)), none); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); assert!(t.overlays(&cx).is_empty()); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0)), set(&[e])); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e])); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0)), set(&[e])); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e])); // Select Strokes off: Alt-click by an edge selects the face under the pointer. t.set_option("selectFills", &json!(true)); t.set_option("selectStrokes", &json!(false)); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2])); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2])); t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(alt)); let ov = t.overlays(&cx); let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; @@ -1096,12 +1137,12 @@ fn live_paint_selection_options_choose_what_a_click_selects() { // Both off: nothing is selected or highlighted. t.set_option("selectFills", &json!(false)); for m in [Mods::default(), alt] { - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none); t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(m)); assert!(t.overlays(&cx).is_empty()); } let shift = Mods { shift: true, ..Default::default() }; - assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); + assert!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); } #[test] @@ -1121,15 +1162,156 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() let ov = t.overlays(&cx); let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0)); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]); - let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); + let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); } // Farther than 4 pixels from every edge: no highlight, and a click selects nothing. t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0)); assert!(t.overlays(&cx).is_empty()); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); +} + +/// Presses at `from` with `m`, drags to `to` and releases there; returns what the release does +/// (the press and the drag do nothing). +fn lp_drag(t: &mut LivePaintSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), m: Mods) -> Vec { + assert!(t.pointer(cx, &ev(PointerKind::Down, from.0, from.1).with_mods(m)).is_empty()); + assert!(t.pointer(cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)).is_empty()); + t.pointer(cx, &ev(PointerKind::Up, to.0, to.1).with_mods(m)) +} + +#[test] +fn live_paint_selection_marquee_selects_the_faces_and_edges_it_touches() { + let mut d = Document::new(500.0, 500.0); + let (_, [left, top, right, bottom]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let face = faces(d.node(d.layers[0].children().unwrap()[0].id).unwrap())[0].id; + live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let mut t = LivePaintSelectionTool::default(); + // While the drag is on, the tool is busy and draws the marquee. + t.pointer(&cx, &ev(PointerKind::Down, -10.0, -10.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); + assert!(t.busy()); + assert_eq!(t.overlays(&cx), vec![Overlay::Marquee(Rect::new(-10.0, -10.0, 50.0, 50.0))]); + // Partly inside the marquee: the face and its left and top sides, not the other group. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)), set(&[face, left, top])); + assert!(!t.busy()); + // A marquee inside the face takes the face. + assert_eq!(lp_drag(&mut t, &cx, (20.0, 20.0), (40.0, 40.0), Mods::default()), set(&[face])); + // Across the right side, which ends outside the marquee, and the bottom-right corner. + assert_eq!(lp_drag(&mut t, &cx, (90.0, 40.0), (110.0, 150.0), Mods::default()), set(&[face, right, bottom])); + // A marquee that touches nothing selects nothing. + assert_eq!(lp_drag(&mut t, &cx, (120.0, 120.0), (180.0, 180.0), Mods::default()), vec![Action::Exec("select.none".into(), json!({}))]); + // A drag shorter than 3 pixels is a click: the release selects the face under the press. + assert_eq!(lp_drag(&mut t, &cx, (50.0, 50.0), (52.0, 51.0), Mods::default()), set(&[face])); + // Select Fills off takes no faces, and Select Strokes off no edges. + t.set_option("selectFills", &json!(false)); + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), Mods::default()), set(&[left, top])); + t.set_option("selectFills", &json!(true)); + t.set_option("selectStrokes", &json!(false)); + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), Mods::default()), set(&[face])); +} + +#[test] +fn live_paint_selection_shift_marquee_adds_and_removes() { + let mut d = Document::new(500.0, 500.0); + let (_, [left, top, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let face = faces(d.node(d.layers[0].children().unwrap()[0].id).unwrap())[0].id; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + // Each face and edge the marquee touches leaves the selection or joins it. + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), shift), toggle(&[face, left, top])); + // From a press on the face, the face is toggled once, with the rest of what the marquee touches. + assert_eq!(lp_drag(&mut t, &cx, (50.0, 50.0), (60.0, -10.0), shift), toggle(&[face, top])); + // From a press by the right side (which a Shift-click there toggles), a marquee that touches + // nothing changes nothing. + let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; + assert_eq!(lp_drag(&mut t, &cx, (102.0, 50.0), (102.0, 50.0), alt_shift), vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); + assert!(lp_drag(&mut t, &cx, (102.0, 50.0), (150.0, 60.0), alt_shift).is_empty()); +} + +#[test] +fn live_paint_selection_escape_cancels_the_marquee() { + let mut d = Document::new(500.0, 500.0); + live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + // Pressed outside the group and on its face, with and without Shift, and then dragged or not. + for (m, from, to) in [ + (Mods::default(), (-10.0, -10.0), (50.0, 50.0)), + (Mods { shift: true, ..Default::default() }, (-10.0, -10.0), (50.0, 50.0)), + (Mods::default(), (50.0, 50.0), (80.0, 80.0)), + (Mods { shift: true, ..Default::default() }, (50.0, 50.0), (80.0, 80.0)), + (Mods::default(), (50.0, 50.0), (50.0, 50.0)), + ] { + let mut t = LivePaintSelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1).with_mods(m)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)).is_empty()); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy()); + assert!(t.overlays(&cx).is_empty(), "no marquee after Escape"); + // The rest of the drag and the release select nothing. + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0).with_mods(m)); + assert!(t.overlays(&cx).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 60.0, 60.0).with_mods(m)).is_empty(), "{m:?} {from:?}"); + } +} + +#[test] +fn live_paint_selection_skips_locked_groups_and_art_outside_isolation() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, back_bottom]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0)); + let face = |d: &Document, g: NodeId| faces(d.node(g).unwrap())[0].id; + let back_face = face(&d, back); + d.node_mut(front).unwrap().locked = true; + let s = Selection::default(); + let p = paint(); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let none = vec![Action::Exec("select.none".into(), json!({}))]; + let part = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box()), + _ => panic!("{ov:?}"), + }; + let mut t = LivePaintSelectionTool::default(); + { + let cx = cx(&d, &s, &p); + // Where the faces overlap, the back group's face under the locked one is highlighted and picked. + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&t.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 50.0)), set(&[back_face])); + // Over the locked group alone, nothing is highlighted or picked. + t.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&t.overlays(&cx)), None); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 125.0, 50.0)), none); + // A marquee across both groups takes only the back group's face and edges. + assert_eq!(lp_drag(&mut t, &cx, (90.0, 90.0), (140.0, 110.0), Mods::default()), set(&[back_face, back_right, back_bottom])); + } + // With the back group isolated, the front group is not picked either. + d.node_mut(front).unwrap().locked = false; + { + let cx = ToolContext { isolation: Some(back), ..cx(&d, &s, &p) }; + t.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&t.overlays(&cx)), None); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 125.0, 50.0)), none); + assert_eq!(lp_drag(&mut t, &cx, (90.0, 90.0), (140.0, 110.0), Mods::default()), set(&[back_face, back_right, back_bottom])); + } + // On a locked layer, no group is picked. + let layer = d.layers[0].id; + d.node_mut(layer).unwrap().locked = true; + let cx = cx(&d, &s, &p); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 50.0)), none); + assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (200.0, 200.0), Mods::default()), none); } #[test] @@ -1158,7 +1340,7 @@ fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() { Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), None => Action::Exec("select.none".into(), json!({})), }; - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); let mut bucket = LivePaintBucketTool::default(); bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift)); assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2)); @@ -1201,7 +1383,7 @@ fn live_paint_tools_skip_hidden_edges() { Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), None => Action::Exec("select.none".into(), json!({})), }; - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); let mut bucket = LivePaintBucketTool::default(); bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift)); assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}"); @@ -1237,19 +1419,19 @@ fn live_paint_tools_skip_hidden_faces() { let mut sel = LivePaintSelectionTool::default(); sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); // Over the hidden face alone, neither tool highlights or picks a face. bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); assert_eq!(part(&bucket.overlays(&cx)), None); assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty()); sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); assert_eq!(part(&sel.overlays(&cx)), None); - assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); // Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side // as it does outside every face. sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt)); assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0))); - let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); + let picked = lp_click(&mut sel, &cx, ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]); } diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index f3a0e3c190..97b9f85973 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -96,7 +96,8 @@ pub struct ToolContext<'a> { pub snap_tangent: bool, /// View → Snapping → Snap to Perpendicular: likewise where it meets a nearby segment square. pub snap_perpendicular: bool, - /// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides). + /// View → Snap to Glyph: drawn points snap to live-text glyph bounds, and on a single line of type + /// to its baseline, x-height, character widths and glyph anchor points (with Smart Guides). pub snap_to_glyph: bool, /// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners. pub corner_widgets: bool, diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index d3cb7d3e88..25fd035147 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -15,7 +15,10 @@ //! objects entirely inside it (members of groups too), whole. //! In isolation mode both pick only the isolated object and what is inside it: anchors, segments, //! paths and groups outside it stay put. -//! Both move objects selected as a whole as the Selection tool does, snapping alike. +//! Both move objects selected as a whole as the Selection tool does, snapping alike. When the +//! selection also holds direct-selected anchors or segments, a drag from an object moves those +//! anchors and the objects selected whole, as an anchor drag does, and while Alt is held it +//! copies the selected objects. //! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]). //! A Cmd/Ctrl+Shift double-click with either one releases the ruler guide or guide path under the //! pointer ([`release_guide_at`]). @@ -158,6 +161,13 @@ pub struct DirectSelectionTool { handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. move_snap: Option, + /// What the point holding a move of objects snaps to while the move takes direct-selected + /// anchors along (`path.moveAnchors`): each move of objects sets it when it begins, to the + /// targets of an anchor drag when anchors are selected, else to `None`. + partial_snap: Option, + /// With Snap to Grid or Snap to Pixel on, what such a move from a fill snaps instead of the + /// point it holds: the box around the selected anchors and the objects selected whole. + partial_grid: Option, /// The drag snapped to a point or a guide at its last step: the arrow turns hollow. snapped: bool, /// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is. @@ -182,6 +192,8 @@ impl DirectSelectionTool { copy_snap: None, handle_snap: HandleSnap::default(), move_snap: None, + partial_snap: None, + partial_grid: None, snapped: false, hover: None, guide: GuideEdit::default(), @@ -199,7 +211,7 @@ impl DirectSelectionTool { /// Whether `id` may be picked while `scope` is isolated: it is that object, or it sits inside it. /// With no isolation, everything may be picked. -fn in_scope(doc: &vectorcraft_doc::Document, scope: Option, id: NodeId) -> bool { +pub(crate) fn in_scope(doc: &vectorcraft_doc::Document, scope: Option, id: NodeId) -> bool { scope.is_none_or(|scope| doc.ancestry(id).is_some_and(|anc| anc.contains(&scope))) } @@ -453,8 +465,9 @@ impl Tool for DirectSelectionTool { fn options(&self) -> Value { self.mesh.options() } - /// Escape during a drag (anchors, handles, segments, objects, type areas, blend spines, a - /// marquee) cancels it: what it moved goes back and the tool waits for the next press (#1743). + /// Escape during a drag (anchors, handles, segments, objects, type areas, blend spines, mesh + /// points, corner widgets, the brackets of type on a path, a marquee) cancels it: what it moved + /// goes back and the tool waits for the next press (#1743). /// With Group Selection, E during a marquee drag makes the marquee select only the objects /// entirely inside it, whole, and E pressed again makes it select what it selects by default. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { @@ -467,9 +480,11 @@ impl Tool for DirectSelectionTool { if key == ToolKey::Escape && self.guide.busy() { return self.guide.cancel(); } - if key != ToolKey::Escape || matches!(self.state, State::Idle | State::Mesh | State::Corner(_) | State::Bracket(_)) { + if key != ToolKey::Escape || matches!(self.state, State::Idle) { return vec![]; } + // A mesh point drag ends here too; the `Action::Cancel` below undoes it. + self.mesh.cancel(); self.state = State::Idle; self.guides.clear(); (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); @@ -1002,7 +1017,16 @@ impl DirectSelectionTool { if p.distance(start) < cx.tol(3.0) { return out; } - out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); + // With direct-selected anchors or segments in the selection, the drag moves + // them and the objects selected whole (`path.moveAnchors`), as an anchor drag + // does, while Alt is up; the other anchors of a partly selected path stay in + // place. The history names the move or the copy at the end. + self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel); + self.partial_grid = + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + let copy = ev.mods.alt && self.partial_snap.is_none(); + out.push(Action::Begin(if copy { "Copy".into() } else { "Move".into() })); self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; if grab.is_some() { // Alt can go down or up at any time: gather both while nothing has moved. @@ -1015,6 +1039,21 @@ impl DirectSelectionTool { let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; self.guides.clear(); + if !ev.mods.alt + && let Some(snap) = &self.partial_snap + { + let held = grab.unwrap_or(hold); + if let Some(grid) = &self.partial_grid { + (d, self.guides) = if ev.mods.shift { grid.snap_along(cx, held, d, false) } else { grid.snap(cx, held, d, false) }; + } else { + let shift = ev.mods.shift.then(|| Leave::segment(cx, held, true)); + let (q, guides) = snap.snap_from(cx, held + (p - start), shift.as_ref()); + (d, self.guides) = (q - held, guides); + } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); + return out; + } let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; if let (Some(grab), Some(snap)) = (grab, snap) { let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 311efe2231..a77c137945 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -661,6 +661,41 @@ fn escape_cancels_a_drag() { assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![], "nothing to cancel"); } +/// Escape also cancels a corner widget drag and a drag of the brackets of type on a path, and +/// the release after it does nothing. After a press on a widget that has not moved, Escape drops +/// the press, so its release selects no corner. +#[test] +fn escape_cancels_a_corner_widget_or_bracket_drag() { + let p = paint(); + let (d, rect) = doc_with_rect(); + let mut s = Selection::default(); + s.set([rect]); + let corners = cx(&d, &s, &p); + let (d2, text) = doc_with_path_type(); + let mut s2 = Selection::default(); + s2.add(text); + let brackets = cx(&d2, &s2, &p); + // (what, its context, the press, the drag's first step) + for (what, c, from, to) in [ + ("corner widget", &corners, Point::new(110.0, 110.0), Point::new(120.0, 120.0)), + ("bracket", &brackets, Point::new(400.0, 290.0), Point::new(340.0, 290.0)), + ] { + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Down, from.x, from.y)).is_empty(), "{what}"); + let a = t.pointer(c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + assert!(matches!(a.first(), Some(Action::Begin(_))), "{what}: {a:?}"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy(), "{what}"); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Drag, to.x - 5.0, to.y)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Up, to.x - 5.0, to.y)).is_empty(), "{what}: the release does nothing"); + } + let mut t = DirectSelectionTool::new(false); + t.pointer(&corners, &PointerEvent::new(PointerKind::Down, 110.0, 110.0)); + assert_eq!(t.key(&corners, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(t.pointer(&corners, &PointerEvent::new(PointerKind::Up, 110.0, 110.0)).is_empty(), "the click selects no corner"); +} + #[test] fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() { let (d, arch) = arch_doc(); @@ -963,6 +998,92 @@ fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); } +/// A drag from the fill of a path selected whole, while another path has some anchors selected, +/// moves the selected anchors and the paths selected whole (`path.moveAnchors`), as a drag of +/// an anchor does, with Direct Selection and Group Selection alike. Alt, pressed or released at +/// any time during the drag, turns the copy of the selected objects on and off, and a selection +/// of whole paths only still moves as objects. +#[test] +fn a_drag_from_a_fill_with_anchors_selected_moves_the_anchors() { + let (d, a, b) = two_squares(); + let p = paint(); + let mut mixed = anchors_of(b, &[(0, 0)]); + mixed.objects.insert(0, a); + let mut whole = Selection::default(); + whole.set([a, b]); + let alt = Mods { alt: true, ..Default::default() }; + for group in [false, true] { + let c = cx(&d, &mixed, &p); + let mut t = DirectSelectionTool::new(group); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)).is_empty(), "group: {group}"); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0)); + assert_eq!( + out, + vec![Action::Begin("Move".into()), Action::Preview("path.moveAnchors".into(), json!({"dx": 21.0, "dy": 3.0}))], + "group: {group}" + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 171.0, 153.0)), vec![Action::Commit]); + // Alt held at the start, released and pressed again: a copy, a move, a copy. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(alt)); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0).with_mods(alt)); + assert!(matches!(out.last(), Some(Action::Preview(c, v)) if c == "object.transform" && v["copy"] == true), "group: {group} {out:?}"); + assert_eq!(out.first(), Some(&Action::Begin("Move".into())), "the history names it by the last preview"); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 172.0, 153.0)); + assert_eq!(out, vec![Action::Preview("path.moveAnchors".into(), json!({"dx": 22.0, "dy": 3.0}))], "group: {group}"); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 173.0, 153.0).with_mods(alt)); + assert!(matches!(out.as_slice(), [Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "group: {group} {out:?}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 173.0, 153.0).with_mods(alt)); + let c = cx(&d, &whole, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 171.0, 153.0)); + assert!(matches!(out.last(), Some(Action::Preview(c, _)) if c == "object.transform"), "group: {group} {out:?}"); + } +} + +/// A drag from the fill of A, selected whole, while B's top-left anchor is selected snaps the +/// point it holds as a dragged anchor does: near an anchor of B that stays put, it lands on it. +/// With Snap to Grid on, the box around A and B's selected anchor, (100, 100)–(300, 300), lands an +/// edge on the 9 pt grid instead: moved by (21, 3), its right side and bottom (321, 303) are +/// nearest the gridlines 324 and 306, and with Shift the level move by 13 lands the right side on +/// 315. +#[test] +fn a_drag_from_a_fill_with_anchors_selected_snaps() { + let (d, a, b) = two_squares(); + let p = paint(); + let mut mixed = anchors_of(b, &[(0, 0)]); + mixed.objects.insert(0, a); + let none = Mods::default(); + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + let c = cx(&d, &mixed, &p); + let (v, labels) = drag(&mut t, &c, (150.0, 150.0), (298.0, 401.0), none); + assert_eq!((v, labels), (json!({"dx": 150.0, "dy": 250.0}), vec!["anchor".to_string()]), "group: {group}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 298.0, 401.0)); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &mixed, &p) }; + assert_eq!(drag(&mut t, &c, (150.0, 150.0), (171.0, 153.0), none).0, json!({"dx": 24.0, "dy": 6.0}), "group: {group}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 171.0, 153.0)); + let (v, _) = drag(&mut t, &c, (150.0, 150.0), (163.0, 151.0), Mods { shift: true, ..none }); + let (x, y) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap()); + assert!((x - 15.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 163.0, 151.0)); + } +} + +/// A path inside a selected group moves whole with the group, even with some of its anchors +/// direct-selected, so a drag from a fill does not snap to its other anchors: here to the +/// bottom-right corner (400, 400) of the path at (300, 300)–(400, 400). +#[test] +fn a_drag_from_a_fill_skips_the_anchors_of_a_path_in_a_selected_group() { + let (d, _, outside, _, outer) = nested_group_doc(); + let p = paint(); + let mut sel = anchors_of(outside, &[(0, 0)]); + sel.objects.insert(0, outer); + let c = cx(&d, &sel, &p); + let mut t = DirectSelectionTool::new(true); + let (v, labels) = drag(&mut t, &c, (150.0, 150.0), (398.0, 401.0), Mods::default()); + assert_eq!((v, labels), (json!({"dx": 248.0, "dy": 251.0}), Vec::::new())); +} + /// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors /// of its own path, and with several selected the one pressed on snaps, the others following. #[test] diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index 3cebc0079f..58664704ad 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -19,7 +19,7 @@ use vectorcraft_geom::Point; use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor}; use crate::direct::hit_handle; use crate::guides::HandleSnap; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; use vectorcraft_geom::constrain_angle_from; #[derive(Clone, Copy, Debug)] @@ -296,6 +296,30 @@ impl Tool for AnchorTool { fn busy(&self) -> bool { !matches!(self.state, State::Idle) } + /// Escape during an Anchor Point drag (an anchor pulled into a smooth point or moved, a + /// handle, a segment, a blend spine's point, handle or segment) cancels it: what it moved goes + /// back and the release does nothing. Escape after a press that has not moved yet drops the + /// press, so its release converts nothing. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape || matches!(self.state, State::Idle) { + return vec![]; + } + let began = matches!( + self.state, + State::Convert { began: true, .. } + | State::Move { began: true, .. } + | State::SpineMove { began: true, .. } + | State::Handle { began: true, .. } + | State::SpineConvert { began: true, .. } + | State::SpineHandle { began: true, .. } + | State::Reshape { began: true, .. } + ); + self.state = State::Idle; + self.guides.clear(); + self.measure = None; + self.space_resume = None; + if began { vec![Action::Cancel] } else { vec![] } + } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; // Anchors and handle ends are picked from a little further than segments. diff --git a/crates/tools/src/draw2/anchor/tests.rs b/crates/tools/src/draw2/anchor/tests.rs index 11c843e05e..f6986cf7e9 100644 --- a/crates/tools/src/draw2/anchor/tests.rs +++ b/crates/tools/src/draw2/anchor/tests.rs @@ -537,6 +537,58 @@ fn anchor_point_space_mid_drag_moves_the_anchor() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 370.0)), vec![Action::Commit]); } +/// Escape during a drag cancels it, whatever the drag does, and the release after it does +/// nothing; Enter leaves the drag alone. After a press that has not moved, Escape drops the press, +/// so its release does not make the smooth anchor a corner. +#[test] +fn escape_cancels_an_anchor_point_drag() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); + sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0)); + let path = vectorcraft_geom::PathData::single(sp); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Mods::default() }; + let space = Mods { space: true, ..Mods::default() }; + // (what, the press, its modifiers, Space held during the second drag step) + for (what, x, y, mods, spaced) in [ + ("convert", 100.0, 300.0, Mods::default(), false), + ("handle", 240.0, 320.0, Mods::default(), false), + ("segment", 150.0, 200.0, Mods::default(), false), + ("shift move", 200.0, 300.0, shift, false), + ("space move", 100.0, 300.0, Mods::default(), true), + ] { + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(mods)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y + 30.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(..)]), "{what}: {a:?}"); + let step = if spaced { space } else { Mods::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 30.0, y + 40.0).with_mods(step)); + let moved = matches!(&a[..], [Action::Preview(c, _)] if c == "path.moveAnchor"); + assert_eq!(moved, spaced || what == "shift move", "{what}: {a:?}"); + assert!(t.key(&cx, ToolKey::Enter, Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy(), "{what}"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y + 50.0)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 40.0, y + 50.0)).is_empty(), "{what}: the release does nothing"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + } + // A click on the smooth anchor makes it a corner; Escape between press and release drops it. + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0)); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty(), "nothing began, so nothing is canceled"); + assert!(!t.busy()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0)).is_empty()); + // With nothing pressed, Escape is not the tool's. + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); +} + #[test] fn anchor_point_shift_drag_honors_measurement_labels() { let (mut d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 21c090f213..70217dab18 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -82,13 +82,7 @@ pub struct GestureTool { /// The straight line is Alt's: the pointer is not constrained to 45° steps, and the line's end /// snaps to Smart Guides as a stroke's end does. unconstrained: bool, - /// Where the pointer was for each Blob Brush point dragged since Shift went down: the last - /// `shift_raw.len()` of `points`. - shift_raw: Vec, - /// Where the pointer was for the Shift-constrained point the next [`Self::push`] adds. - shift_next: Option, - /// The angle of the line the dragged points were last moved onto: while it holds they are on - /// it, and the next drag event need not move them again. + /// Axis selected during the current uninterrupted Shift interval, in degrees. shift_line: Option, /// "Merge only with selection": a Blob Brush stroke merges only with selected paths. pub merge_only_selection: bool, @@ -149,8 +143,6 @@ impl GestureTool { moved: false, straight: false, unconstrained: false, - shift_raw: vec![], - shift_next: None, shift_line: None, merge_only_selection: false, edit_within: 12.0, @@ -176,12 +168,6 @@ impl GestureTool { /// Add a stroke sample. fn push(&mut self, p: Point, pressure: f32) { - let raw = self.shift_next.take(); - if self.points.is_empty() { - self.shift_raw.clear(); - } else if let Some(raw) = raw { - self.shift_raw.push(raw); - } self.points.push(p); if self.id == "paintbrush" { self.pressures.push(pressure); @@ -200,14 +186,10 @@ impl GestureTool { self.id == "blobBrush" && m.alt && !self.moved } - /// Where the stroke takes a pointer at `p`. For the Blob Brush with Shift held, that is the - /// horizontal or vertical line through the press point that is nearer in angle to `p` (90° - /// steps from the Constrain Angle), at `p`'s distance from the press point. The points dragged - /// since Shift went down move to that line too, each at its pointer position's distance and on - /// that position's side of the press point; a pointer at the press point leaves them on their - /// line. Without Shift, or while the Blob Brush smooths, the stroke takes `p`. + /// Constrain the Pencil line or the current Blob Brush sample. Blob Brush chooses an + /// axis once per uninterrupted Shift interval; samples already drawn never move. + /// A smoothing drag follows the pointer without a Shift constraint. fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point { - self.shift_next = None; if self.straight && self.unconstrained { return p; } @@ -217,26 +199,26 @@ impl GestureTool { return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle); } let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift && !self.smoothing) else { - self.shift_raw.clear(); self.shift_line = None; return p; }; - self.shift_next = Some(p); - // The press point is on both lines: the points dragged before stay on theirs. - if (p - start).hypot() < vectorcraft_geom::EPS { - return start; + if self.shift_line.is_none() { + // Raw press-to-pointer displacement in logical screen pixels along the constraint + // axes. Zoom changes the document distance, not the activation tolerance. + let v = p - start; + let v = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(Point::new(v.x, v.y), |axes| axes.inverse() * Point::new(v.x, v.y)); + let x = v.x.abs() * cx.zoom; + let y = v.y.abs() * cx.zoom; + self.shift_line = if x >= 1.0 && y < 0.5 { + Some(cx.constrain_angle) + } else if y >= 1.0 && x < 0.5 { + Some(cx.constrain_angle + 90.0) + } else { + // No unconstrained trail while pending, including an initially diagonal drag. + return self.points.last().copied().unwrap_or(start); + }; } - let d = vectorcraft_geom::constrain_angle_from(p - start, 90.0, cx.constrain_angle); - // `line` is the line's angle, measured counterclockwise on screen as the Constrain Angle is measured. - let line = (-d.y).atan2(d.x).to_degrees(); - let on_line = |q: Point| start + vectorcraft_geom::constrain_angle_from(q - start, 180.0, line); - if self.shift_line != Some(line) { - for (q, raw) in self.points.iter_mut().skip(1).rev().zip(self.shift_raw.iter().rev()) { - *q = on_line(*raw); - } - self.shift_line = Some(line); - } - on_line(p) + start + vectorcraft_geom::constrain_angle_from(p - start, 180.0, self.shift_line.unwrap_or(cx.constrain_angle)) } /// Forget the stroke. diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 25e59c67cf..e3e8c4a28c 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -301,9 +301,7 @@ fn blob_brush_sends_its_fidelity() { } /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, -/// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points -/// dragged with Shift held move to the other line together, and each point follows the Shift -/// state of its own event. +/// with its axis chosen by the first clear axial movement. Crossing it never moves earlier samples. #[test] fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { let (d, _) = doc_with_rect(); @@ -311,46 +309,51 @@ fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { let p = paint(); let mut cx = cx(&d, &s, &p); let shift = Mods { shift: true, ..Default::default() }; - let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { - let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(c, "path.blob"); - v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() - }; let mut t = GestureTool::new("blobBrush"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); - assert_eq!(pts.len(), 3); - assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); - // A pointer that crosses to the vertical line takes the points before it along, each on - // its own side of the press point and at its own distance. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); - assert_eq!(pts.len(), 4, "{pts:?}"); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); - assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); - // Without Shift the stroke follows the pointer. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); - assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); - // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, - // and a later press of Shift leaves the points of the earlier one where they are. let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); - t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); - let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); - assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); - assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); - assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); - // The axes turn with the Constrain Angle. - cx.constrain_angle = 30.0; - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); - // A pointer back at the press point leaves the stroke on its line. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); + for zoom in [0.5, 1.0, 4.0] { + cx.zoom = zoom; + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 4.0 / zoom, 4.0 / zoom, shift)); + assert_eq!(t.points, vec![Point::ZERO]); + assert!(t.overlays(&cx).is_empty()); + t.pointer(&cx, &ev(PointerKind::Drag, 1.0 / zoom, 0.5 / zoom, shift)); + assert_eq!(t.points.len(), 1, "small component has a strict bound"); + t.pointer(&cx, &ev(PointerKind::Drag, 1.0 / zoom, 0.0, shift)); + let first = t.points[1]; + t.pointer(&cx, &ev(PointerKind::Drag, 3.0 / zoom, 10.0 / zoom, shift)); + assert_eq!(t.points[1], first); + assert!(t.points.iter().all(|p| p.y.abs() < 1e-9)); + // Release Shift: freehand resumes without relocating the constrained points. + t.pointer(&cx, &ev(PointerKind::Drag, 8.0 / zoom, 12.0 / zoom, Mods::default())); + assert_eq!(t.points.last(), Some(&Point::new(8.0 / zoom, 12.0 / zoom))); + t.pointer(&cx, &ev(PointerKind::Drag, 0.0, 15.0 / zoom, shift)); + assert_eq!(t.points[1], first); + assert!(t.points.last().unwrap().x.abs() < 1e-9); + t.pointer(&cx, &ev(PointerKind::Up, 0.0, 15.0 / zoom, shift)); + } + // Exact quarter turns preserve the strict half-pixel boundary in either direction. + for angle in [-450.0, -180.0, -90.0, 90.0, 180.0, 450.0] { + cx.constrain_angle = angle; + cx.zoom = 1.0; + let axes = vectorcraft_geom::constrain_axes(angle).unwrap(); + for (x, y) in [(1.0, 0.5), (0.5, 1.0), (-1.0, -0.5), (-0.5, -1.0)] { + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, shift)); + let at = axes * Point::new(x, y); + t.pointer(&cx, &ev(PointerKind::Drag, at.x, at.y, shift)); + assert_eq!(t.points, vec![Point::ZERO], "angle {angle}, ({x}, {y}) stays pending"); + assert!(t.shift_line.is_none()); + let at = axes * Point::new(x, 0.0); + t.pointer(&cx, &ev(PointerKind::Drag, at.x, at.y, shift)); + assert_eq!(t.shift_line.is_some(), x.abs() >= 1.0); + t.clear(); + } + } + cx.constrain_angle = 0.0; + // A pending diagonal released without locking makes the ordinary dot at the press. + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0, shift)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0, shift)); + assert!(matches!(&acts[0], Action::Exec(c, v) if c == "path.blob" && v["points"] == json!([[20.0, 20.0]])), "{acts:?}"); } /// Alt switches the Blob Brush to the Smooth tool: held before the press, or pressed before the diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index c3401b010f..894e7ca5a7 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -307,7 +307,7 @@ impl SnapKey { exclude: exclude.to_vec(), screen: cx.screen, view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular], - snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides, + snap_to_glyph: cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides, style: GuideStyle::of(cx), tolerances: [cx.snapping_tolerance, cx.snap_tolerance], } diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 5c5c061e9e..a1e1291fe7 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -47,6 +47,10 @@ pub struct Targets { /// it ends, as the end that stays put and the unit direction towards the dragged one /// ([`Self::for_anchor_drag`]). pub(super) extensions: Vec<(Point, Vec2)>, + /// Snap to Glyph's guides that run at an angle (in rotated type, or along a glyph's slanted + /// segments), as a point on each and its unit direction: a drawn point lands on the nearest + /// ([`Self::for_drawing`]). + pub(super) angled: Vec<(Point, Vec2)>, /// Snap to Tangent and Snap to Perpendicular: a drawn line from an anchor lands where it /// grazes a curve, or meets a segment square ([`Self::touching`]). pub(super) tangent: bool, @@ -209,12 +213,34 @@ impl Targets { self.ys.push((a.y, a, Kind::Anchor)); } - /// Live-text baselines and glyph bounds (View › Snap to Glyph). + /// A glyph guide through `at` along `dir` (on the page): a line the art lines up with when it + /// runs across or down the page, else an angled line a point lands on. + fn push_glyph_line(&mut self, at: Point, dir: Vec2) { + let len = dir.hypot(); + if !(len.is_finite() && len > 1e-12 && at.is_finite()) { + return; + } + let d = dir / len; + if d.y.abs() <= 1e-9 { + self.ys.push((at.y, at, Kind::Glyph)); + } else if d.x.abs() <= 1e-9 { + self.xs.push((at.x, at, Kind::Glyph)); + } else { + self.angled.push((at, d)); + } + } + + /// Live text as View › Snap to Glyph shows it: the glyph bounds of every text object, and for + /// a single line of type also its baseline and x-height, the proximity guides at the edges of + /// each character's advance width, and its glyphs' anchor points. In rotated type these + /// guides run at its angle (angular guides), and type of one glyph also has angular guides + /// along the glyph's slanted straight segments. fn push_glyphs(&mut self, doc: &Document, exclude: &[NodeId], visible: Option) { let skip: HashSet = exclude.iter().copied().collect(); let db = FontDb::global(); let kind = Kind::Glyph; let mut budget = BUDGET / 4; + let (mut anchors, mut angular) = (BUDGET, BUDGET / 4); walk_but(&doc.layers, &skip, &mut |n| { if budget == 0 || !n.visible { return; @@ -231,25 +257,84 @@ impl Targets { { return; } + // A paragraph shows glyph bounds only. + let single = lay.lines.len() == 1; + // Angular guides along a glyph's slanted segments: type of one glyph only. + let one_glyph = lay.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).count() == 1; + // Text space → the page, and a direction in text space → one on the page. let to_doc = |p: Point| t.xf * p; - for line in &lay.lines { - let at = to_doc(Point::new(line.x0, line.baseline)); - self.ys.push((at.y, at, kind)); + let along = |m: Affine, v: Vec2| (m * v.to_point()) - (m * Point::ZERO); + if single { + let line_doc = t.xf * lay.line_xf; + for line in &lay.lines { + self.push_glyph_line(line_doc * Point::new(line.x0, line.baseline), along(line_doc, Vec2::new(1.0, 0.0))); + } } + // The last x-height pushed, to push each run of glyphs alike once. + let mut last_x_height: Option<(Point, Vec2)> = None; for g in &lay.glyphs { if budget == 0 { break; } + // Glyphs that draw nothing (spaces) give no guides. let bb = g.outline.bounding_box(); if !(bb.width() > 0.0 && bb.height() > 0.0) { continue; } + budget -= 1; + // Horizontal type and type on a path: the baseline runs from where the pen stood + // along the glyph's angle (not the glyph's own drawing, which Character Rotation + // turns), up being to its left. + if single + && !lay.vertical + && let Some(face) = db.face_by_id(g.font_id) + { + let ahead = Vec2::new(g.angle.cos(), g.angle.sin()); + let up = Vec2::new(ahead.y, -ahead.x); + let dir = along(t.xf, ahead); + // The x-height of the glyph as placed without its Character Rotation (raised or + // lowered by its baseline shift), measured across the baseline. + let top = g.xf * g.unturned * Point::new(0.0, -face.x_height()); + let at = to_doc(g.origin + up * (top - g.origin).dot(up)); + let same = last_x_height.is_some_and(|(a, d)| { + let (l, k) = (d.hypot(), dir.hypot()); + l > 1e-12 && k > 1e-12 && (d / l - dir / k).hypot() <= 1e-9 && ((at - a).cross(d / l)).abs() <= 1e-6 + }); + if face.x_height() > 0.0 && !same { + self.push_glyph_line(at, dir); + last_x_height = Some((at, dir)); + } + // Proximity guides: across the baseline at both edges of the width the layout + // gives the character (its advance with tracking and kerning). + for e in [g.origin, g.origin + ahead * g.advance] { + self.push_glyph_line(to_doc(e), along(t.xf, up)); + } + } + if single { + for seg in g.outline.segments() { + if anchors > 0 { + self.points.push((to_doc(seg.start()), Kind::Anchor)); + anchors -= 1; + } + if one_glyph + && angular > 0 + && let PathSeg::Line(l) = seg + { + let (p, q) = (to_doc(l.p0), to_doc(l.p1)); + let d = q - p; + let len = d.hypot(); + if len > 1e-6 && d.x.abs() > 1e-9 * len && d.y.abs() > 1e-9 * len { + self.push_glyph_line(p, d); + angular -= 1; + } + } + } + } let r = t.xf.transform_rect_bbox(bb); for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { self.xs.push((p.x, p, kind)); self.ys.push((p.y, p, kind)); } - budget -= 1; } }); } @@ -287,7 +372,11 @@ impl Targets { // With several anchors moving, the one under the pointer is not known here, and the // others' extensions don't run through it. let single = sel.objects.len() <= 1 && sel.anchors.values().map(|s| s.len()).sum::() == 1; - for (id, set) in &sel.anchors { + // A path inside another selected object, such as a path in a selected group, moves whole + // with that object, so none of its anchors or segments stay put. + let selected: HashSet = sel.objects.iter().copied().collect(); + let inside_selected = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().rev().skip(1).any(|p| selected.contains(p))); + for (id, set) in sel.anchors.iter().filter(|(id, _)| !inside_selected(**id)) { let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue }; for (si, sp) in path.subpaths.iter().enumerate() { let moving = |ai: usize| set.contains(&(si, ai)); @@ -399,6 +488,7 @@ impl Targets { self.ys.clear(); self.segments.clear(); self.extensions.clear(); + self.angled.clear(); self } @@ -508,7 +598,11 @@ impl Targets { } // A construction guide through the point it leaves, else a dragged segment's extension: // it lands on the guide, sliding into line with a target crossing it nearby. - if let Some((at, dir, q)) = from.and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))).or_else(|| self.extension(p, tol)) { + if let Some((at, dir, q)) = from + .and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))) + .or_else(|| self.extension(p, tol)) + .or_else(|| self.on_angled(p, tol)) + { let construction = |q: Point| Overlay::Line { a: at, b: q, color: self.style.color, dashed: false }; return match self.slide(at, dir, q, tol, false) { Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()), @@ -606,6 +700,18 @@ impl Targets { .map(|(at, d, q, _)| (at, d, q)) } + /// The angled glyph guide ([`Self::angled`]) nearest `p`, within `tol` of it: a point on it, its + /// direction and where `p` lands on it. + fn on_angled(&self, p: Point, tol: f64) -> Option<(Point, Vec2, Point)> { + self.angled + .iter() + .map(|&(at, d)| (at, d, at + d * (p - at).dot(d))) + .map(|(at, d, q)| (at, d, q, q.distance(p))) + .filter(|(.., dist)| *dist <= tol) + .min_by(|a, b| a.3.total_cmp(&b.3)) + .map(|(at, d, q, _)| (at, d, q)) + } + /// The anchor or centre nearest `p`, within `tol`. /// Is a target point (an anchor, a centre) within `tol` of `p`? pub(crate) fn has_point_near(&self, p: Point, tol: f64) -> bool { @@ -621,7 +727,7 @@ impl Targets { } /// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where - /// the nearest target line crosses it, at most twice `tol` away: where it lands and the + /// the nearest target line or angled glyph guide crosses it, at most twice `tol` away: where it lands and the /// target's own point (where its line starts). fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> { if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 { @@ -646,6 +752,19 @@ impl Targets { } } } + // The angled glyph guides it crosses. + for &(on, e) in &self.angled { + let across = dir.cross(e); + if across.abs() <= 1e-9 * dir.hypot() { + continue; + } + let s = (on - at).cross(e) / across; + let hit = at + dir * s; + let moved = hit.distance(q); + if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) { + best = Some((moved, hit, on)); + } + } best.map(|(_, hit, from)| (hit, from)) } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 517937c74c..200ab5008f 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -608,3 +608,208 @@ fn draw_snap_gathers_once_per_document_state() { let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from)); assert!((q.y - 0.0).abs() < 1e-9, "{q:?}"); } + +/// A document holding point type `text` at (100, 300), 60 pt, turned `angle` radians about +/// where it starts, and the type. +fn glyph_doc(text: &str, angle: f64) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) { + glyph_doc_with(text, angle, vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() }) +} + +/// [`glyph_doc`] with the type in `style`. +fn glyph_doc_with(text: &str, angle: f64, style: vectorcraft_doc::CharStyle) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let at = Point::new(100.0, 300.0); + let mut t = vectorcraft_doc::TextObject::point(at, text, style); + t.xf = Affine::rotate_about(angle, at) * t.xf; + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + (d, t) +} + +/// Snap to Glyph: a Pen click near an anchor point of a glyph's outline lands on it, and not with +/// Snap to Glyph off. +#[test] +fn pen_click_near_a_glyph_anchor_lands_on_it() { + use crate::{Action, PointerEvent, PointerKind, Tool}; + let (d, t) = glyph_doc("H", 0.0); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + // An anchor inside the glyph's bounds (a corner where the crossbar meets a stem), which no + // glyph bounds line passes through. + let bb = g.outline.bounding_box(); + let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0; + let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph"); + let (s, p) = (Selection::default(), paint()); + let click = anchor + Vec2::new(1.5, -1.0); + let first_anchor = |glyphs: bool| { + let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &s, &p) }; + let mut pen = crate::pen::PenTool::default(); + let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y)); + let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") }; + Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap()) + }; + assert!(first_anchor(true).distance(anchor) < 1e-9, "{:?} vs {anchor:?}", first_anchor(true)); + assert!(first_anchor(false).distance(anchor) > 1e-3); + // Snap to Glyph works with Smart Guides on: Snap to Point alone does not land on glyph anchors. + let c = ToolContext { snap_to_glyph: true, smart_guides: false, snap_to_point: true, ..cx(&d, &s, &p) }; + let mut pen = crate::pen::PenTool::default(); + let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y)); + let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") }; + let q = Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap()); + assert!(q.distance(anchor) > 1e-3, "{q:?} landed on the glyph anchor with Smart Guides off"); +} + +/// Snap to Glyph: a single line of type has an x-height guide; a paragraph shows glyph bounds only. +#[test] +fn drawn_points_line_up_with_the_x_height_of_a_single_line() { + let (d, t) = glyph_doc("H", 0.0); + let lay = layout_text(FontDb::global(), &t); + let g = &lay.glyphs[0]; + let face = FontDb::global().face_by_id(g.font_id).unwrap(); + let x_height = (t.xf * g.xf * Point::new(0.0, -face.x_height())).y; + let p = Point::new(400.0, x_height + 2.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q.y - x_height).abs() < 1e-9, "{q:?}, x-height {x_height}"); + let (q, _) = Targets::for_drawing(&d, &[], None, false).snap_point(p, 4.0); + assert_eq!(q.y, p.y); + let (d, _) = glyph_doc("H\nH", 0.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert_eq!(q.y, p.y, "a paragraph has no x-height guide"); + // Baseline Shift raises the x-height with the glyphs. + let (d, _) = glyph_doc_with("H", 0.0, vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, ..Default::default() }); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0); + assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0); + // Character Rotation turns the glyph, not its baseline shift. + let style = vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, rotation: 30.0, ..Default::default() }; + let (d, _) = glyph_doc_with("H", 0.0, style); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0); + assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0); + // Glyph Scaling widens a rotated glyph without raising its x-height. + let mut t = + vectorcraft_doc::TextObject::point(Point::ZERO, "HH HH", vectorcraft_doc::CharStyle { size: 60.0, rotation: 90.0, ..Default::default() }); + let frame = kurbo::Shape::to_path(&kurbo::Rect::new(50.0, 100.0, 450.0, 400.0), 0.1); + t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&frame) }; + t.xf = Affine::IDENTITY; + t.para.justify = vectorcraft_doc::Justify::JustifyAll; + t.para.justification.glyph_scaling = [100.0, 100.0, 200.0]; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &t); + assert_eq!(lay.lines.len(), 1); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + assert!(g.scale > 1.2, "Glyph Scaling widens the glyph: {}", g.scale); + let expected = (t.xf * g.origin).y - (300.0 - x_height); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(Point::new(30.0, expected + 2.0), 4.0); + assert!((q.y - expected).abs() < 1e-9, "{q:?}, x-height {expected}"); +} + +/// Snap to Glyph: the x-height guide of type skewed along a slanted path runs through the glyph's +/// x-height. +#[test] +fn the_x_height_guide_of_skewed_path_type_runs_through_the_glyphs_x_height() { + let mut path = kurbo::BezPath::new(); + path.move_to((100.0, 400.0)); + path.line_to((300.0, 54.0)); + let mut t = vectorcraft_doc::TextObject::point(Point::ZERO, "H", vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() }); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.path_effect = vectorcraft_doc::PathEffect::Skew; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + let face = FontDb::global().face_by_id(g.font_id).unwrap(); + let top = t.xf * g.xf * Point::new(0.0, -face.x_height()); + let ahead = Vec2::new(g.angle.cos(), g.angle.sin()); + let normal = Vec2::new(ahead.y, -ahead.x); + // Beside the guide's line, past the glyph's end along it. + let p = top + ahead * 80.0 + normal * 1.5; + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q - top).cross(ahead).abs() < 1e-6, "{q:?} is off the x-height line through {top:?}"); +} + +/// Snap to Glyph › Proximity Guides: the edges of each character's advance width are guides. +#[test] +fn drawn_points_line_up_with_the_edge_of_a_characters_width() { + let (d, t) = glyph_doc("HH", 0.0); + let lay = layout_text(FontDb::global(), &t); + let edge = (t.xf * lay.glyphs[1].origin).x; + let p = Point::new(edge + 0.3, 450.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}"); + let (q, _) = Targets::for_drawing(&d, &[], None, false).snap_point(p, 4.0); + assert_eq!(q.x, p.x); + // Character Rotation turns the glyphs, not their widths along the baseline. + let (d, t) = glyph_doc_with("HH", 0.0, vectorcraft_doc::CharStyle { size: 60.0, rotation: 30.0, ..Default::default() }); + let lay = layout_text(FontDb::global(), &t); + let edge = (t.xf * lay.glyphs[1].origin).x; + let p = Point::new(edge + 0.3, 450.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}"); +} + +/// Snap to Glyph › Angular Guides: in a rotated glyph a straight upright stroke is a guide at its +/// angle, which a drawn point lands on beyond the glyph. +#[test] +fn a_rotated_glyph_has_angular_guides_along_its_strokes() { + let angle = 20f64.to_radians(); + let (d, t) = glyph_doc("H", angle); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + let stem = g + .outline + .segments() + .find_map(|s| match s { + PathSeg::Line(l) if (l.p1.x - l.p0.x).abs() < 1e-6 && (l.p1.y - l.p0.y).abs() > 10.0 => Some(l), + _ => None, + }) + .expect("an upright stroke"); + let (a, b) = (t.xf * stem.p0, t.xf * stem.p1); + let dir = (b - a) / (b - a).hypot(); + // Past the far end of the stroke, a little beside its line. + let far = if (a - t.xf * Point::new(0.0, 0.0)).hypot() > (b - t.xf * Point::new(0.0, 0.0)).hypot() { a } else { b }; + let out = if far == a { -dir } else { dir }; + let p = far + out * 30.0 + Vec2::new(-dir.y, dir.x) * 1.5; + let on_line = |q: Point| (q - a).cross(dir).abs(); + let (q, ov) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!(on_line(q) < 1e-9, "{q:?} is {} off the stroke's line", on_line(q)); + assert!(!lines(&ov).is_empty()); + let (q, _) = Targets::for_drawing(&d, &[], None, false).snap_point(p, 4.0); + assert!(on_line(q) > 1e-3, "no angular guide with Snap to Glyph off"); +} + +/// Snap to Glyph: the baseline of type turned slightly runs at its angle, and a point near it +/// lands on it even close to where it starts, far nearer the horizontal through that point. +#[test] +fn a_slightly_rotated_baseline_pulls_points_near_its_start() { + let (d, t) = glyph_doc("H", 2f64.to_radians()); + let lay = layout_text(FontDb::global(), &t); + let m = t.xf * lay.line_xf; + let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO); + let dir = dir / dir.hypot(); + let p = start - dir * 10.0 + Vec2::new(-dir.y, dir.x) * 1.0; + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q - start).cross(dir).abs() < 1e-9, "{q:?} is off the baseline"); +} + +/// Snap to Glyph: a segment Shift keeps level slides along to where it crosses the baseline of +/// rotated type. +#[test] +fn a_shift_constrained_point_slides_onto_a_rotated_baseline() { + let (d, t) = glyph_doc("H", 20f64.to_radians()); + let lay = layout_text(FontDb::global(), &t); + let m = t.xf * lay.line_xf; + let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO); + let dir = dir / dir.hypot(); + let crossing = start - dir * 40.0; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let from = Leave::segment(&c, crossing - Vec2::new(60.0, 0.0), true); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_from(crossing + Vec2::new(1.5, 0.3), Some(&from), 4.0); + assert!(q.distance(crossing) < 1e-9, "{q:?} vs {crossing:?}"); +} diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 230c92ea2e..d6a46faf05 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -3,13 +3,14 @@ //! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`; clicking an anchor //! point of a path blends from that point (`starts`). Each further click adds the object as one //! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens -//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over. +//! Blend Options (dialog `blendOptions`). Clicking empty canvas or pressing Escape starts over. //! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through //! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's //! new point takes the current fill color in its own model, unless Shift); drag a mesh point, //! or a handle of the point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); //! Alt-click a mesh point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects -//! the mesh, and the next solid fill color recolors just that point. +//! the mesh, and the next solid fill color recolors just that point. Escape during a drag +//! cancels it. use serde_json::json; use vectorcraft_color::Paint; @@ -17,7 +18,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::Point; use crate::meshedit::{MeshEdit, grid_of}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; @@ -135,6 +136,14 @@ impl Tool for BlendTool { fn busy(&self) -> bool { self.first.is_some() } + /// Escape drops the object picked first and stops adding to the blend just made, so the next + /// click picks a new first object. The document and the selection stay as they are. + fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + if key == crate::ToolKey::Escape { + (self.first, self.chained) = (None, false); + } + vec![] + } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { (self.first, self.chained) = (None, false); vec![] @@ -252,6 +261,14 @@ impl Tool for MeshTool { fn busy(&self) -> bool { self.edit.dragging() } + /// Escape during a drag of a mesh point or handle cancels it: the point goes back and the + /// release does nothing. + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape { + return vec![]; + } + self.edit.cancel().unwrap_or_default() + } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.edit.unfocus(); self.edit.release().unwrap_or_default() @@ -331,6 +348,49 @@ mod tests { assert!(t.first(&c).is_none()); } + /// Escape drops the object picked first, and stops adding to the blend just made: the next + /// click is a new first pick, and the click after it blends with that one. + #[test] + fn escape_drops_the_blend_tools_first_pick() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let (b, g) = (d.alloc_id(), d.alloc_id()); + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (k1, k2) = (d.alloc_id(), d.alloc_id()); + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() }); + d.insert(Some(l), 2, blend).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y); + let esc = |t: &mut BlendTool| t.key(&c, crate::ToolKey::Escape, Mods::default()); + let mut t = BlendTool::default(); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + assert!(t.busy() && !t.overlays(&c).is_empty()); + assert!(t.key(&c, crate::ToolKey::Enter, Mods::default()).is_empty()); + assert!(t.busy(), "Enter keeps the pick"); + assert!(esc(&mut t).is_empty(), "Escape changes nothing in the document"); + assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "Escape drops the pick"); + // The next click picks b first instead of blending a with it. + assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b)); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), vec![Action::Exec("object.blend.make".into(), json!({"ids": [b.0, a.0]}))]); + // After a blend is made, Escape stops the next click adding to it. + let mut s = Selection::default(); + s.set([g]); + let c = cx(&d, &s, &p); + let mut t = BlendTool { first: None, chained: true }; + assert!(t.first(&c).is_some()); + assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty()); + assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made"); + assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a)); + assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop"); + } + #[test] fn mesh_tool_click_path_creates_mesh() { let (d, a) = doc_with_rect(); diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index 9037968ae2..39f8adcdb7 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -102,6 +102,11 @@ impl MeshEdit { self.drag.take().map(|_| vec![Action::Commit]) } + /// Cancel a drag: the point or handle goes back (`None` when nothing was being dragged). + pub fn cancel(&mut self) -> Option> { + self.drag.take().map(|_| vec![Action::Cancel]) + } + pub fn dragging(&self) -> bool { self.drag.is_some() } @@ -194,6 +199,34 @@ mod tests { ); } + /// Escape during a drag of a mesh point or handle, with the Mesh tool or Direct Selection, + /// cancels it, and the release after it does nothing; the point stays the focused one. + #[test] + fn escape_cancels_a_mesh_point_drag() { + let (d, e) = doc_with_envelope(); + let mut s = Selection::default(); + s.set([e]); + let p = paint(); + let c = cx(&d, &s, &p); + let tools: [(&str, Box); 2] = + [("mesh", crate::meshblend::create("mesh").unwrap()), ("directSelection", Box::new(crate::direct::DirectSelectionTool::new(false)))]; + for (what, mut t) in tools { + // A point, then (once the point is focused) its handle. + for (x, y) in [(200.0, 100.0), (200.0, 133.33333)] { + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)), vec![Action::Begin("Move Mesh Point".into())], "{what}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x + 10.0, y - 10.0)); + assert!(t.key(&c, crate::ToolKey::Enter, crate::Mods::default()).is_empty(), "{what}: Enter leaves the drag alone"); + assert!(t.busy(), "{what}"); + assert_eq!(t.key(&c, crate::ToolKey::Escape, crate::Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy(), "{what}"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x + 20.0, y - 20.0)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, x + 20.0, y - 20.0)).is_empty(), "{what}: the release does nothing"); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Handle { .. })), "{what}: the point stays focused"); + } + assert!(t.key(&c, crate::ToolKey::Escape, crate::Mods::default()).is_empty(), "{what}: nothing to cancel"); + } + } + #[test] fn direct_selection_drags_a_selected_envelopes_points() { let (d, e) = doc_with_envelope(); diff --git a/crates/tools/src/pie.rs b/crates/tools/src/pie.rs index 57ef477341..af79c1ce46 100644 --- a/crates/tools/src/pie.rs +++ b/crates/tools/src/pie.rs @@ -287,6 +287,24 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, Point::new(312.0, 150.0))).is_empty()); } + /// Escape during a pie widget drag with the Selection tool cancels it: the angle readout is removed, + /// and the release after it commits nothing. + #[test] + fn escape_cancels_a_pie_widget_drag() { + let (d, _, s) = doc_with_ellipse((0.0, 360.0)); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, Point::new(312.0, 150.0))).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, Point::new(200.0, 20.0))); + assert_eq!(a[0], Action::Begin("Live Shape".into())); + assert_eq!(t.key(&c, crate::ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "the readout is removed"); + assert!(t.pointer(&c, &ev(PointerKind::Drag, Point::new(100.0, 200.0))).is_empty(), "the drag is over"); + assert!(t.pointer(&c, &ev(PointerKind::Up, Point::new(100.0, 200.0))).is_empty(), "the release commits nothing"); + } + /// On a whole ellipse the two widgets sit at the very same point, whatever its size, place or /// angle, so a press there always takes the End widget. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 3dab80fa3d..f6d8eb9e57 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -17,6 +17,8 @@ //! scaling its type: the text reflows at its size. //! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and //! E pressed again makes it take the objects it touches. +//! Escape during a drag (a move or an Alt-drag copy, a scale, a rotation, a marquee, a widget or +//! bracket drag) cancels it: the art returns to its state at the press. //! Selected area type and type on a path show an in port and an out port. A click on the out port //! loads the pointer with the text ([`Cursor::LoadedText`]): the next click threads it into the //! closed path, area type or type on a path under the pointer, or into a new frame of the same @@ -118,6 +120,9 @@ pub struct SelectionTool { /// What a bounding-box handle being dragged snaps to. targets: Option, moving: Option, + /// The params of the move's last preview: in Shaper's Construction mode the move ends with + /// them and `releaseOutside` ([`construction`]). + last_move: Option, /// The move snapped to a point or a guide at its last step: the arrow turns hollow. snapped: bool, guide: GuideEdit, @@ -195,6 +200,12 @@ impl MoveSnap { } } + /// Snap to Grid and Snap to Pixel alone, for a move whose box is `bounds`: the grid lands an + /// edge of the box on a gridline, and pixels put its top-left on whole pixels. + pub(crate) fn grid_only(bounds: Option) -> Self { + Self { bounds, targets: None, points: None, copy_targets: None, copy_points: None, rulers: None } + } + /// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its /// guides. pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { @@ -460,6 +471,33 @@ pub(crate) fn hold_point(cx: &ToolContext, h: &Hit, p: Point) -> Point { cx.doc.node(h.leaf).and_then(|n| n.path_data()).and_then(|pd| hold_on_path(pd, p, reach)).unwrap_or(p) } +/// While an opacity mask or a pattern is edited, a Shaper double-click selects a Shaper Group's +/// original shape without isolating the group's original shapes. A press at `p` on a selected +/// original shape holds it for a move: the hit inside its Shaper Group's original shapes, and that +/// container. Each selected original shape's Shaper Group is checked, front to back, and an object +/// in front of that group at `p` keeps the press. None otherwise. +fn selected_source_hit(cx: &ToolContext, p: Point) -> Option<(Hit, NodeId)> { + if cx.doc.mask_edit.is_none() && cx.doc.pattern_edit.is_none() { + return None; + } + let is_sources = |g: &NodeId| cx.doc.node(*g).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)); + let mut containers = cx.doc.paint_order(cx.selection.objects.iter().filter_map(|id| cx.doc.parent_of(*id)).filter(is_sources)); + containers.dedup(); + let front = hit_test(cx.doc, p, cx.selection_hit_options()); + containers.into_iter().rev().find_map(|sources| { + let group = cx.doc.parent_of(sources)?; + if let Some(f) = &front + && f.leaf != group + && !f.ancestry.contains(&group) + && cx.doc.paint_order([group, f.leaf]).last() == Some(&f.leaf) + { + return None; + } + let h = hit_test(cx.doc, p, vectorcraft_doc::hit::HitOptions { scope: Some(sources), ..cx.selection_hit_options() })?; + cx.selection.contains(h.top_object(Some(sources))).then_some((h, sources)) + }) +} + impl SelectionTool { fn drag_threshold(cx: &ToolContext) -> f64 { cx.tol(3.0) @@ -559,7 +597,11 @@ impl Tool for SelectionTool { } /// E during a marquee drag switches the marquee between the objects it touches and only the - /// objects inside it. + /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or + /// rotation, a marquee, a corner widget, a pie widget, the brackets of type on a path, a mirror + /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy + /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape + /// drops the text. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Letter('e') && let State::Marquee { enclosed, .. } = &mut self.state @@ -583,6 +625,13 @@ impl Tool for SelectionTool { self.state = State::Idle; return vec![]; } + if key == ToolKey::Escape && !matches!(self.state, State::Idle) { + self.state = State::Idle; + self.measure = None; + self.guides.clear(); + (self.targets, self.moving, self.snapped) = (None, None, false); + return vec![Action::Cancel]; + } if key == ToolKey::Escape { self.guide.cancel() } else { vec![] } } @@ -662,9 +711,13 @@ impl Tool for SelectionTool { self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } - match hit_test(cx.doc, p, cx.selection_hit_options()) { + let (hit, scope) = match selected_source_hit(cx, p) { + Some((h, sources)) => (Some(h), Some(sources)), + None => (hit_test(cx.doc, p, cx.selection_hit_options()), cx.isolation), + }; + match hit { Some(h) => { - let top = h.top_object(cx.isolation); + let top = h.top_object(scope); let mut out = vec![]; let (mut deselect, mut key) = (None, None); if m.shift { @@ -759,8 +812,17 @@ impl Tool for SelectionTool { self.measure = None; self.guides.clear(); self.moving = None; + let last = self.last_move.take(); match (began, deselect, key) { - (true, ..) => vec![Action::Commit], + // In Construction mode, an original shape dropped wholly outside the group's + // bounds leaves the group, in the move's undo step (`releaseOutside`). + (true, ..) => match last.filter(|p| construction(cx) && p["copy"] != json!(true)) { + Some(mut params) => { + params["releaseOutside"] = json!(true); + vec![Action::Preview("object.transform".into(), params), Action::Commit] + } + None => vec![Action::Commit], + }, (false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], // A click on the key object again: no key. (false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))], @@ -883,6 +945,9 @@ impl Tool for SelectionTool { if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) { return Cursor::ArrowHollow; } + if selected_source_hit(cx, p).is_some() { + return Cursor::Move; + } if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) { if cx.selection.contains(h.top_object(cx.isolation)) || m.alt { return Cursor::Move; @@ -912,6 +977,11 @@ fn can_be_key(cx: &ToolContext, id: NodeId) -> bool { cx.doc.node(id).is_some_and(|n| !n.is_layer()) && !inside_selected && !member } +/// Shaper's Construction mode: a Shaper Group's original shapes are isolated. +fn construction(cx: &ToolContext) -> bool { + cx.isolation.and_then(|id| cx.doc.node(id)).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)) +} + /// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't: /// it transforms whole. pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { @@ -1139,7 +1209,9 @@ impl SelectionTool { self.snapped = (d - unsnapped).hypot() > 1e-9; self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); - out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); + let params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }); + self.last_move = Some(params.clone()); + out.push(Action::Preview("object.transform".into(), params)); out } } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index f777d59cce..8ebbe9c68a 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2,6 +2,64 @@ use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; +/// While an opacity mask is edited, a press on a selected Shaper original shape moves it, over the +/// art the group shows and over a part of the shape the group does not show. A press on an original +/// shape that is not selected selects the Shaper Group or starts a marquee, as before. +#[test] +fn a_press_on_a_selected_shaper_source_moves_it_while_a_mask_is_edited() { + let (mut d, g, sources, _) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let layer = d.layers[0].id; + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + let ids: Vec = d.node(sources).unwrap().children().unwrap().iter().map(|n| n.id).collect(); + let (mut s, p) = (Selection::default(), paint()); + s.set([ids[1]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + for x in [75.0, 125.0] { + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(x, 350.0), Mods::default()), Cursor::Move, "at {x}"); + let down = t.pointer(&c, &ev(PointerKind::Down, x, 350.0)); + assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "at {x}: {down:?}"); + let drag = t.pointer(&c, &ev(PointerKind::Drag, x + 20.0, 350.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "at {x}: {drag:?}"); + } + s.set([ids[0]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 125.0, 350.0)); + assert!(down.is_empty(), "{down:?}"); + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 75.0, 350.0)); + assert!(down.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([g.0]))), "{down:?}"); +} + +/// While an opacity mask is edited, a selected Shaper original shape that is not the first selected +/// object still moves on a press, and an object in front of the Shaper Group keeps the press. +#[test] +fn a_selected_shaper_source_moves_after_other_objects_and_behind_front_art() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _, sources, _) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let layer = d.layers[0].id; + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + let ids: Vec = d.node(sources).unwrap().children().unwrap().iter().map(|n| n.id).collect(); + let (mut s, p) = (Selection::default(), paint()); + s.set([rect, ids[1]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(125.0, 350.0), Mods::default()), Cursor::Move); + let down = t.pointer(&c, &ev(PointerKind::Down, 125.0, 350.0)); + assert!(!down.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{down:?}"); + let drag = t.pointer(&c, &ev(PointerKind::Drag, 145.0, 350.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "object.transform")), "{drag:?}"); + let front = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(110.0, 340.0, 140.0, 360.0)); + d.insert(Some(layer), 2, Node::path(front, square, Appearance::default_art())).unwrap(); + s.set([ids[1]]); + let c = ToolContext { isolation: Some(layer), ..cx(&d, &s, &p) }; + assert_ne!(SelectionTool::default().cursor(&c, Point::new(125.0, 350.0), Mods::default()), Cursor::Move); + let down = SelectionTool::default().pointer(&c, &ev(PointerKind::Down, 125.0, 350.0)); + assert!(down.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([front.0]))), "{down:?}"); +} + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { PointerEvent::new(kind, x, y) } @@ -102,6 +160,44 @@ fn a_corner_drag_counts_as_transforming_once_it_moves() { assert!(t.transforming()); } +/// Escape during a corner widget drag cancels it, and the release after it commits nothing. +/// After an Alt-press on a widget, Escape drops the press, so its release cycles no corner kind. +#[test] +fn escape_cancels_a_corner_widget_drag() { + use vectorcraft_doc::{Appearance, LiveShape, Node, NodeKind}; + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), + }; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let id = d.alloc_id(); + let mut n = Node::path(id, live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 175.0, 176.0)); + assert_eq!(a.first(), Some(&Action::Begin("Corner Radius".into())), "{a:?}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 171.0)).is_empty(), "the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 171.0)).is_empty(), "the release commits nothing"); + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 190.0, 191.0).with_mods(alt)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 190.0, 191.0).with_mods(alt)).is_empty(), "the Alt-click cycles no corner kind"); +} + /// A type-on-path bracket drag is a transform, so the sharp selection box hides while the span moves. #[test] fn a_bracket_drag_counts_as_transforming_once_it_moves() { @@ -118,6 +214,25 @@ fn a_bracket_drag_counts_as_transforming_once_it_moves() { assert!(t.transforming()); } +/// Escape during a drag of the brackets of type on a path cancels it, and the release after it +/// commits nothing. +#[test] +fn escape_cancels_a_bracket_drag() { + let (d, id) = crate::testutil::doc_with_path_type(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 280.0, 290.0)).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 320.0, 290.0)); + assert!(matches!(a.first(), Some(Action::Begin(_))), "{a:?}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the drag"); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 330.0, 290.0)).is_empty(), "the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 330.0, 290.0)).is_empty(), "the release commits nothing"); +} + /// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a /// press there grabs the object by that point. #[test] @@ -460,6 +575,30 @@ fn drag_moves_with_single_undo() { assert_eq!(a, vec![Action::Commit]); } +/// Escape during a move, or an Alt-drag copy, cancels it: the release after it commits nothing, +/// and the next drag is a new one. Escape with nothing to cancel sends nothing. +#[test] +fn escape_cancels_a_move_or_an_alt_drag_copy() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + for (what, m) in [("move", Mods::default()), ("copy", Mods { alt: true, ..Default::default() })] { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0).with_mods(m)); + assert!(matches!(&a[1], Action::Preview(_, v) if v["copy"] == m.alt), "{what}: {a:?}"); + assert!(t.transforming(), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy() && !t.transforming(), "{what}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 150.0).with_mods(m)).is_empty(), "{what}: the release commits nothing"); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty(), "{what}: nothing to cancel"); + } +} + /// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps /// to a ruler guide within the Snap to Point distance; not with Snap to Point off. #[test] @@ -907,6 +1046,22 @@ fn marquee_selects() { assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); } +/// Escape during a marquee drag ends it: the marquee is removed, and its release selects nothing. +#[test] +fn escape_cancels_a_marquee() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "Escape cancels the marquee"); + assert!(!t.busy()); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee is removed"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)).is_empty(), "the release selects nothing"); +} + /// Shift-drag a marquee: the objects it reaches toggle (#483), so selected ones can be /// taken out of the selection; a Shift-click on empty canvas leaves the selection alone. #[test] @@ -1099,6 +1254,28 @@ fn handle_drag_scales() { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } +/// Escape during a bounding-box scale or rotation cancels it, and the release after it commits +/// nothing. +#[test] +fn escape_cancels_a_scale_or_a_rotation() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // (what, the press, its Begin, the drag) + for (what, from, begin, to) in [("scale", (200.0, 200.0), "Scale", (300.0, 300.0)), ("rotate", (210.0, 210.0), "Rotate", (150.0, 230.0))] { + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1)), vec![Action::Begin(begin.into())], "{what}"); + assert!(matches!(t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)).as_slice(), [Action::Preview(..)]), "{what}"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{what}: Escape cancels the drag"); + assert!(!t.busy() && !t.transforming(), "{what}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Measure { .. })), "{what}: no readout stays"); + assert!(t.pointer(&cx, &ev(PointerKind::Drag, to.0 + 5.0, to.1)).is_empty(), "{what}: the drag is over"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, to.0 + 5.0, to.1)).is_empty(), "{what}: the release commits nothing"); + } +} + #[test] fn handle_drag_snaps_to_the_other_objects_with_smart_guides() { use vectorcraft_doc::{Appearance, Node}; diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 62679d99e3..f8594226ce 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -75,12 +75,15 @@ impl ShapeTool { } } - /// Ctrl/Cmd in a star drag from `start` to `p`: the inner radius stays as it was when the key - /// went down, so the ratio follows the outer radius (and stays so once the key is let go). - fn hold_inner(&mut self, start: Point, p: Point, m: Mods) { + /// Ctrl/Cmd in a star drag from `start` to `p`, where the last drag showed a star with the inner + /// radius `shown`: once the star has begun, the inner radius stays as the star showed it when + /// the key went down, so the ratio follows the outer radius (and stays so once the key is let + /// go). When the key goes down before the star has begun, the inner radius stays as the first + /// star the drag shows has it. + fn hold_inner(&mut self, shown: f64, start: Point, p: Point, m: Mods) { let outer = (p - start).hypot(); match (m.cmd, self.inner) { - (true, None) => self.inner = Some(outer * self.star_ratio), + (true, None) if self.began => self.inner = Some(shown), (false, Some(_)) => self.inner = None, _ => {} } @@ -224,17 +227,23 @@ impl Tool for ShapeTool { self.start = Some(p); self.last = ev.pos; self.began = false; + self.inner = None; vec![] } PointerKind::Drag => { let Some(mut s) = self.start else { return vec![] }; let pos = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref()); let ev = &PointerEvent { pos, ..*ev }; + // The inner radius of the star the last drag showed, with the ratio it drew with: + // the canvas sends a drag only once the pointer moves, so a key pressed in between + // first arrives with the next position. + let ratio = if self.mods.alt { straight_ratio(self.points).unwrap_or(self.star_ratio) } else { self.star_ratio }; + let shown = (self.last - s).hypot() * ratio; space_moves(&mut s, &mut self.last, ev, self.began); self.start = Some(s); self.mods = ev.mods; if self.id == "star" { - self.hold_inner(s, ev.pos, ev.mods); + self.hold_inner(shown, s, ev.pos, ev.mods); } let mut out = vec![]; if !self.began { @@ -299,6 +308,7 @@ impl Tool for ShapeTool { } else if key == ToolKey::Escape { self.start = None; self.began = false; + self.inner = None; self.snap.clear(); vec![Action::Cancel] } else { diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 66a4abad9a..da4916576e 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -106,8 +106,10 @@ fn star_measure_label_matches_the_preview_with_modifiers_and_shrink() { assert_eq!(label(&t), cx.star_radii_label(r1, r2)); } -/// During a star drag, Ctrl/Cmd keeps the inner radius while the outer one follows the pointer -/// (the new ratio stays once the key is let go), and Alt keeps the shoulders straight (#812). +/// During a star drag, Ctrl/Cmd keeps the inner radius the star showed when the key went down +/// while the outer one follows the pointer (the new ratio stays once the key is let go), and Alt +/// keeps the shoulders straight (#812). The canvas sends a drag only once the pointer moves, so +/// the key first arrives with a new position. #[test] fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { let (d, _) = doc_with_rect(); @@ -123,8 +125,7 @@ fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0))); assert_eq!((r1, r2), (40.0, 20.0)); - // Ctrl: the inner radius stays 20 as the outer one grows to 80. - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl)); + // Ctrl, pressed with the pointer still: the inner radius stays 20 as the outer one grows to 80. let (r1, r2) = radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl))); assert!((r1 - 80.0).abs() < 1e-9 && (r2 - 20.0).abs() < 1e-9, "{r1} {r2}"); // Let go: the ratio (a quarter) stays. @@ -138,6 +139,54 @@ fn a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt() { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 50.0)), vec![Action::Commit]); } +/// Ctrl/Cmd pressed while Alt straightens the shoulders keeps the straightened inner radius, a drag +/// canceled with Escape leaves no held radius for the next star, and Ctrl/Cmd pressed inside the +/// drag threshold keeps the inner radius of the first star the drag shows. The canvas lends a +/// selection tool to a press with Ctrl/Cmd held, so every press here is without it. +#[test] +fn a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let radii = |a: &[Action]| match a.last() { + Some(Action::Preview(_, v)) => (v["radius1"].as_f64().unwrap(), v["radius2"].as_f64().unwrap()), + other => panic!("{other:?}"), + }; + let close = |(r1, r2): (f64, f64), (w1, w2): (f64, f64)| assert!((r1 - w1).abs() < 1e-9 && (r2 - w2).abs() < 1e-9, "{r1} {r2}, not {w1} {w2}"); + let ctrl = Mods { cmd: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let ctrl_alt = Mods { cmd: true, alt: true, ..Default::default() }; + // Alt shows straight shoulders; Ctrl/Cmd pressed then keeps that inner radius. + let mut t = ShapeTool::new("star"); + let straight = straight_ratio(5).unwrap_or(0.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(alt))), (40.0, 40.0 * straight)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl_alt)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 170.0, 50.0).with_mods(ctrl))), (120.0, 40.0 * straight)); + + // Escape with the key held, then a new press and the key before the first move: the next star + // holds what it shows, not the canceled radius. + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)); + assert_eq!(t.key(&cx, ToolKey::Escape, ctrl), vec![Action::Cancel]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 2.5)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 2.5)); + + // Ctrl/Cmd pressed inside the drag threshold, where no star is shown yet. + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 51.0, 50.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0).with_mods(ctrl)); + assert_eq!(a[0], Action::Begin("Star".into())); + close(radii(&a), (40.0, 20.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl))), (80.0, 20.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 130.0, 50.0).with_mods(ctrl)), vec![Action::Commit]); +} + #[test] fn shift_square_alt_center() { let t = ShapeTool::new("ellipse"); diff --git a/crates/tools/src/shaperwidget.rs b/crates/tools/src/shaperwidget.rs index 0eb1bcb190..f44a622a1c 100644 --- a/crates/tools/src/shaperwidget.rs +++ b/crates/tools/src/shaperwidget.rs @@ -1,6 +1,7 @@ //! The Shaper Group's mode widget: a small arrow beside the right side of the bounds of a Shaper //! Group's source shapes. It shows while the group or one of its faces is selected (Face Selection -//! mode) and while the group's source shapes are isolated (Construction mode). A click on it +//! mode) and while the group's source shapes are isolated (Construction mode), but not while an +//! opacity mask or a pattern is edited. A click on it //! switches between the two modes: it isolates the source shapes (`object.isolate`), or it leaves //! isolation with the group selected (`object.exitIsolation`). The Shaper and Selection tools hit //! it and the canvas draws it from the same geometry. @@ -33,9 +34,10 @@ impl ShaperWidget { /// The widget at `zoom` (screen px per document point), beside the bounds of the group's source /// shapes in both modes. Construction mode: `isolation` is a Shaper Group's source shapes. Face /// Selection mode: the selection is one Shaper Group, or one face or stroke of it. It returns - /// None otherwise, and when the group, or a layer or group that holds it, is hidden or locked. + /// None otherwise, when the group, or a layer or group that holds it, is hidden or locked, and + /// while an opacity mask or a pattern is edited. pub fn of(doc: &Document, selection: &Selection, isolation: Option, zoom: f64) -> Option { - if !zoom.is_finite() || zoom <= 0.0 { + if !zoom.is_finite() || zoom <= 0.0 || doc.mask_edit.is_some() || doc.pattern_edit.is_some() { return None; } let shaper = |id: NodeId| doc.node(id).is_some_and(|n| n.extras().shaper.is_some()); @@ -153,4 +155,21 @@ mod tests { assert!(widget(&d, &[g]).is_none()); assert!(widget(&d, &[face]).is_none()); } + + #[test] + fn no_widget_shows_while_an_opacity_mask_or_a_pattern_is_edited() { + let (mut d, g, sources, _) = doc_with_shaper_group(); + let rect = d.layers[0].children().unwrap()[0].id; + let layer = d.layers[0].id; + let mut s = Selection::default(); + s.add(g); + assert!(ShaperWidget::of(&d, &s, None, 1.0).is_some()); + d.mask_edit = Some(vectorcraft_doc::MaskEdit { object: rect, layer }); + assert!(ShaperWidget::of(&d, &s, Some(layer), 1.0).is_none(), "mask editing"); + assert!(ShaperWidget::of(&d, &Selection::default(), Some(sources), 1.0).is_none(), "mask editing, sources isolated"); + d.mask_edit = None; + d.pattern_edit = Some(vectorcraft_doc::pattern::PatternEdit { pattern: "P".into(), layer, original: None }); + assert!(ShaperWidget::of(&d, &s, Some(layer), 1.0).is_none(), "pattern editing"); + assert!(ShaperWidget::of(&d, &Selection::default(), Some(sources), 1.0).is_none(), "pattern editing, sources isolated"); + } } diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index 15b956bae7..3f2b350e01 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -1,17 +1,20 @@ //! Measure tool: drag between two points to read distance, angle, dX and dY (Shift constrains to //! 45°). The result stays on screen (and in the tool options, for agents) until the next drag; the -//! document is never changed. +//! document is never changed. Escape during a drag cancels it: the result of the drag before +//! comes back. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; use super::CYAN; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Default)] pub struct MeasureTool { line: Option<(Point, Point)>, dragging: bool, + /// The line measured before the drag under way, which Escape puts back. + before: Option<(Point, Point)>, } /// (distance, angle in degrees counter-clockwise, dx, dy) @@ -33,6 +36,7 @@ impl Tool for MeasureTool { match ev.kind { PointerKind::Down => { let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); + self.before = self.line; self.line = Some((p, p)); self.dragging = true; } @@ -52,6 +56,14 @@ impl Tool for MeasureTool { vec![] } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.dragging { + self.dragging = false; + self.line = self.before; + } + vec![] + } + fn overlays(&self, cx: &ToolContext) -> Vec { let Some((a, b)) = self.line else { return vec![] }; if a == b { @@ -128,4 +140,39 @@ mod tests { assert!(!ov.iter().any(|o| matches!(o, Overlay::Measure { .. })), "Measurement Labels off hides the readout"); assert!(ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "the measured line stays"); } + + /// Escape during a drag ends it and brings back the result of the drag before (none at + /// first); the release after it measures nothing. + #[test] + fn escape_cancels_a_measure_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = MeasureTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -30.0)); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy()); + assert_eq!(t.options(), Value::Null, "no earlier result"); + assert!(t.overlays(&cx).is_empty()); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -30.0)); + assert_eq!(t.options(), Value::Null, "the release measures nothing"); + // A measurement, then a second drag canceled: the first one's result is back. + for (kind, x, y) in [(PointerKind::Down, 10.0, 10.0), (PointerKind::Drag, 40.0, -30.0), (PointerKind::Up, 40.0, -30.0)] { + t.pointer(&cx, &PointerEvent::new(kind, x, y)); + } + let first = t.options(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 100.0)); + assert!(t.key(&cx, ToolKey::Enter, Mods::default()).is_empty()); + assert!(t.busy(), "Enter leaves the drag alone"); + t.key(&cx, ToolKey::Escape, Mods::default()); + assert!(!t.busy()); + assert_eq!(t.options(), first); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 130.0, 100.0)); + assert_eq!(t.options(), first); + assert!((t.options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9); + } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 67fae641b3..8c32eceb7d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -59,6 +59,8 @@ impl VectorcraftApp { from_worker: false, waits_for_still: cfg!(target_arch = "wasm32"), still: None, + fill: None, + fill_worker: None, worker: None, worker_started: false, slices: None, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index b32c64c4f0..9c9e41e7d8 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -24,6 +24,12 @@ pub struct CanvasCache { /// The cache key the canvas last asked for and when it first asked for it (seconds, egui's /// input time), kept while [`Self::waits_for_still`] is set. pub still: Option<(CacheKey, f64)>, + /// The low-resolution frame that covers the parts of the canvas the frame shown leaves + /// uncovered while it is drawn for a view it was not rendered for ([`canvas::Fill`]). + pub fill: Option, + /// The thread that renders [`Self::fill`], beside [`Self::worker`]. None on wasm, which has no + /// fill. + pub fill_worker: Option, pub worker: Option, pub worker_started: bool, /// The slices as laid out for (document uid, revision): the canvas draws them every frame. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 400a32d03f..0de1171bd1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1,6 +1,7 @@ //! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas //! selection visuals (bounding box, anchors, handles, smart-guide style labels). +use egui::emath::GuiRounding as _; use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use serde_json::json; use vectorcraft_doc::text::TextKind; @@ -18,6 +19,7 @@ mod artboards; mod context; mod cursor; mod drag; +mod fill; mod grid; mod home; mod input; @@ -39,6 +41,10 @@ pub use drag::{apply_requests, dispatch, mods}; pub(crate) use drag::{end_document_drag, release_art_in_window, release_over_panel}; #[cfg(test)] use drag::{frame_moves, tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_press_doc_id}; +pub use fill::Fill; +use fill::fill_uncovered; +#[cfg(test)] +use fill::{fill_view, polygon_area}; #[cfg(test)] use grid::ruler_rects; use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds}; @@ -196,6 +202,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let bars = app.ui.screen_mode < 2; let full = if bars { egui::Rect::from_min_max(full.min, full.max - vec2(SCROLL_BAR, SCROLL_BAR)) } else { full }; let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full }; + // Dock layout can differ by fractions of a device pixel between fresh windows. + // Use one pixel-aligned rectangle for drawing, inspection and pointer transforms. + let rect = rect.round_to_pixels(ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); let fitted = app.view().is_some_and(|v| v.fitted); if !fitted { @@ -277,6 +286,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if undecoded > 0 { app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes")); } + fill_uncovered(app, ui.ctx(), &painter, &key, &doc, rect, &xf); reproject_frame(app, &painter, &xf, rect, (key.doc, v.rotation)); if let Some(look) = grid_look.filter(|_| !grids_in_back) { grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); @@ -317,8 +327,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_bands(app, ui, &xf, (full, rect), origin_preview, &t); } - // The task bar is not drawn while the Simplify slider shows. - if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) { + // The task bar is not drawn while the Simplify or Smooth slider shows. + let strip = crate::dialogs::simplify_slider::showing(app) || crate::dialogs::smooth_slider::showing(app); + if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !strip { task_bar(app, ui, &xf); } if app.ui.screen_mode < 3 { @@ -485,6 +496,7 @@ fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) { app.canvas.worker_started = true; if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() { app.canvas.worker = crate::render_worker::Worker::spawn(ui.ctx().clone()); + app.canvas.fill_worker = crate::render_worker::Worker::spawn(ui.ctx().clone()); } } // Upload finished background renders. diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index abd4e0cf88..6d39883be8 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -66,7 +66,7 @@ pub(super) fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&Cac /// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel /// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both. -fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool { +pub(super) fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool { a.pixel.is_some() == b.pixel.is_some() && *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() } } diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs new file mode 100644 index 0000000000..f68c175585 --- /dev/null +++ b/crates/ui-egui/src/canvas/fill.rs @@ -0,0 +1,198 @@ +use super::cursor::only_view_differs; +use super::{Affine, CacheKey, Color32, Point, Pos2, Shape, VectorcraftApp, Xf, color_image, pos2, vec2}; + +/// The fraction of the canvas's resolution, each way, that [`Fill`] is rendered at. +const FILL_SCALE: f64 = 0.25; +/// How many times the canvas's width and height [`Fill`] covers, around the view it is rendered +/// for. A pan or zoom-out that stays within it draws it again without rendering it. +const FILL_REACH: f64 = 2.0; + +/// [`crate::CanvasCache::fill`]: a frame of the art from [`crate::CanvasCache::fill_worker`], +/// rendered for the canvas key `key` as [`fill_view`] gives, and its texture. +pub struct Fill { + /// The document, revision and view it was rendered for. + pub(super) key: CacheKey, + /// Document → its pixels. + pub(super) view: Affine, + /// Its size in pixels. + pub(super) size: (u32, u32), + pub(super) tex: egui::TextureHandle, +} + +/// The size in pixels and the view (document → its pixels) of the fill for the canvas key `key`: +/// [`FILL_SCALE`] of the canvas's resolution, covering [`FILL_REACH`] times the canvas's width and +/// height around the view of `key`. +pub(super) fn fill_view(key: &CacheKey) -> (u32, u32, Affine) { + let fw = (f64::from(key.w) * FILL_REACH * FILL_SCALE).ceil().clamp(1.0, f64::from(u16::MAX)) as u32; + let fh = (f64::from(key.h) * FILL_REACH * FILL_SCALE).ceil().clamp(1.0, f64::from(u16::MAX)) as u32; + let ppp = if key.ppp > 0.0 { f64::from(key.ppp) } else { 1.0 }; + let view = Affine::translate((f64::from(fw) / 2.0, f64::from(fh) / 2.0)) + * Affine::rotate(key.rot.to_radians()) + * Affine::scale(key.zoom * ppp * FILL_SCALE) + * Affine::translate((-key.cx, -key.cy)); + (fw, fh, view) +} + +/// While the frame shown is drawn for a view it was not rendered for, and its key differs from +/// `key` in the view alone (the frame for `key` is on its way from the render worker), the parts +/// of the canvas `rect` it leaves uncovered show `doc` from [`crate::CanvasCache::fill`]. The fill +/// worker ([`crate::CanvasCache::fill_worker`]) renders it with the frame shown's options and these +/// changes: images draw only from pixels already decoded +/// ([`vectorcraft_render::RenderOptions::kept_images_only`]) and draw mips +/// ([`vectorcraft_render::RenderOptions::image_mips`]), raster effects render within a drag's +/// budget ([`vectorcraft_render::RenderOptions::dragging`]), and rasters cached for moved objects +/// are not reused ([`vectorcraft_render::RenderOptions::interacting`] off). This thread only copies a +/// finished fill into its texture and draws it. A fill is asked for again when the one shown or on +/// its way does not cover the canvas, when the view's zoom is more than twice or less than half +/// its own, and when the document, its revision or anything but the view changed; until it +/// arrives, the fill shown is drawn where it reaches. It is dropped once the canvas shows the frame +/// for `key`, and while the frame shown differs from `key` in more than the view. +pub(super) fn fill_uncovered( + app: &mut VectorcraftApp, + ctx: &egui::Context, + painter: &egui::Painter, + key: &CacheKey, + doc: &std::sync::Arc, + rect: egui::Rect, + xf: &Xf, +) { + // Taken every frame, so a fill that is no longer needed does not stay queued. + let done = app.canvas.fill_worker.as_mut().and_then(|w| w.poll()); + let (Some(k), Some((_, opts))) = (&app.canvas.key, &app.canvas.shown) else { + app.canvas.fill = None; + return; + }; + // The fill takes the frame shown's options, which match `key`'s only when the two keys differ + // in the view alone. + if k == key || !only_view_differs(k, key) || app.canvas.texture.is_none() { + app.canvas.fill = None; + return; + } + let screen_to_doc = xf.affine().inverse(); + let corners = + [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|p| screen_to_doc * Point::new(p.x as f64, p.y as f64)); + let usable = |f: &CacheKey| only_view_differs(f, key) && (0.5..=2.0).contains(&(key.zoom / f.zoom)); + let covers = |f: &CacheKey| { + let (w, h, view) = fill_view(f); + usable(f) + && corners.iter().all(|c| { + let p = view * *c; + (-0.5..=f64::from(w) + 0.5).contains(&p.x) && (-0.5..=f64::from(h) + 0.5).contains(&p.y) + }) + }; + let opts = vectorcraft_render::RenderOptions { kept_images_only: true, image_mips: true, interacting: false, dragging: true, ..opts.clone() }; + let gaps = shown_frame(k, rect, xf).map_or_else(Vec::new, |shown| uncovered(shown, rect)); + if let Some(done) = done.filter(|d| usable(&d.key)) { + let color = color_image(&done.img); + let tex = match app.canvas.fill.take() { + Some(mut f) => { + f.tex.set(color, egui::TextureOptions::LINEAR); + f.tex + } + None => ctx.load_texture("canvas-fill", color, egui::TextureOptions::LINEAR), + }; + let (_, _, view) = fill_view(&done.key); + app.canvas.fill = Some(Fill { key: done.key, view, size: (done.img.width, done.img.height), tex }); + } + let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return }; + if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) { + let (w, h, view) = fill_view(key); + worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }); + } + let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return }; + let to_uv = Affine::scale_non_uniform(1.0 / f64::from(f.size.0.max(1)), 1.0 / f64::from(f.size.1.max(1))) * f.view * screen_to_doc; + let from_uv = to_uv.inverse(); + let mut mesh = egui::Mesh::with_texture(f.tex.id()); + for gap in gaps { + // Only where the fill reaches: its pixels are 0..1 each way in uv. + let uv: Vec = gap.iter().map(|p| to_uv * Point::new(p.x as f64, p.y as f64)).collect(); + let uv = clip_polygon(&clip_polygon(&uv, |p| p.x, 0.0, false), |p| p.x, 1.0, true); + let uv = clip_polygon(&clip_polygon(&uv, |p| p.y, 0.0, false), |p| p.y, 1.0, true); + if uv.len() < 3 { + continue; + } + let first = mesh.vertices.len() as u32; + for p in &uv { + let pos = from_uv * *p; + mesh.vertices.push(egui::epaint::Vertex { + pos: pos2(pos.x as f32, pos.y as f32), + uv: pos2(p.x as f32, p.y as f32), + color: Color32::WHITE, + }); + } + for i in 1..uv.len() as u32 - 1 { + mesh.indices.extend([first, first + i, first + i + 1]); + } + } + painter.add(Shape::mesh(mesh)); +} + +/// Where the canvas draws the frame shown (its key `k`) in the view `xf` of the canvas `rect`: +/// the unit square (the frame's texture coordinates) → screen points. None when the mapping is +/// degenerate. +fn shown_frame(k: &CacheKey, rect: egui::Rect, xf: &Xf) -> Option { + let to_screen = match k.pixel { + Some([x0, y0, x1, y1]) => { + xf.affine() * Affine::translate((x0 as f64, y0 as f64)) * Affine::scale_non_uniform((x1 - x0) as f64, (y1 - y0) as f64) + } + None => { + let ppp = if k.ppp > 0.0 { k.ppp } else { 1.0 }; + let old_rect = egui::Rect::from_center_size(rect.center(), vec2(k.w as f32 / ppp, k.h as f32 / ppp)); + let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() }; + xf.affine() + * old.affine().inverse() + * Affine::translate((old_rect.min.x as f64, old_rect.min.y as f64)) + * Affine::scale_non_uniform(old_rect.width() as f64, old_rect.height() as f64) + } + }; + let det = to_screen.determinant(); + (det.is_finite() && det.abs() > 1e-12).then_some(to_screen) +} + +/// The parts of `rect` outside the parallelogram `shown` maps the unit square to, as convex +/// polygons of screen points, leaving out those thinner than half a point (a frame whose pixel +/// size was rounded from the canvas's leaves such slivers along its edges). +fn uncovered(shown: Affine, rect: egui::Rect) -> Vec> { + // In the unit square's coordinates the canvas is a convex polygon and the frame shown is + // 0..1 each way: the parts left of it, right of it, and above and below it in between. + let inv = shown.inverse(); + let canvas: Vec = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()] + .iter() + .map(|p| inv * Point::new(p.x as f64, p.y as f64)) + .collect(); + let left = clip_polygon(&canvas, |p| p.x, 0.0, true); + let right = clip_polygon(&canvas, |p| p.x, 1.0, false); + let between = clip_polygon(&clip_polygon(&canvas, |p| p.x, 0.0, false), |p| p.x, 1.0, true); + let above = clip_polygon(&between, |p| p.y, 0.0, true); + let below = clip_polygon(&between, |p| p.y, 1.0, false); + [left, right, above, below] + .into_iter() + .map(|part| part.into_iter().map(|p| shown * p).map(|p| pos2(p.x as f32, p.y as f32)).collect::>()) + .filter(|part| { + // Twice the area over the perimeter: about the width of a thin strip. + let perimeter: f32 = part.iter().zip(part.iter().cycle().skip(1)).map(|(a, b)| a.distance(*b)).sum(); + perimeter > 0.0 && 2.0 * polygon_area(part) / perimeter > 0.5 + }) + .collect() +} + +/// The part of convex polygon `poly` where `along(p)` is below `at` (`below`) or above it. +fn clip_polygon(poly: &[Point], along: impl Fn(&Point) -> f64, at: f64, below: bool) -> Vec { + let inside = |p: &Point| if below { along(p) <= at } else { along(p) >= at }; + let mut out = Vec::with_capacity(poly.len() + 1); + for (a, b) in poly.iter().zip(poly.iter().cycle().skip(1)) { + if inside(a) { + out.push(*a); + } + if inside(a) != inside(b) { + let t = (at - along(a)) / (along(b) - along(a)); + out.push(a.lerp(*b, t)); + } + } + out +} + +/// The area of polygon `poly` (shoelace formula). +pub(super) fn polygon_area(poly: &[Pos2]) -> f32 { + poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::().abs() / 2.0 +} diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index a81d8307f4..461730c6ed 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -117,7 +117,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none())); } else if is_group { items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none())); - items.push((tl!("Isolate"), "square-dashed", "object.isolate", none())); + // Isolate is left out while Enter Isolation Mode is off, as it is while an opacity mask is edited. + if crate::menus::enabled(app, "object.isolate") { + items.push((tl!("Isolate"), "square-dashed", "object.isolate", none())); + } } else if is_compound { items.push(edit_path()); items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none())); @@ -141,14 +144,16 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } - let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default(); + // The paints the Fill and Stroke proxies show, "?" when the selected objects' paints differ. + let (fill, stroke) = crate::panels::current_paints(app); + let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; // Under the selection's lowest corner on screen, centered on the selection (the view may be // rotated). let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); let anchor = pos2(xf.to_screen(b.center()).x, lowest); - let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let est_w = 150.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); // Its place under the selection, then where the handle moved it, kept on the canvas (with the // bar's size last frame, the estimate before it first shows). let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); @@ -175,6 +180,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let pinned = place.pinned; let mut moved = Vec2::ZERO; let mut run: Option<(&str, serde_json::Value)> = None; + let mut show_swatches = false; egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.panel) @@ -207,19 +213,24 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { run = Some((*cmd, params.clone())); } } - // The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool. + // The selected objects' Fill, Stroke, Recolor Artwork and Lock, left out with the Artboard tool. if !on_artboards { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill); - ui.painter().rect_stroke( - egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), - 0.0, - Stroke::new(1.0, t.button_border), - StrokeKind::Outside, - ); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, &fill, fill_mixed, None); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); if resp.on_hover_text(tl!("Fill")).clicked() { app.session.fill_active = true; - app.ui.open_panel = Some("swatches".into()); + show_swatches = true; + } + // The Stroke chip, drawn as a ring as the Control bar's is. + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, &stroke, stroke_mixed, Some(4.0)); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); + if resp.on_hover_text(tl!("Stroke")).clicked() { + app.session.fill_active = false; + show_swatches = true; } // Recolor Artwork, when the selection has colours to recolour. if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() { @@ -262,6 +273,14 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } ui.ctx().request_repaint(); } + // The Swatches panel shows where it is docked, floating or closed. A panel in an icon column + // toggles when activated, so an open flyout stays as it is. + if show_swatches + && app.ui.open_panel.as_deref() != Some("swatches") + && let Err(e) = app.run("window.panel.activate", json!({ "panel": "swatches" })) + { + app.ui.status = e; + } match run { // Image Trace traces the image at once with the Default preset. As a quick trace from // Properties or the Control bar, it leaves the Image Trace panel as it was. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index bb76684dc6..7aa53c22e6 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2,6 +2,32 @@ use super::*; use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; +#[test] +fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { + let mut observed = Vec::new(); + for width in [1324.0 + 29.0 / 32.0, 1324.875, 1325.875] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))), + max_texture_side: Some(8192), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); + show(&mut app, &mut child); + }); + out.textures_delta.clear(); + let rect = app.canvas_rect.unwrap(); + observed.push((rect, Xf::new(rect, app.view().unwrap()).to_doc(pos2(440.0, 330.0)))); + } + assert_eq!(observed[0], observed[1], "the same physical canvas must map the same pointer identically"); + assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping"); +} + /// A rendered raster becomes the image egui's own conversion makes of it, half-transparent /// edges and all; one whose bytes don't match its size becomes an empty image of that size. #[test] @@ -2309,6 +2335,144 @@ fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() { app.canvas.worker = None; } +/// While the worker renders the frame for a panned view, the fill worker renders a +/// low-resolution frame, and the part of the canvas the last frame no longer covers shows the +/// art from it, drawn only there. When the worker's frame arrives, it covers the canvas and the +/// fill is dropped. +#[test] +fn a_pan_fills_the_area_the_last_frame_leaves_until_the_worker_frame_arrives() { + // The canvas key holds the global placed-document bitmap clock, which tests on other threads + // move: a frame whose key changed by more than the view drops the fill. An attempt that fails + // while the clock moved runs again; one that fails while it stood still is a real failure. + for _ in 0..20 { + let clock = vectorcraft_render::placed_document::generation(); + match std::panic::catch_unwind(pan_fills_the_area_the_last_frame_leaves) { + Ok(()) => return, + Err(_) if vectorcraft_render::placed_document::generation() != clock => {} + Err(e) => std::panic::resume_unwind(e), + } + } + pan_fills_the_area_the_last_frame_leaves(); +} + +fn pan_fills_the_area_the_last_frame_leaves() { + let ctx = egui::Context::default(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 80, "height": 100})).unwrap(); + app.canvas.worker_started = true; // render on this thread + app.ui.view.rulers = false; + app.ui.screen_mode = 2; // no scroll bars: the canvas is the window + // One frame → the meshes drawn and the fill's pixels, when this frame uploaded them. + let frame = |app: &mut VectorcraftApp| { + let (shapes, mut delta) = frame_output(app, &ctx); + let fill = app.canvas.fill.as_ref().map(|f| f.tex.id()); + let pixels = delta.set.iter().find(|(id, _)| Some(**id) == fill).and_then(|(_, d)| d.last()).map(|d| match &d.image { + egui::ImageData::Color(img) => img.clone(), + }); + delta.clear(); + let meshes: Vec = shapes + .into_iter() + .filter_map(|s| match s { + Shape::Mesh(m) => Some(std::sync::Arc::unwrap_or_clone(m)), + _ => None, + }) + .collect(); + (meshes, pixels) + }; + frame(&mut app); + { + let v = app.view_mut().unwrap(); + v.zoom = 4.0; + v.center = Point::new(100.0, 150.0); + } + frame(&mut app); // the art from about x 0 to 200, without the rectangle + let old = app.canvas.key.clone().unwrap(); + let before = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + assert!(app.canvas.fill.is_none()); + let (worker, jobs, completed) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + let (fill_worker, fill_jobs, fill_completed) = crate::render_worker::Worker::test_channel(); + app.canvas.fill_worker = Some(fill_worker); + app.canvas.last_ms = 50.0; + // Pan right by 150 pt: the last frame covers the canvas's left quarter, and the rectangle + // comes into its right quarter. + app.view_mut().unwrap().center = Point::new(250.0, 150.0); + frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown"); + let job = jobs.try_recv().expect("the frame for the new view is on the worker"); + let fill_job = fill_jobs.try_recv().expect("the fill is on the fill worker"); + let (fw, fh, _) = fill_view(&job.key); + assert!(fill_job.opts.kept_images_only && (fill_job.w, fill_job.h) == (fw, fh), "{:?}", (fill_job.w, fill_job.h)); + assert!(app.canvas.fill.is_none(), "nothing to draw until the fill arrives"); + // The fill arrives; the next frame draws it, and asks for no other fill. + let img = app.canvas.renderer.render(&fill_job.doc, fill_job.w, fill_job.h, fill_job.view, &fill_job.opts); + let (doc, opts) = (fill_job.doc, fill_job.opts); + fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0 }).unwrap(); + let (meshes, pixels) = frame(&mut app); + assert!(fill_jobs.try_recv().is_err(), "the fill covers the canvas"); + assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown"); + let fill = app.canvas.fill.as_ref().expect("the fill is kept"); + let mesh = meshes.iter().find(|m| m.texture_id == fill.tex.id()).expect("the fill is drawn"); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let edge = xf.to_screen(before.to_doc(rect.right_top())).x; // the last frame's right edge + assert!(edge > rect.left() + 100.0 && edge < xf.to_screen(Point::new(300.0, 0.0)).x, "{edge} in {rect:?}"); + assert!( + mesh.vertices.iter().all(|v| v.pos.x >= edge - 1e-3 && rect.expand(1e-3).contains(v.pos)), + "only right of the last frame: {:?}", + mesh.vertices + ); + let area: f32 = mesh.indices.as_chunks::<3>().0.iter().map(|t| polygon_area(&t.map(|i| mesh.vertices[i as usize].pos))).sum(); + let uncovered = (rect.right() - edge) * rect.height(); + assert!((area - uncovered).abs() < 1.0, "the whole uncovered area, {uncovered} square points: {area}"); + // The fill's pixel under a point of the canvas, from the uv the mesh gives that point. + let img = pixels.expect("the fill's pixels were uploaded"); + let to_uv = Affine::scale_non_uniform(1.0 / fill.size.0 as f64, 1.0 / fill.size.1 as f64) * fill.view * xf.affine().inverse(); + let alpha = |p: Pos2| { + let uv = to_uv * Point::new(p.x as f64, p.y as f64); + img.pixels[(uv.y * img.size[1] as f64) as usize * img.size[0] + (uv.x * img.size[0] as f64) as usize].a() + }; + assert!(alpha(xf.to_screen(Point::new(340.0, 150.0))) > 0, "the rectangle, in the uncovered area"); + assert_eq!(alpha(xf.to_screen(Point::new(250.0, 150.0))), 0, "no art there"); + for v in &mesh.vertices { + assert!((to_uv * Point::new(v.pos.x as f64, v.pos.y as f64) - Point::new(v.uv.x as f64, v.uv.y as f64)).hypot() < 1e-4); + } + // Panned until the fill reaches only the canvas's left half: it is drawn only there, and the + // fill for the new view is asked for. + let fill_tex = fill.tex.id(); + let reach = fill.view.inverse() * Point::new(fill.size.0 as f64, 0.0); // the fill's right edge + app.view_mut().unwrap().center = Point::new(reach.x, 150.0); + let (meshes, _) = frame(&mut app); + let right = Xf::new(rect, app.view().unwrap()).to_screen(reach).x; + assert!((right - rect.center().x).abs() < 1.0, "{right} in {rect:?}"); + let mesh = meshes.iter().find(|m| m.texture_id == fill_tex).expect("the fill is drawn where it reaches"); + assert!(mesh.vertices.iter().all(|v| v.pos.x <= right + 1e-3), "only where the fill reaches: {:?}", mesh.vertices); + let next = fill_jobs.try_recv().expect("the next fill"); + assert_eq!(Some(&next.key), app.canvas.worker.as_ref().unwrap().pending.as_ref()); + app.view_mut().unwrap().center = Point::new(250.0, 150.0); + // That fill finishes after the worker's frame: it is taken and not kept. + let img = app.canvas.renderer.render(&next.doc, next.w, next.h, next.view, &next.opts); + fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0 }).unwrap(); + // The worker's frame arrives: it covers the canvas, and the fill is no longer drawn or kept. + let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); + let expected = job.key.clone(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0 }).unwrap(); + let (meshes, _) = frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame is shown"); + assert!(app.canvas.fill.is_none() && meshes.iter().all(|m| m.texture_id != fill_tex), "the fill is dropped"); + assert!(app.canvas.fill_worker.as_ref().unwrap().pending.is_none(), "the late fill was taken"); + // A pan with Outline turned on: the frame shown's options are not the ones asked for, and + // no fill is asked for. + app.ui.view.outline = true; + app.view_mut().unwrap().center = Point::new(400.0, 150.0); + frame(&mut app); + assert!(jobs.try_iter().last().is_some_and(|j| j.key.outline), "the outline frame is on the worker"); + assert!(fill_jobs.try_recv().is_err() && app.canvas.fill.is_none(), "no fill"); + app.canvas.worker = None; + app.canvas.fill_worker = None; +} + /// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas /// is not rendered while the view moves: each frame draws the frame shown moved and scaled with /// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been @@ -6067,6 +6231,121 @@ fn a_pen_tap_within_one_frame_clicks_where_it_tapped() { assert_eq!(doc.doc.art_bounds(), before, "the tap moved nothing"); } +/// The Contextual Task Bar shows a Stroke chip beside the Fill chip, drawn with the selection's +/// stroke paint. A click on it brings the Stroke proxy forward and opens Swatches, as a click on +/// the Fill chip does for the Fill proxy. +#[test] +fn the_task_bar_shows_a_stroke_chip_beside_fill() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap(); + app.session.execute("paint.setStroke", &json!({"ids": [id], "color": "#2244cc"})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + // Past the bar's fade-in, so its chips draw at full opacity. + for _ in 0..30 { + frame(&mut app, &ctx, vec![]); + } + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, Lock, Recolor, then Stroke and Fill. + let stroke_x = bar.right() - 8.0 - 3.0 * (30.0 + 6.0) - 13.0; + let fill_x = stroke_x - 26.0 - 6.0; + let blue = Color32::from_rgb(0x22, 0x44, 0xcc); + let chip_with = |x: f32| { + out.shapes.iter().any(|c| match &c.shape { + egui::Shape::Rect(r) => r.fill == blue && bar.contains(r.rect.center()) && (r.rect.center().x - x).abs() < 4.0, + _ => false, + }) + }; + assert!(chip_with(stroke_x), "the Stroke chip is drawn with the stroke color"); + assert!(!chip_with(fill_x), "the Fill chip is not"); + + let click = |app: &mut VectorcraftApp, x: f32| { + let at = pos2(x, bar.center().y); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + app.session.fill_active = true; + click(&mut app, stroke_x); + assert!(!app.session.fill_active, "the Stroke proxy is in front"); + assert_eq!(app.ui.open_panel.as_deref(), Some("swatches")); + app.ui.open_panel = None; + click(&mut app, fill_x); + assert!(app.session.fill_active, "the Fill proxy is in front"); + assert_eq!(app.ui.open_panel.as_deref(), Some("swatches")); +} + +/// The Contextual Task Bar's Fill and Stroke chips show the paints the proxies show: a group's +/// painted art, and "?" when the selected objects' paints differ. A click on a chip shows the +/// Swatches panel wherever it is, closed included. +#[test] +fn the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut square = |x: f64, stroke: &str| { + let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap(); + app.session.execute("paint.setStroke", &json!({"ids": [id], "color": stroke})).unwrap(); + id + }; + square(160.0, "#2244cc"); + let second = square(220.0, "#22cc44"); + app.select_tool("selection"); + app.session.execute("select.all", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let shapes = |app: &mut VectorcraftApp| { + for _ in 0..30 { + frame(app, &ctx, vec![]); + } + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + }; + // Right to left: the frame's margin, More Options, Lock, Recolor, then Stroke and Fill. + let stroke_x = |bar: egui::Rect| bar.right() - 8.0 - 3.0 * (30.0 + 6.0) - 13.0; + let chip_with = |shapes: &[egui::epaint::ClippedShape], bar: egui::Rect, x: f32, color: Color32| { + shapes.iter().any(|c| match &c.shape { + egui::Shape::Rect(r) => r.fill == color && bar.contains(r.rect.center()) && (r.rect.center().x - x).abs() < 4.0, + _ => false, + }) + }; + let (blue, green) = (Color32::from_rgb(0x22, 0x44, 0xcc), Color32::from_rgb(0x22, 0xcc, 0x44)); + + // Two strokes that differ: "?" on white, as the proxy shows them. + let out = shapes(&mut app); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + assert!(chip_with(&out, bar, stroke_x(bar), Color32::WHITE), "the Stroke chip shows the strokes differ"); + assert!(!chip_with(&out, bar, stroke_x(bar), blue), "the Stroke chip does not show the first object's stroke"); + + // A group shows its painted art's stroke, as the proxy does. + app.session.execute("paint.setStroke", &json!({"ids": [second], "color": "#2244cc"})).unwrap(); + app.session.execute("select.all", &json!({})).unwrap(); + app.session.execute("object.group", &json!({})).unwrap(); + let out = shapes(&mut app); + let bar = task_bar_rect(&ctx).expect("the task bar shows"); + assert!(chip_with(&out, bar, stroke_x(bar), blue), "the Stroke chip shows the group's art's stroke"); + assert!(!chip_with(&out, bar, stroke_x(bar), green)); + + // Swatches closed: a click on the Stroke chip shows it. + app.run("window.panel.close", json!({"panel": "swatches"})).unwrap(); + assert!(!crate::panel_docking::showing(&app.ui, "swatches")); + let at = pos2(stroke_x(bar), bar.center().y); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + frame(&mut app, &ctx, vec![]); + assert!(!app.session.fill_active, "the Stroke proxy is in front"); + assert!(crate::panel_docking::showing(&app.ui, "swatches"), "the Swatches panel shows"); +} + /// The Contextual Task Bar shows Recolor Artwork (a palette) when the selection has colours, /// and it opens the Recolor Artwork dialog; art painted with None has no button. #[test] @@ -6237,6 +6516,32 @@ fn the_task_bar_offers_the_artboard_tools_actions() { assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]); } +/// A selected group's Contextual Task Bar offers Ungroup and Isolate. While an opacity mask is +/// edited, when Enter Isolation Mode is off, a group in the mask art gets Ungroup without Isolate. +#[test] +fn the_task_bar_leaves_isolate_out_while_an_opacity_mask_is_edited() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 40, "height": 40})).unwrap()["id"].clone() + }; + let group = |app: &mut VectorcraftApp, x: f64| { + let (a, b) = (rect(app, x), rect(app, x + 50.0)); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + app.session.execute("object.group", &json!({})).unwrap(); + }; + group(&mut app, 20.0); + assert_eq!(names(&mut app), ["Ungroup", "Isolate", "Duplicate"]); + let (obj, m) = (rect(&mut app, 200.0), rect(&mut app, 200.0)); + app.session.execute("transparency.makeOpacityMask", &json!({"ids": [obj, m]})).unwrap(); + app.session.execute("transparency.editOpacityMask", &json!({"id": obj})).unwrap(); + group(&mut app, 200.0); + assert_eq!(names(&mut app), ["Ungroup", "Duplicate"]); +} + /// The Artboard tool's Contextual Task Bar buttons act on its selected artboards: Resize opens /// Artboard Options for the active one, Rearrange All opens Rearrange All Artboards, Export opens /// Export for Screens with the selected artboards checked, and Duplicate copies each selected @@ -6328,7 +6633,7 @@ fn the_task_bar_is_dragged_by_its_handle_pinned_and_reset() { app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); let mut square = |x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone(); - let (a, b) = (square(120.0, 40.0), square(160.0, 150.0)); + let (a, b) = (square(140.0, 40.0), square(160.0, 150.0)); app.select_tool("selection"); let ctx = egui::Context::default(); let bar = || task_bar_rect(&ctx).expect("the task bar shows"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 51b818cda4..d5a0e2ef61 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -342,7 +342,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); } for (f, value) in fields { - d.fields.insert(f, value); + crate::dialogs::set_field(d, f, value); } ok(serde_json::to_value(&*d).unwrap_or_default()) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 49dfe37b9a..1e96e74d39 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -99,6 +99,7 @@ pub(crate) mod shapes; pub mod shortcut_import; pub(crate) mod simplify_slider; pub mod slices; +pub(crate) mod smooth_slider; pub mod spot_colors; pub(crate) mod svg_options; pub mod swatch_conflict; @@ -176,6 +177,10 @@ pub(crate) struct DialogSpec { /// A button left of Cancel, as (label, field), that sets `field: true` and confirms, as OK /// does otherwise (Transform Each's Copy). pub alternate: Option<(&'static str, &'static str)>, + /// Puts the fields back to their defaults. While Alt (Option on macOS) is held, the Cancel + /// button of a dialog with an `ok` reads Reset; Reset (or `ui.dialogReset`) runs this and the + /// dialog stays open. + pub reset: Option, pub min_width: f32, pub max_width: Option, /// The body runs a live preview interaction that Cancel rolls back. @@ -211,6 +216,7 @@ impl DialogSpec { ok: Some("OK"), discard: None, alternate: None, + reset: None, min_width: 320.0, max_width: None, preview: false, @@ -352,6 +358,7 @@ registry! { ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, + SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC, PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, LiquifyOptions: [liquify::KIND] => liquify::SPEC, @@ -377,14 +384,15 @@ registry! { ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm), } -/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close), -/// so the catalog tests can insist they are translated. +/// The button labels the shared dialog frame can show (OK, discard, the fixed Cancel/Close and +/// Reset), so the catalog tests can insist they are translated. pub fn button_labels() -> Vec<&'static str> { let mut v = vec!["Cancel", "Close"]; for spec in std::iter::once(&DialogSpec::FORM).chain(DialogKind::ALL.iter().map(|k| k.spec())) { v.extend(spec.ok); v.extend(spec.discard); v.extend(spec.alternate.map(|(label, _)| label)); + v.extend(spec.reset.map(|_| "Reset")); } v.sort_unstable(); v.dedup(); @@ -410,6 +418,15 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { || (spec.passes)(d, field) } +/// Set `field` of `d` to `value` for `ui.dialog.set`, as the dialog's own control for it does: a +/// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is. +pub fn set_field(d: &mut Dialog, field: String, value: Value) { + if d.kind == brush_options::KIND { + brush_art_options::note_set(d, &field); + } + d.fields.insert(field, value); +} + /// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, /// `discard`, `preview` and the spec's `inputs` for its kind. pub fn field_names(d: &Dialog) -> Vec { @@ -444,7 +461,10 @@ pub(crate) fn parent(d: &Dialog) -> Option { pub fn cancel(app: &mut VectorcraftApp) { missing_fonts::requeue_replaced(app); if let Some(d) = app.ui.dialog.take() { - if spec(&d.kind).preview { + // Toolbar-only Flare options start no preview and must not cancel a gesture + // that was already active before opening them. + let owns_preview = d.kind != flare_options::KIND || d.fields.contains_key(form::PREVIEWED); + if spec(&d.kind).preview && owns_preview { let _ = app.session.cancel_interaction(); } app.ui.dialog = parent(&d); @@ -452,6 +472,23 @@ pub fn cancel(app: &mut VectorcraftApp) { settle(app); } +/// Whether the open dialog has a Reset ([`DialogSpec::reset`]). +pub(crate) fn can_reset(app: &VectorcraftApp) -> bool { + app.ui.dialog.as_ref().is_some_and(|d| spec(&d.kind).reset.is_some()) +} + +/// Reset (`ui.dialogReset`, or Cancel while Alt or Option is held): the open dialog's fields go +/// back to their defaults and it stays open → the dialog; an error when no dialog is open or the +/// open one has no Reset. +pub(crate) fn reset(app: &mut VectorcraftApp) -> DialogResult { + let Some(mut d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; + let Some(reset) = spec(&d.kind).reset else { return Err("the open dialog has no Reset".into()) }; + reset(app, &mut d); + let out = serde_json::to_value(&d).unwrap_or_default(); + app.ui.dialog = Some(d); + Ok(out) +} + /// Apply the open dialog (OK). pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { missing_fonts::requeue_replaced(app); @@ -527,6 +564,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut ok = false; let mut ok_enabled = true; let mut cancel = false; + let mut reset = false; let mut discard = false; let mut alternate = false; // Opened as a save panel closed: the Enter that closed it isn't this dialog's, until it is let go. @@ -587,8 +625,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ok = true; } ui.add_space(8.0); - if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() { - cancel = true; + // While Alt (Option) is held, Cancel reads Reset on a dialog that has one. + let resets = spec.ok.is_some() && spec.reset.is_some() && ui.input(|i| i.modifiers.alt); + let label = if resets { + "Reset" + } else if spec.ok.is_some() { + "Cancel" + } else { + "Close" + }; + if widgets::secondary_button(ui, label).clicked() { + if resets { + reset = true; + } else { + cancel = true; + } } if let Some((label, _)) = spec.alternate { ui.add_space(8.0); @@ -614,6 +665,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.dialog = Some(d); if cancel { self::cancel(app); + } else if reset { + if let Err(e) = self::reset(app) { + app.status(e); + } } else if ok && let Err(e) = confirm(app) { app.status(e); } diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index 1898172a84..f9b5bf5584 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -14,14 +14,17 @@ //! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`, //! `overlap` (bend on each run between sharp corners). //! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`, -//! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has). +//! `flipAcross`, `outerCornerTile` and `innerCornerTile` (`original`: the corner art the brush +//! draws there | `none` | `autoCentered` | `autoBetween` | `autoSliced` | `autoOverlap`: a tile +//! made from the side tile), +//! `__tiles` (slot → preview key, for the tiles the brush has). use std::hash::{Hash, Hasher}; use std::sync::Arc; use egui::{Color32, Sense, Stroke, pos2, vec2}; use serde_json::{Map, Value, json}; -use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, Direction, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId}; @@ -41,6 +44,43 @@ const DIRECTIONS: [Direction; 4] = [Direction::LeftToRight, Direction::RightToLe const TILES: [(&str, &str); 5] = [("outer_corner", "outerCorner"), ("side", "side"), ("inner_corner", "innerCorner"), ("start", "start"), ("end", "end")]; +/// Pattern brush corner tiles: (dialog field, art field of the definition, source field, art field +/// of the other kind of corner). +const CORNER_TILES: [(&str, &str, &str, &str); 2] = + [("outerCornerTile", "outer_corner", "outer_source", "inner_corner"), ("innerCornerTile", "inner_corner", "inner_source", "outer_corner")]; + +/// The art field that corner tile `field` draws with in definition `def`: its own art, or, with +/// neither art nor a source, the other kind of corner's art (as [`PatternBrush`] draws it). +fn corner_art_field<'a>(def: Option<&Value>, field: &'a str) -> Option<&'a str> { + let set = |f: &str| def.and_then(|d| d.get(f)).is_some_and(|v| !v.is_null()); + let (_, art, source, other) = CORNER_TILES.into_iter().find(|(_, art, _, _)| *art == field)?; + if set(art) { + Some(art) + } else if !set(source) && set(other) { + Some(other) + } else { + None + } +} + +/// The dialog field that records that corner tile `key`'s menu was set. +fn corner_set_key(key: &str) -> String { + format!("__{key}Set") +} + +/// Sets corner tile `key` to `choice` from its menu. +pub(super) fn choose_corner(d: &mut Dialog, key: &str, choice: &str) { + d.fields.insert(key.into(), json!(choice)); + note_set(d, key); +} + +/// Records that field `field` was set, from its menu or `ui.dialog.set`, when it is a corner tile. +pub(super) fn note_set(d: &mut Dialog, field: &str) { + if CORNER_TILES.iter().any(|(key, ..)| *key == field) { + d.fields.insert(corner_set_key(field), json!(true)); + } +} + /// The most key colours offered. const MAX_COLORS: usize = 16; @@ -121,6 +161,10 @@ pub(super) fn fields(b: &Brush, def: Value, f: &mut Map) { f.insert("fit".into(), json!(id_of(&p.fit))); f.insert("flipAlong".into(), json!(p.flip_along)); f.insert("flipAcross".into(), json!(p.flip_across)); + for ((key, art, _, _), source) in CORNER_TILES.into_iter().zip([p.outer_source, p.inner_source]) { + let choice = if corner_art_field(Some(&def), art).is_some() { "original" } else { source.map_or("none", CornerSource::id) }; + f.insert(key.into(), json!(choice)); + } // A preview key per tile the brush has: its art's hash, so a changed tile redraws. let tiles: Map = TILES .iter() @@ -200,13 +244,37 @@ pub(super) fn params(d: &Dialog) -> Value { "overlap": d.bool("overlap"), }) } - _ => json!({ - "scale": clamped(d, "scale", PatternBrush::SCALE_RANGE), - "spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE), - "fit": id_of(&parse_field::(d, "fit")), - "flip_along": d.bool("flipAlong"), - "flip_across": d.bool("flipAcross"), - }), + _ => { + let mut p = json!({ + "scale": clamped(d, "scale", PatternBrush::SCALE_RANGE), + "spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE), + "fit": id_of(&parse_field::(d, "fit")), + "flip_along": d.bool("flipAlong"), + "flip_across": d.bool("flipAcross"), + }); + // None or an automatic tile replaces the corner art. Original keeps the art the corner + // draws, the other kind of corner's art included, so changing the other kind leaves it. + // A None the menu was not set to, on a corner without art or a source, leaves the + // definition as it is: that corner still draws the other kind's art when it gets some. + let def = d.fields.get("__def"); + for (key, art, source, other) in CORNER_TILES { + let choice = d.str(key); + let unset = |f: &str| def.and_then(|d| d.get(f)).is_none_or(Value::is_null); + if choice == CornerSource::None.id() && !d.bool(&corner_set_key(key)) && unset(source) && corner_art_field(def, art).is_none() { + continue; + } + if let Some(s) = CornerSource::parse(&choice) { + p[art] = Value::Null; + p[source] = json!(s.id()); + } else if choice == "original" + && let Some(from) = corner_art_field(def, art).filter(|f| *f != art) + && CORNER_TILES.iter().find(|(_, a, _, _)| *a == other).is_some_and(|(k, ..)| d.str(k) != "original") + { + p[art] = def.and_then(|d| d.get(from)).cloned().unwrap_or_default(); + } + } + p + } }; p["colorization"] = serde_json::to_value(colorization(d)).unwrap_or_default(); p @@ -379,21 +447,44 @@ fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) { radios(ui, d, "fit", &fits); } -/// The pattern's five tiles, each with its art (or None when the brush has no such tile). -fn tiles(ui: &mut egui::Ui, d: &Dialog) { +/// What a corner tile can be: (choice, label). Original (the corner art the brush draws there) is +/// offered only when the brush draws corner art there. +fn corner_choices() -> [(&'static str, &'static str); 6] { + [ + ("original", tl!("Original")), + (CornerSource::None.id(), tl!("None")), + (CornerSource::AutoCentered.id(), tl!("Auto-Centered")), + (CornerSource::AutoBetween.id(), tl!("Auto-Between")), + (CornerSource::AutoSliced.id(), tl!("Auto-Sliced")), + (CornerSource::AutoOverlap.id(), tl!("Auto-Overlap")), + ] +} + +/// The pattern's five tiles, each with its art (or None when the brush has no such tile). Below +/// each corner tile, a menu sets it to the brush's art, None or a tile made from the side tile. +fn tiles(ui: &mut egui::Ui, d: &mut Dialog) { let t = Tokens::get(ui.ctx()); let names = [tl!("Outer Corner Tile"), tl!("Side Tile"), tl!("Inner Corner Tile"), tl!("Start Tile"), tl!("End Tile")]; - let keys = d.fields.get("__tiles"); + let keys = d.fields.get("__tiles").cloned(); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; for ((field, slot), name) in TILES.into_iter().zip(names) { + let corner = CORNER_TILES.iter().find(|(_, art, _, _)| *art == field).map(|(key, ..)| *key); + // The art the tile draws with: its own, or a corner's that falls back on the other's. + let drawn = if corner.is_some() { corner_art_field(d.fields.get("__def"), field) } else { Some(field) }; + let drawn_slot = drawn.and_then(|f| TILES.iter().find(|(tf, _)| *tf == f)).map_or(slot, |(_, s)| *s); + let choices: Vec<(&str, &str)> = corner_choices().into_iter().filter(|(c, _)| drawn.is_some() || *c != "original").collect(); + let choice = corner.map(|key| d.str(key)); + // The label of the corner's choice when it shows no art. + let shown = choice.as_deref().filter(|c| *c != "original"); + let label = shown.and_then(|c| choices.iter().find(|(id, _)| *id == c)).map_or(tl!("None"), |(_, l)| *l); ui.vertical(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(76.0, 44.0), Sense::hover()); ui.painter().rect_filled(r, 0.0, Color32::WHITE); - let key = keys.and_then(|k| k.get(slot)).and_then(Value::as_str); + let key = keys.as_ref().filter(|_| shown.is_none()).and_then(|k| k.get(drawn_slot)).and_then(Value::as_str); let tex = key.and_then(|key| { widgets::doc_preview(ui, key, r.size(), |w, h| { - let art: Node = serde_json::from_value(d.fields.get("__def")?.get(field)?.clone()).ok()?; + let art: Node = serde_json::from_value(d.fields.get("__def")?.get(drawn?)?.clone()).ok()?; tile_doc(&art, w, h) }) }); @@ -402,18 +493,21 @@ fn tiles(ui: &mut egui::Ui, d: &Dialog) { ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { - ui.painter().text( - r.center(), - egui::Align2::CENTER_CENTER, - tl!("None"), - egui::FontId::proportional(11.5), - Color32::from_gray(140), - ); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(10.5), Color32::from_gray(140)); } } ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); resp.on_hover_text(name); ui.add_sized(vec2(76.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5).color(t.text_dim)).truncate()); + if let (Some(key), Some(cur)) = (corner, &choice) { + let current = choices.iter().find(|(id, _)| id == cur).map_or(tl!("None"), |(_, l)| *l); + let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect(); + if let Some((id, _)) = + widgets::dropdown_named(ui, ("brush-pattern-corner", key), Some(name), current, &labels, 76.0).and_then(|i| choices.get(i)) + { + choose_corner(d, key, id); + } + } }); } }); diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 7ca4b316ce..9adeffed1b 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -443,6 +443,110 @@ fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() { assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size"); } +/// Each corner tile has a menu below it: Original (only when the brush has corner art), None and +/// the four automatic tiles. OK saves the choice, and an automatic tile or None replaces the art. +#[test] +fn pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile() { + use crate::widgets::accessibility_tests::tree; + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("none".into(), "none".into()), "Diamonds has no corner art"); + let shown_tree = tree(|ui| super::super::show(&mut app, ui.ctx())); + for name in ["Outer Corner Tile", "Inner Corner Tile"] { + let roles: Vec<_> = shown_tree.iter().filter(|n| n.label() == Some(name)).map(|n| n.role()).collect(); + assert!(roles.contains(&egui::accesskit::Role::ComboBox), "a menu named {name}: {roles:?}"); + } + for name in ["Side Tile", "Start Tile", "End Tile"] { + assert!(!shown_tree.iter().any(|n| n.label() == Some(name) && n.role() == egui::accesskit::Role::ComboBox), "no menu for {name}"); + } + set(&mut app, json!({"outerCornerTile": "autoBetween", "innerCornerTile": "autoOverlap"})); + let text = shown(&mut app); + assert!(text.contains("Auto-Between") && text.contains("Auto-Overlap"), "{text}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert_eq!((&def["outer_source"], &def["inner_source"]), (&json!("autoBetween"), &json!("autoOverlap"))); + // Chain has corner art: Original keeps it, None and an automatic tile replace it, one corner + // at a time. + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "original".into())); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def["outer_corner"].is_object() && def["inner_corner"].is_object() && def.get("outer_source").is_none(), "{def}"); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + set(&mut app, json!({"innerCornerTile": "none"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def["outer_corner"].is_object() && def.get("inner_corner").is_none(), "{def}"); + assert_eq!(def["inner_source"], json!("none")); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "none".into())); + set(&mut app, json!({"outerCornerTile": "autoSliced"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def.get("outer_corner").is_none() && def.get("inner_corner").is_none(), "{def}"); + assert_eq!((&def["outer_source"], &def["inner_source"]), (&json!("autoSliced"), &json!("none"))); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("outerCornerTile"), "autoSliced"); +} + +/// A pattern brush with art for one kind of corner draws it at both kinds. Its other corner tile +/// shows that art as Original, and OK keeps it there when the first corner tile changes. +#[test] +fn pattern_options_keep_the_corner_art_a_corner_borrows() { + let mut app = app(); + let art = app.run("brush.get", json!({"name": "Chain"})).unwrap()["outer_corner"].clone(); + app.run("brush.new", json!({"type": "pattern", "name": "One Corner", "params": {"side": art.clone(), "outer_corner": art}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "One Corner"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "original".into())); + set(&mut app, json!({"outerCornerTile": "none"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "One Corner"})).unwrap(); + assert!(def.get("outer_corner").is_none() && def["inner_corner"].is_object(), "{def}"); + assert_eq!(def["outer_source"], json!("none")); +} + +/// OK leaves the corner fields of a corner tile the menus were not set to as they were: a corner +/// without art keeps borrowing the other kind's art when it gets some, and a corner that borrows +/// art still borrows it. A None picked from the menu is saved, after another choice too. +#[test] +fn pattern_options_ok_leaves_corner_tiles_the_menus_were_not_set_to() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + set(&mut app, json!({"scale": 150})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert!(def.get("outer_source").is_none() && def.get("inner_source").is_none(), "{def}"); + let art = app.run("brush.get", json!({"name": "Chain"})).unwrap()["outer_corner"].clone(); + app.run("brush.options", json!({"name": "Diamonds", "params": {"outer_corner": art.clone()}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("innerCornerTile"), "original", "the inner corners draw the outer art"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert!(def["outer_corner"].is_object() && def.get("inner_corner").is_none(), "{def}"); + + app.run("brush.new", json!({"type": "pattern", "name": "Plain", "params": {"side": art}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Plain"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::brush_art_options::choose_corner(d, "outerCornerTile", "autoCentered"); + crate::dialogs::brush_art_options::choose_corner(d, "outerCornerTile", "none"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Plain"})).unwrap(); + assert_eq!(def["outer_source"], json!("none")); + assert!(def.get("inner_source").is_none(), "{def}"); + // None set through the control channel is saved too. + app.run("ui.brushOptions", json!({"name": "Plain"})).unwrap(); + let (set, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "innerCornerTile", "value": "none"})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &set) else { panic!("not done") }; + assert!(r.get("error").is_none(), "{r}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Plain"})).unwrap(); + assert_eq!(def["inner_source"], json!("none")); +} + /// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their /// field, which screen readers name as the row, with the field's number and text over the /// value's whole range. diff --git a/crates/ui-egui/src/dialogs/color_guide_options.rs b/crates/ui-egui/src/dialogs/color_guide_options.rs index faeb11e0d2..1fe685e605 100644 --- a/crates/ui-egui/src/dialogs/color_guide_options.rs +++ b/crates/ui-egui/src/dialogs/color_guide_options.rs @@ -45,7 +45,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(10.0); // Preview: the base colour's row of variations. let o = options(app, d); - let base = base_color(app, ui.ctx()); + let base = base_color(app); let n = o.steps as i32; let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); let w = row.width() / (2 * n + 1) as f32; diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index eb72ae7bb4..60c037597b 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -3,7 +3,8 @@ //! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; an eyedropper //! button that samples a color anywhere on the screen ([`crate::screen_color`]); and a Color //! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one -//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back. +//! undo step through `paint.setFill` / `paint.setStroke` and makes the color the Color Guide's +//! base color; Cancel puts the art back. //! Opened for an artboard (Properties › Artboards › Fill), it shows the color on the artboard's //! paper live, and OK keeps it as one undo step through `artboard.setProps {index, color}`. //! Opened over Recolor Artwork (double-click a row's new color), its OK sets that row's new color @@ -636,7 +637,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { return Ok(Value::Null); } let (cmd, params) = paint_command(app, d); - super::form::commit_preview(app, cmd, params) + let c = in_document_model(app, picked(d).color); + let r = super::form::commit_preview(app, cmd, params)?; + // A color picked for the fill or stroke also becomes the Color Guide's base color. + if !d.fields.contains_key("artboard") { + crate::panels::color_guide::choose_base(app, c); + } + Ok(r) } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/color_picker/tests.rs b/crates/ui-egui/src/dialogs/color_picker/tests.rs index cc650978b6..4d678b1069 100644 --- a/crates/ui-egui/src/dialogs/color_picker/tests.rs +++ b/crates/ui-egui/src/dialogs/color_picker/tests.rs @@ -275,6 +275,30 @@ fn open_set_hex_and_confirm_fills() { assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); } +/// OK makes the picked fill or stroke color the Color Guide's base color (#3137); Cancel and a +/// color picked for an artboard leave the base as it is. +#[test] +fn ok_makes_the_picked_color_the_color_guides_base() { + let mut app = app(); + let base = |app: &VectorcraftApp| crate::panels::color_guide::base_color(app).to_hex(); + app.ui.color_guide_base = Some(Color::WHITE); + app.run("ui.colorPicker", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("2050a0")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(base(&app), "#2050a0"); + app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("a02050")); + frame(&mut app); + super::super::cancel(&mut app); + assert_eq!(base(&app), "#2050a0", "Cancel keeps the base"); + app.run("select.set", json!({"ids": []})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(base(&app), "#2050a0", "an artboard's color leaves the base"); +} + /// #1692: Tab goes through one color model's fields before the next: H, S, B, then R, G, B, the /// hex field, then L, a, b, then C, M, Y, K. Each Lab or CMYK field is level with the field on /// its left. diff --git a/crates/ui-egui/src/dialogs/flare_options.rs b/crates/ui-egui/src/dialogs/flare_options.rs index 44ae5eb586..b94f6a7c2e 100644 --- a/crates/ui-egui/src/dialogs/flare_options.rs +++ b/crates/ui-egui/src/dialogs/flare_options.rs @@ -1,13 +1,15 @@ //! Flare Tool Options (double-click the Flare tool, or click with it): the options the Flare tool //! keeps, which draw the next flare dragged out, last across tool switches and are saved with the //! preferences. OK sets them with `tool.setOption {tool: "flare", values}`; opened by a click, it -//! then draws a flare there with them (`shape.flare`, on the active perspective plane while the -//! grid shows). +//! accepts a flare preview there with them (`shape.flare`, on the active perspective plane while the +//! grid shows). While Alt (Option on macOS) is held, Cancel reads Reset, which puts every option +//! back to the tool's default and leaves the dialog open (`ui.dialogReset`). A click-opened +//! dialog previews its settings live; Reset refreshes that preview and Cancel rolls it back. //! //! Fields: the options (`vectorcraft_tools::extra::FLARE_OPTIONS`): `diameter` and `pathLength` //! (points), `opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and //! `largest` (%), `rays` and `rings` (counts), `direction` (degrees); `raysOn` and `ringsOn`; and -//! `x`, `y` where a click opened it. +//! `x`, `y` where a click opened it, with `preview` on initially for that click. use serde_json::{Map, Value, json}; use vectorcraft_geom::Point; @@ -21,7 +23,15 @@ use crate::{VectorcraftApp, widgets}; /// The dialog kind of the Flare Tool Options. pub const KIND: &str = "flareOptions"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Flare Tool Options").into(), body, confirm, min_width: 440.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Flare Tool Options").into(), + body, + confirm, + reset: Some(reset), + preview: true, + min_width: 440.0, + ..DialogSpec::FORM +}; /// Width of the label column and of the fields. const LABEL_W: f32 = 78.0; @@ -35,6 +45,7 @@ pub fn open(app: &mut VectorcraftApp, at: Option) -> Value { _ => Map::new(), }; if let Some(p) = at { + fields.insert("preview".into(), json!(true)); fields.insert("x".into(), json!(p.x)); fields.insert("y".into(), json!(p.y)); } @@ -102,23 +113,53 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); ui.end_row(); }); + if let Some((cmd, params)) = preview_command(app, d) { + form::preview(app, ui, d, "Flare", &cmd, params); + } false } +/// A click-created flare previews at that original click; toolbar options draw no artwork. +fn preview_command(app: &VectorcraftApp, d: &Dialog) -> Option<(String, Value)> { + let at = Point::new(d.fields.get("x")?.as_f64().filter(|v| v.is_finite())?, d.fields.get("y")?.as_f64().filter(|v| v.is_finite())?); + // Use the same clamping and whole-count conversion as accepted tool settings. + let mut tool = vectorcraft_tools::create("flare"); + for (key, default, ..) in FLARE_OPTIONS { + tool.set_option(key, &json!(d.f64(key, default))); + } + for key in ["raysOn", "ringsOn"] { + tool.set_option(key, &json!(d.bool(key))); + } + let params = flare_params(&tool.options(), at); + Some(vectorcraft_engine::perspective_click(&app.session, "shape.flare", ¶ms, at).unwrap_or_else(|| ("shape.flare".into(), params))) +} + +/// Reset (Cancel with Alt or Option held): every option goes back to the Flare tool's default +/// (`FLARE_OPTIONS`, with Rays and Rings on). The dialog keeps a click's `x` and `y`, so OK still draws there. +fn reset(_: &VectorcraftApp, d: &mut Dialog) { + if let Value::Object(defaults) = vectorcraft_tools::create("flare").options() { + d.fields.extend(defaults); + } +} + /// OK: set the tool's options; opened by a click, draw a flare there with them. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { + let command = preview_command(app, d); + if command.is_none() && (d.fields.contains_key("x") || d.fields.contains_key("y") || d.fields.contains_key(form::PREVIEWED)) { + return Err("Flare click coordinates must be finite numbers".into()); + } let mut values: Map = FLARE_OPTIONS.iter().filter(|(k, ..)| d.fields.contains_key(*k)).map(|(k, default, ..)| (k.to_string(), json!(d.f64(k, *default)))).collect(); for key in ["raysOn", "ringsOn"] { values.insert(key.into(), json!(d.bool(key))); } - let at = d.fields.get("x").and_then(Value::as_f64).zip(d.fields.get("y").and_then(Value::as_f64)).map(|(x, y)| Point::new(x, y)); - let options = run_and_close(app, "tool.setOption", json!({ "tool": "flare", "values": values }))?; - let Some(at) = at else { return Ok(options) }; - let params = flare_params(&options, at); - match vectorcraft_engine::perspective_click(&app.session, "shape.flare", ¶ms, at) { - Some((id, p)) => app.run(&id, p), - None => app.run("shape.flare", params), + if let Some((cmd, params)) = command { + // Accept the preview once; option persistence follows successful artwork creation. + let result = form::commit_preview(app, &cmd, params)?; + app.run("tool.setOption", json!({ "tool": "flare", "values": values }))?; + Ok(result) + } else { + run_and_close(app, "tool.setOption", json!({ "tool": "flare", "values": values })) } } @@ -148,6 +189,81 @@ mod tests { children.iter().filter_map(|c| c.name.clone()).collect() } + /// Invalid control-channel coordinates must not turn a click dialog into toolbar options. + #[test] + fn invalid_click_coordinates_keep_the_dialog_and_preview_cancellable() { + for (key, value) in [("x", Value::Null), ("y", Value::Null), ("x", json!("invalid"))] { + let mut app = app(); + let original = app.session.doc().unwrap().doc.layers.clone(); + let depth = app.session.doc().unwrap().history.undo.len(); + let options = app.session.tool_options_of("flare"); + open(&mut app, Some(Point::new(220.0, 330.0))); + painted(&mut app); + let preview = app.session.doc().unwrap().doc.layers.clone(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert(key.into(), value); + d.fields.insert("rings".into(), json!(4)); + assert_eq!(super::super::confirm(&mut app).unwrap_err(), "Flare click coordinates must be finite numbers"); + assert!(app.ui.dialog.is_some()); + assert!(app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc.layers, preview); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth); + assert_eq!(app.session.tool_options_of("flare"), options); + super::super::cancel(&mut app); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc.layers, original); + } + } + + #[test] + fn cancelling_toolbar_options_preserves_an_existing_interaction() { + let mut app = app(); + app.session.begin_interaction("Existing gesture").unwrap(); + app.session.preview("shape.rectangle", &json!({"x":10,"y":10,"width":40,"height":40})).unwrap(); + let previewed = app.session.doc().unwrap().doc.layers.clone(); + open(&mut app, None); + painted(&mut app); + super::super::cancel(&mut app); + assert!(app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().doc.layers, previewed); + app.session.cancel_interaction().unwrap(); + } + + #[test] + fn reset_updates_live_preview_and_cancel_discards_art_and_options() { + let mut app = app(); + app.run("tool.setOption", json!({"tool":"flare", "values":{"rings":3}})).unwrap(); + let original = app.session.doc().unwrap().doc.layers.clone(); + let depth = app.session.doc().unwrap().history.undo.len(); + let options = app.session.tool_options_of("flare"); + open(&mut app, Some(Point::new(220.0, 330.0))); + let text = painted(&mut app); + assert!(text.contains("Preview")); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 3); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth); + app.ui.dialog.as_mut().unwrap().fields.insert("rings".into(), json!(4.0)); + painted(&mut app); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 4); + app.run("ui.dialogReset", json!({})).unwrap(); + painted(&mut app); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 10); + assert_eq!(app.session.tool_options_of("flare"), options, "preview doesn't persist options"); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc.layers, original); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth); + assert_eq!(app.session.tool_options_of("flare"), options); + // Accepting the same reset preview produces one flare and one Undo step. + open(&mut app, Some(Point::new(220.0, 330.0))); + painted(&mut app); + app.run("ui.dialogReset", json!({})).unwrap(); + painted(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth + 1); + assert_eq!(flare_parts(&app).iter().filter(|s| *s == "Ring").count(), 10); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.layers, original); + } + #[test] fn the_toolbar_opens_the_options_and_ok_keeps_them_for_the_next_drag() { let mut app = app(); @@ -200,4 +316,24 @@ mod tests { let o = app.session.tool_options_of("flare"); assert_eq!((o["opacity"].as_f64(), o["diameter"].as_f64()), (Some(100.0), Some(0.0))); } + + #[test] + fn ui_dialog_reset_puts_the_defaults_back_and_keeps_where_a_click_opened_it() { + let mut app = app(); + assert_eq!(app.run("ui.dialogReset", json!({})).unwrap_err(), "no dialog open"); + app.run("tool.setOption", json!({"tool": "flare", "values": {"rings": 3, "raysOn": false, "largest": 120}})).unwrap(); + crate::dialogs::open_tool_dialog(&mut app, "flare", json!({"x": 300, "y": 300})); + let d = app.run("ui.dialogReset", json!({})).unwrap(); + assert_eq!((d["kind"].as_str(), d["fields"]["rings"].as_f64(), d["fields"]["raysOn"].as_bool()), (Some(KIND), Some(10.0), Some(true))); + let d = app.ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.f64("largest", 0.0), d.f64("x", 0.0), d.f64("y", 0.0)), (50.0, 300.0, 300.0)); + // The tool keeps its options until OK, which draws the flare with the defaults where the click was. + assert_eq!(app.session.tool_options_of("flare")["rings"].as_u64(), Some(3)); + super::super::confirm(&mut app).unwrap(); + let parts = flare_parts(&app); + assert_eq!((parts.iter().filter(|p| *p == "Ring").count(), parts.iter().any(|p| p == "Rays")), (10, true), "{parts:?}"); + // A dialog without a Reset refuses it. + app.ui.dialog = Some(Dialog::new("move", json!({"dx": 1}))); + assert_eq!(app.run("ui.dialogReset", json!({})).unwrap_err(), "the open dialog has no Reset"); + } } diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index b320897dee..c05ff231be 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -87,6 +87,9 @@ fn shown(f: &Format) -> impl Iterator { || (range && o.name == "artboard") || (separate && o.name == "range") || (matches!(f.id, "ai" | "ait") && o.name == "useArtboards") + // EPS Save keeps a page and the complete editing document; cropped and + // selected-only output belongs to Export, never this save dialog. + || (f.id == "eps" && matches!(o.name, "useArtboards" | "selectedOnly")) || (f.id == "ait" && o.name == "aiVersion")) }) } diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 9c8ae60bb1..fe46331a5e 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -72,7 +72,7 @@ fn save_as_svg_opens_svg_options_then_writes_svg() { // The save panel offers every save format, the document's own first. let pick = &picks.borrow()[0]; let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect(); - assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); + assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai", "EPS"]); assert_eq!(pick.name, "Untitled-1.vectorcraft"); let d = dialog(&app); assert_eq!((d.kind.as_str(), d.str("path").as_str(), d.str("mode").as_str()), ("svgOptions", "art.svg", "save")); @@ -332,3 +332,18 @@ fn save_as_pdf_asks_with_the_save_pdf_dialog_and_remembers_its_options() { assert_eq!(dialog(&app).str("compatibility"), "1.5"); assert_eq!(dialog(&app).str("__save"), "file.saveCopy"); } + +#[test] +fn eps_save_as_keeps_page_options_and_writes_eps_without_export_only_switches() { + let (mut app, written, _) = desktop("art.eps"); + app.run("file.saveAs", json!({})).unwrap(); + let names: Vec<_> = shown(fileio::format("eps").unwrap()).map(|option| option.name).collect(); + assert!(!names.contains(&"useArtboards") && !names.contains(&"selectedOnly")); + assert!(names.contains(&"previewFormat")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let saved = written.borrow(); + assert_eq!(saved[0].0, "art.eps"); + assert!(vectorcraft_eps::native(&saved[0].1).is_some()); + assert_eq!(app.session.active().unwrap().format, "eps"); +} diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index bc9f2916ff..dd2201de00 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -181,6 +181,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); } else if d.kind == "arc" { arc_form(ui, d, unit); + } else if LINKED.contains(&d.kind.as_str()) { + linked_size_form(ui, d, unit); } else { form::grid(ui, d, unit); } @@ -250,6 +252,102 @@ fn arc_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { form::check(ui, d, "fillArc", tl!("Fill Arc")); } +/// The click dialogs whose Width and Height share a Constrain Width and Height Proportions link +/// (, +/// ): the same +/// link as the Transform panel's ([`crate::panels::transform::constrain_link`]), drawn the same way. +const LINKED: [&str; 3] = ["rectangle", "roundedRectangle", "ellipse"]; + +/// The dialog field holding the link's state (UI-only, as every `__` field is). +const LINK: &str = "__constrain"; + +/// The dialog field holding the link state the dialog last applied ([`link_changed`]): a change of +/// [`LINK`], by its button or from outside (`ui.dialog.set`), shows as a difference from it. +const APPLIED: &str = "__constrainApplied"; + +/// The dialog field holding height ÷ width while the link is on: the proportions it keeps. +const RATIO: &str = "__ratio"; + +/// Where the link's state waits for the next of these dialogs (egui memory, this run only). +const LINK_STATE: &str = "shape-size-link"; + +/// Width and Height (then a rounded rectangle's radius) with the Constrain Width and Height +/// Proportions link beside the two size rows. While it is on, typing one side sets the other, so +/// the shape keeps the proportions it had when the link was turned on. The next of these dialogs +/// opens with the link as this one left it. +fn linked_size_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + if !d.fields.contains_key(LINK) { + d.fields.insert(LINK.into(), json!(crate::panels::pstate::(ui.ctx(), LINK_STATE))); + } + sync_link(ui.ctx(), d); + const BUTTON: f32 = 22.0; + let mut rows: Option = None; + egui::Grid::new("shape-size-dlg").num_columns(3).spacing([10.0, 8.0]).show(ui, |ui| { + for (key, label) in [("width", "Width:"), ("height", "Height:")] { + let row = form::row_label(ui, label); + if ui.label_inputs_by(row.id, |ui| form::length(ui, d, key, unit, form::FIELD_W)) { + follow_link(d, key); + } + // The link's column: the button is drawn over both rows' cells once the grid is laid out. + let (_, cell) = ui.allocate_space(egui::vec2(BUTTON, BUTTON)); + rows = Some(rows.map_or(cell, |r| r.union(cell))); + ui.end_row(); + } + if d.kind == "roundedRectangle" { + form::length_field(ui, d, "radius", &form::humanized("radius"), unit); + } + }); + let Some(rows) = rows else { return }; + let on = d.bool(LINK); + let rect = egui::Rect::from_center_size(rows.center(), egui::vec2(BUTTON, BUTTON)); + let resp = ui.interact(rect, ui.scope_id().with("shape-size-link"), egui::Sense::click()); + let icon = if on { "link" } else { "link-2-off" }; + if widgets::paint_icon_button(ui, resp, icon, "Constrain Width and Height Proportions", on).clicked() { + d.fields.insert(LINK.into(), json!(!on)); + sync_link(ui.ctx(), d); + } +} + +/// Apply a change of the link's state, however it was made ([`link_changed`]), and keep the new +/// state for the next of these dialogs. +fn sync_link(ctx: &egui::Context, d: &mut Dialog) { + if let Some(on) = link_changed(d) { + crate::panels::set_pstate(ctx, LINK_STATE, on); + } +} + +/// When the link's state differs from the one the dialog last applied (or none was applied yet), +/// apply it and return it: turned on, the link keeps the proportions of the sizes the dialog shows +/// then; turned off, it forgets them. A side of zero when it turns on leaves nothing to keep, so +/// typing changes only the side typed until the link is turned off and on again. +fn link_changed(d: &mut Dialog) -> Option { + let on = d.bool(LINK); + if d.fields.get(APPLIED).and_then(Value::as_bool) == Some(on) { + return None; + } + d.fields.insert(APPLIED.into(), json!(on)); + let ratio = d.f64("height", 100.0) / d.f64("width", 100.0); + if on && ratio.is_finite() && ratio != 0.0 { + d.fields.insert(RATIO.into(), json!(ratio)); + } else { + d.fields.remove(RATIO); + } + Some(on) +} + +/// After the side `typed` ("width" or "height") was typed with the link on, set the other side +/// from the proportions the link keeps. +fn follow_link(d: &mut Dialog, typed: &str) { + let ratio = d.f64(RATIO, 0.0); + if !d.bool(LINK) || !ratio.is_finite() || ratio == 0.0 { + return; + } + let (other, v) = if typed == "width" { ("height", d.f64("width", 100.0) * ratio) } else { ("width", d.f64("height", 100.0) / ratio) }; + if v.is_finite() { + d.fields.insert(other.into(), json!(v)); + } +} + /// The top-left corner of a rectangle or ellipse: the click itself, or with Alt held (draw from centre) the click minus half the size. fn box_origin(d: &Dialog) -> (f64, f64) { let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); @@ -402,6 +500,145 @@ mod tests { assert_eq!(box_origin(&fields(true)), (80.0, 40.0)); } + /// Turn the link on or off as its button or `ui.dialog.set` does, then apply it as the form + /// does on its next frame; what [`link_changed`] returned. + fn turn(d: &mut Dialog, on: bool) -> Option { + d.fields.insert(LINK.into(), json!(on)); + link_changed(d) + } + + fn type_size(d: &mut Dialog, typed: &str, value: f64) -> (f64, f64) { + d.fields.insert(typed.into(), json!(value)); + follow_link(d, typed); + // The form applies the link's state again on every frame: an unchanged state changes nothing. + assert_eq!(link_changed(d), None); + (d.f64("width", -1.0), d.f64("height", -1.0)) + } + + #[test] + fn the_link_keeps_the_proportions_it_had_when_turned_on() { + let mut d = Dialog::new("rectangle", json!({"x": 0.0, "y": 0.0, "width": "40 pt", "height": "20 pt"})); + // Off: a typed side changes only itself. + assert_eq!(turn(&mut d, false), Some(false)); + assert_eq!(type_size(&mut d, "width", 80.0), (80.0, 20.0)); + // On at 80 × 20: each side follows the other at 4 : 1, also through a zero and back. + assert_eq!(turn(&mut d, true), Some(true)); + for (typed, value, expected) in + [("width", 100.0, (100.0, 25.0)), ("height", 50.0, (200.0, 50.0)), ("width", 0.0, (0.0, 0.0)), ("width", 40.0, (40.0, 10.0))] + { + assert_eq!(type_size(&mut d, typed, value), expected, "{typed} = {value}"); + } + } + + /// Turned off and on again from outside the dialog (`ui.dialog.set`), the link forgets the old + /// proportions and keeps the ones the dialog shows when it comes back on. + #[test] + fn the_link_follows_changes_made_from_outside_the_dialog() { + let mut d = Dialog::new("rectangle", json!({"width": 80.0, "height": 20.0})); + assert_eq!(turn(&mut d, true), Some(true)); + assert_eq!(turn(&mut d, false), Some(false)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!(type_size(&mut d, "width", 30.0), (30.0, 20.0)); + assert_eq!(type_size(&mut d, "height", 60.0), (30.0, 60.0)); + assert_eq!(turn(&mut d, true), Some(true)); + assert_eq!(type_size(&mut d, "width", 10.0), (10.0, 20.0)); + // Set to what it already is: nothing to apply, the proportions stay. + assert_eq!(turn(&mut d, true), None); + assert_eq!(type_size(&mut d, "height", 40.0), (20.0, 40.0)); + } + + /// A side of zero when the link turns on leaves nothing to keep (no NaN): typing changes only + /// the side typed, even once both sides are sizes again, until the link is turned on again. + #[test] + fn a_link_turned_on_at_a_zero_side_stays_unconstrained_until_turned_on_again() { + let mut d = Dialog::new("ellipse", json!({"width": 0.0, "height": 30.0})); + assert_eq!(turn(&mut d, true), Some(true)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!(type_size(&mut d, "width", 50.0), (50.0, 30.0)); + assert_eq!(type_size(&mut d, "height", 60.0), (50.0, 60.0)); + assert!(!d.fields.contains_key(RATIO)); + assert_eq!((turn(&mut d, false), turn(&mut d, true)), (Some(false), Some(true))); + assert_eq!(type_size(&mut d, "width", 100.0), (100.0, 120.0)); + } + + /// The real dialog: Width comes before Height, the link is a named button that turns on with a + /// click, and the next Rectangle dialog opens with it on. + #[test] + fn the_rectangle_dialog_shows_width_then_height_and_a_link_button() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + const LINK_NAME: &str = "Constrain Width and Height Proportions"; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + super::super::open_tool_dialog(&mut app, "rectangle", json!({"x": 10.0, "y": 10.0})); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h.check_accessibility(); + let (w, ht) = (h.get_by_role_and_label(Role::TextInput, "Width:").rect(), h.get_by_role_and_label(Role::TextInput, "Height:").rect()); + assert!(w.center().y < ht.center().y, "Width is above Height: {w:?} {ht:?}"); + assert!(!h.state().ui.dialog.as_ref().unwrap().bool(LINK)); + let link = h.get_by_role_and_label(Role::Button, LINK_NAME).rect(); + assert!(link.left() > w.right() && link.top() < ht.center().y && link.bottom() > w.center().y, "the link sits beside both rows: {link:?}"); + h.get_by_role_and_label(Role::Button, LINK_NAME).click(); + h.run_steps(2); + let d = h.state().ui.dialog.as_ref().unwrap(); + assert!(d.bool(LINK)); + assert_eq!(d.f64(RATIO, 0.0), 1.0); + // Cancel, then click again: the link is still on. + let reopen = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>| { + h.state_mut().ui.dialog = None; + h.run_steps(2); + super::super::open_tool_dialog(h.state_mut(), "rectangle", json!({"x": 10.0, "y": 10.0})); + h.run_steps(2); + h.state().ui.dialog.as_ref().unwrap().bool(LINK) + }; + assert!(reopen(&mut h)); + // Turned off from outside the dialog (`ui.dialog.set`): the next dialog opens with it off. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert(LINK.into(), json!(false)); + h.run_steps(2); + assert!(!h.state().ui.dialog.as_ref().unwrap().fields.contains_key(RATIO)); + assert!(!reopen(&mut h)); + } + + /// With the link on, typing a width over a 40 × 20 rectangle's and pressing Enter draws it + /// 80 × 40. + #[test] + fn typing_a_width_with_the_link_on_draws_the_shape_in_proportion() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); + super::super::open_tool_dialog(&mut app, "rectangle", json!({"x": 10.0, "y": 10.0})); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!(40.0)); + d.fields.insert("height".into(), json!(20.0)); + d.fields.insert(LINK.into(), json!(true)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("80 pt".into())]); + frame(&mut app, vec![enter]); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.doc().unwrap().doc; + let b = doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert!((b.width() - 80.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + } + #[test] fn a_negative_line_length_draws_and_keeps_the_same_direction() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index caf898fddc..cf38660188 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -8,6 +8,8 @@ //! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html). //! `vectorcraft_pathops::simplify_level_tolerance` maps levels to tolerances. //! +//! The strip itself ([`Strip`]) is shared with Object › Path › Smooth's slider (`smooth_slider`). +//! //! Fields: `level` (0..=100, the command's parameter). use egui::{Color32, CornerRadius, Order, Pos2, Sense, Stroke, vec2}; @@ -25,9 +27,122 @@ pub(crate) const KIND: &str = "simplifySlider"; pub(super) const SPEC: DialogSpec = DialogSpec { window: Some(show), confirm, preview: true, ..DialogSpec::FORM }; -/// The command the slider previews, and its undo label. -const CMD: &str = "object.path.simplify"; -const LABEL: &str = "Simplify"; +/// What the Simplify slider previews, and its buttons. +const STRIP: Strip = Strip { + kind: KIND, + cmd: "object.path.simplify", + label: "Simplify", + default_level: SIMPLIFY_LEVEL, + auto: ("Auto-Simplify", auto), + more_options: Some(more_options), +}; + +/// A UI command a button of a strip runs. +type Button = fn(&mut VectorcraftApp) -> Result; + +/// A slider strip under the selection that previews `cmd {level}` while it is open: its first +/// button picks the level `cmd {auto: true}` does, and an optional last one, More Options, opens a +/// dialog in its place. Enter or a click outside the strip keeps the result as one undo step, and +/// Escape puts the paths back. +pub(super) struct Strip { + /// The dialog kind. + pub kind: &'static str, + /// The command previewed. + pub cmd: &'static str, + /// The undo step's name, and the slider's. + pub label: &'static str, + /// The level when the field holds no number. + pub default_level: f64, + /// The auto button's tip, and what it runs. + pub auto: (&'static str, Button), + /// What More Options runs, when the strip has it. + pub more_options: Option