diff --git a/ASSETS.md b/ASSETS.md index 5658882d48..dbdd8b84d8 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -20,7 +20,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo **Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). -Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). +Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets, the vector tool cursors (`crates/ui-egui/src/cursors.rs`) and the Learn panel's practice samples (`crates/ui-egui/src/panels/learn/samples.rs`). **Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay. diff --git a/Cargo.lock b/Cargo.lock index ce5569cde9..0c34a6a952 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6477,6 +6477,7 @@ dependencies = [ "vectorcraft-text", "vectorcraft-three-d", "vectorcraft-trace", + "wat", ] [[package]] diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 0391b7534b..4827772444 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -108,10 +108,37 @@ impl eframe::App for App { self.app.start_pending_update(); #[cfg(target_os = "macos")] super::mac_cursor::release(); + #[cfg(not(any(target_os = "macos", windows)))] + hide_before_teardown(self.winit_window.as_deref()); end_before_teardown(self.frames); } } +/// The main window as [`hide_before_teardown`] sees it. +#[cfg(not(any(target_os = "macos", windows)))] +pub(crate) trait QuittingWindow { + fn hide(&self); +} + +#[cfg(not(any(target_os = "macos", windows)))] +impl QuittingWindow for winit::window::Window { + fn hide(&self) { + self.set_visible(false); + } +} + +/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits +/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and +/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland +/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and +/// cannot hide a Wayland window. +#[cfg(not(any(target_os = "macos", windows)))] +pub(crate) fn hide_before_teardown(window: Option<&W>) { + if let Some(window) = window { + window.hide(); + } +} + impl App { /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a /// focused, shown window whose windows had input waits for the renderer and the next frame, diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index c5ef4d5579..1daccc22f1 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -1504,6 +1504,16 @@ mod portable { assert_eq!(finish(true), Duration::ZERO); } + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn quitting_waits_for_the_paste_checks_handle_as_long_as_its_reads_last() { + // The Paste check's handle is one of these: when the app quits, its unanswered X11 read + // ends `X11_READ_LIMIT` after it was sent, and an idle worker stops within `LOAD_WAIT`. + // Quitting waits for that handle until both are over. + assert!(vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT >= finish(false) + super::super::loads::LOAD_WAIT); + assert!(vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT >= finish(true) + super::super::loads::LOAD_WAIT); + } + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] #[test] fn a_bitmap_paste_waits_for_the_owner_off_the_ui_thread() { diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 03a9b8848c..94419b1049 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -393,8 +393,9 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Its calls are marked for the hang report (`hang_watch`). system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard - // owner that never answers holds a read for up to 4 s. Windows only asks which formats the - // clipboard holds and macOS asks the pasteboard server, so they check in line. + // owner that never answers holds a text read for up to 4 s for each format it tries, and + // quitting waits up to `CLIPBOARD_PROBE_STOP_WAIT` for that thread. Windows only asks which + // formats the clipboard holds and macOS asks the pasteboard server, so they check in line. // The probe's handle is made on its own thread, from the window (`Send`), not its raw display. clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(move || clipboard::system_clipboard(clip_probe)) as ClipboardProbeFactory), // Help → Discord / website / GitHub, the Discord button, About and Home links. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c2b40e37e6..33e962c0aa 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -87,6 +87,8 @@ mod titlebar; use app::bring_window_forward; use app::native_input_hook; use app::open_or_hold; +#[cfg(all(test, not(any(target_os = "macos", windows))))] +use app::{QuittingWindow, hide_before_teardown}; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -116,7 +118,8 @@ struct App { frames: u64, /// Keeps the custom title bar's window borderless (Windows, #1758). window_frame: Option, - /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635). + /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635), + /// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`). winit_window: Option>, /// A saved or fitted geometry asked for maximize once the window is mapped (Linux). pending_maximize: bool, diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 6e61cbe31f..6ae80944a6 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -460,3 +460,21 @@ fn the_linux_desktop_entry_opens_every_readable_format() { } } } + +/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose +/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go. +#[cfg(not(any(target_os = "macos", windows)))] +#[test] +fn quitting_hides_the_window_before_the_clipboards_let_go() { + struct Window(std::cell::Cell); + impl QuittingWindow for Window { + fn hide(&self) { + self.0.set(self.0.get() + 1); + } + } + let window = Window(std::cell::Cell::new(0)); + hide_before_teardown(Some(&window)); + assert_eq!(window.0.get(), 1, "the window was not hidden"); + // Without a window (none was made), there is nothing to hide. + hide_before_teardown::(None); +} diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index 93c0ad0f37..05c386e405 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -567,6 +567,112 @@ pub fn theme_palette(samples: &[(Color, f32)], n: usize) -> Vec { groups.into_iter().map(|g| g.2).collect() } +/// Colors that [`weigh`] moves from one row to another as one: a color they are shown by, how +/// often the art uses them and the row they are in. A fixed unit (such as the color a row is +/// shown by) stays in its row. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WeighUnit { + pub color: Color, + pub uses: f64, + pub row: usize, + pub fixed: bool, +} + +/// The unit-by-row comparisons one [`weigh`] makes, at most. +const WEIGH_WORK: usize = 50_000_000; + +/// The uses of `u` that count: a positive finite number, else 0. +fn unit_uses(u: &WeighUnit) -> f64 { + if u.uses.is_finite() && u.uses > 0.0 { u.uses } else { 0.0 } +} + +/// Each row's share of the uses of `units`, in percent (0 for every row when nothing is used). +/// Units whose row is past `rows`, or whose uses are not a positive finite number, count nothing. +pub fn row_shares(units: &[WeighUnit], rows: usize) -> Vec { + let mut sums = vec![0.0; rows]; + for u in units { + if let Some(s) = sums.get_mut(u.row) { + *s += unit_uses(u); + } + } + let total: f64 = sums.iter().sum(); + if total <= 0.0 { + return vec![0.0; rows]; + } + sums.iter().map(|s| s / total * 100.0).collect() +} + +/// For Recolor Artwork's Prominent Colors (our own rule): the row each of `units` goes to so that +/// each row's share of the uses comes as near its share of `weights` as whole units allow. One +/// unit moves at a time, while a move brings the rows' shares nearer their weights (the sum of +/// every row's distance from its weight falls); of those moves, the one taken is the unit nearest +/// in Lab (CIEDE2000) to the color of the row it goes to (`anchors`), compared with the row it +/// leaves; on a tie, the move that brings the shares nearest. Fixed units, units in a row past +/// `anchors` and units used no times stay where they are. Weights that are negative or not finite +/// count as 0; when no weight is positive, every unit stays. The moves stop after +/// [`WEIGH_WORK`] comparisons of a unit with a row, and when the movable units times the rows pass +/// it, every unit stays. +pub fn weigh(units: &[WeighUnit], anchors: &[Color], weights: &[f64]) -> Vec { + let mut row: Vec = units.iter().map(|u| u.row).collect(); + let n = anchors.len(); + let given = |r: usize| weights.get(r).copied().filter(|x| x.is_finite() && *x > 0.0).unwrap_or(0.0); + // Scaled by the largest weight first, so the sum stays finite. + let top = (0..n).map(given).fold(0.0, f64::max); + let w = |r: usize| if top > 0.0 { given(r) / top } else { 0.0 }; + let sum_w: f64 = (0..n).map(w).sum(); + let movable: Vec = (0..units.len()).filter(|&i| !units[i].fixed && units[i].row < n && unit_uses(&units[i]) > 0.0).collect(); + let per_pass = movable.len().saturating_mul(n); + if sum_w <= 0.0 || n < 2 || movable.is_empty() || per_pass > WEIGH_WORK { + return row; + } + let mut sums = vec![0.0; n]; + for u in units.iter().filter(|u| u.row < n) { + sums[u.row] += unit_uses(u); + } + let total: f64 = sums.iter().sum(); + let target: Vec = (0..n).map(|r| w(r) / sum_w * total).collect(); + let anchor_labs: Vec = anchors.iter().map(|c| c.to_lab()).collect(); + // Each movable unit's distance to each row's color. + let dist: Vec> = movable + .iter() + .map(|&i| { + let l = units[i].color.to_lab(); + anchor_labs.iter().map(|a| delta_e2000(*a, l)).collect() + }) + .collect(); + // A move must bring the shares nearer by more than rounding. + let eps = total * 1e-9; + for _ in 0..WEIGH_WORK / per_pass { + let mut best: Option<(f32, f64, usize, usize)> = None; + for (m, &i) in movable.iter().enumerate() { + let (a, x) = (row[i], unit_uses(&units[i])); + let before_a = (sums[a] - target[a]).abs(); + let after_a = (sums[a] - x - target[a]).abs(); + for b in (0..n).filter(|&b| b != a) { + let gain = before_a + (sums[b] - target[b]).abs() - after_a - (sums[b] + x - target[b]).abs(); + if gain <= eps { + continue; + } + let cost = dist[m][b] - dist[m][a]; + let better = match best { + None => true, + Some((c, g, ..)) => cost < c || (cost == c && gain > g), + }; + if better { + best = Some((cost, gain, m, b)); + } + } + } + let Some((_, _, m, b)) = best else { break }; + let i = movable[m]; + let x = unit_uses(&units[i]); + sums[row[i]] -= x; + sums[b] += x; + row[i] = b; + } + row +} + /// A set of colours to snap to (Limit to Library), with their Lab values cached. #[derive(Clone, Debug, Default)] pub struct Palette(Vec<(Color, Lab)>); @@ -911,4 +1017,55 @@ mod tests { assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. })); assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey"); } + + fn unit(color: Color, uses: f64, row: usize, fixed: bool) -> WeighUnit { + WeighUnit { color, uses, row, fixed } + } + + #[test] + fn row_shares_are_percent_of_the_uses() { + let units = [unit(Color::rgb(1.0, 0.0, 0.0), 3.0, 0, true), unit(Color::rgb(0.0, 0.0, 1.0), 1.0, 1, true), unit(Color::WHITE, 5.0, 4, false)]; + assert_eq!(row_shares(&units, 2), vec![75.0, 25.0]); + assert_eq!(row_shares(&[], 2), vec![0.0, 0.0]); + assert_eq!(row_shares(&[unit(Color::WHITE, f64::NAN, 0, false)], 1), vec![0.0]); + } + + #[test] + fn weigh_moves_the_nearest_colors_toward_the_weights() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let units = [ + unit(red, 1.0, 0, true), + unit(Color::rgb(0.9, 0.1, 0.3), 1.0, 0, false), + unit(Color::rgb(0.5, 0.0, 0.8), 1.0, 0, false), + unit(blue, 1.0, 1, true), + ]; + // Equal weights: the reddish purple, nearer blue than the red-pink is, moves. + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.0]), vec![0, 0, 1, 1]); + // Most of the weight on blue: both movable colors go, the row's own color stays. + assert_eq!(weigh(&units, &[red, blue], &[0.0, 1.0]), vec![0, 1, 1, 1]); + // Weights already met, or none positive: nothing moves. + assert_eq!(weigh(&units, &[red, blue], &[3.0, 1.0]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[0.0, f64::NAN]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1e308, 1e308]), vec![0, 0, 1, 1], "huge weights act as equal ones"); + assert_eq!(weigh(&units, &[red], &[1.0]), vec![0, 0, 0, 1], "a row past the anchors keeps its units"); + } + + #[test] + fn weigh_takes_whole_units_and_stops_when_no_move_helps() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + // A heavy unit: moving it would overshoot by more than staying misses. + let units = [unit(red, 1.0, 0, true), unit(Color::rgb(0.8, 0.0, 0.2), 10.0, 0, false), unit(blue, 10.0, 1, true)]; + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.4]), vec![0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1.0, 20.0]), vec![0, 1, 1]); + // Many units: the result still follows the weights. + let many: Vec = (0..400).map(|i| unit(Color::rgb(i as f32 / 400.0, 0.2, 1.0 - i as f32 / 400.0), 1.0, 0, i == 0)).collect(); + let rows = weigh(&many, &[Color::rgb(0.0, 0.2, 1.0), Color::rgb(1.0, 0.2, 0.0)], &[1.0, 3.0]); + assert_eq!(rows.iter().filter(|r| **r == 1).count(), 300); + assert!(rows[399] == 1 && rows[1] == 0, "the reddest colors go to the red row"); + // Past the work limit (8,000 units by 7,000 rows) nothing moves. + let units: Vec = (0..8_000).map(|i| unit(Color::WHITE, 1.0, 0, i == 0)).collect(); + let anchors = vec![Color::BLACK; 7_000]; + let weights = vec![1.0; 7_000]; + assert!(weigh(&units, &anchors, &weights).iter().all(|r| *r == 0)); + } } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d9e179286f..d170bd606f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1326,27 +1326,39 @@ impl Spine { let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; let sp = &self.subpath; let n = sp.segment_count(); - let mut start = 0.0; - for i in 0..n { - let c = sp.segment(i); - let end = self.anchor_length(i + 1).unwrap_or(self.length()); - if s <= end || i + 1 == n { - let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); - let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); - let e = c.eval(t); - let tv = c.deriv().eval(t).to_vec2(); - // Where a handle sits on its anchor the derivative vanishes there: take the - // direction between points a little before and after instead. - let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; - return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); - } - start = end; + if n == 0 { + return (self.pts.first().copied().unwrap_or_default(), 0.0); } - // Only a spine without segments gets here: the loop returns at the last one. - (self.pts.first().copied().unwrap_or_default(), 0.0) + let i = segment_at(&self.anchors, n, s, || {}); + let c = sp.segment(i); + let start = if i == 0 { 0.0 } else { self.anchor_length(i).unwrap_or(0.0) }; + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + (Point::new(e.x, e.y), tv.y.atan2(tv.x)) } } +/// The segment of a spine with `n` segments (`n > 0`) that holds arc length `s`: the first one whose +/// end is at or past `s`, or the last one when none is. `anchors` holds the arc length at each +/// anchor, so segment `i` ends at `anchors[i + 1]`, and the last segment of a closed spine, which has +/// no entry for its end, ends at the spine's length. The lengths never decrease (each is a sum of +/// distances that are not negative, at most `+inf` and never NaN), so a binary search finds the +/// segment in about `log2(n)` comparisons. `probe` is called once for each comparison. +fn segment_at(anchors: &[f64], n: usize, s: f64, mut probe: impl FnMut()) -> usize { + let ends = anchors.get(1..n.min(anchors.len())).unwrap_or_default(); + ends.partition_point(|&end| { + probe(); + end < s + }) + .min(n.saturating_sub(1)) +} + /// The anchor of the first subpath each key sits on, when they are all valid for `n` anchors. fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { (spec.key_anchors.len() == keys && keys >= 2 && spec.key_anchors.iter().all(|a| (*a as usize) < n)) @@ -1369,6 +1381,14 @@ pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Op layout_spine(keys, spec) } +/// The line the steps of a blend of `keys` are placed along: [`blend_spine`]'s path for a blend +/// with a spine, and the straight lines between the key centers, in key order, for a blend made +/// without one ([`BlendSpec::no_spine`]). For a blend without a spine the line places the steps +/// only; it is not a spine and is not drawn as one. `None` with fewer than two keys. +pub fn blend_layout_line(keys: &[Arc], spec: &BlendSpec) -> Option { + layout_spine(keys, spec).map(|(path, _)| path) +} + /// The spine `spec` stores, unless the blend was made without one. fn stored_spine(spec: &BlendSpec) -> Option<&PathData> { spec.spine.as_ref().filter(|_| !spec.no_spine) @@ -1840,6 +1860,28 @@ mod tests { assert_eq!(moved.subpaths[0].anchors[2].p, Point::new(305.0, 5.0)); } + #[test] + fn a_blend_without_a_spine_has_a_layout_line_through_its_key_centers() { + let at = + |x: f64, y: f64| Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x - 5.0, y - 5.0, x + 5.0, y + 5.0)), Appearance::default())); + let line_of = |path: &PathData| path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect::>(); + // A stored spine is ignored once the blend is made without one. + let stored = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(300.0, 50.0)], false)); + let spec = BlendSpec { no_spine: true, spine: Some(stored), ..Default::default() }; + for keys in [vec![at(10.0, 20.0), at(110.0, 20.0)], vec![at(10.0, 20.0), at(110.0, 20.0), at(110.0, 220.0), at(-40.0, 0.0)]] { + assert!(blend_spine(&keys, &spec).is_none(), "still no spine"); + let line = blend_layout_line(&keys, &spec).unwrap(); + assert_eq!(line.subpaths.len(), 1); + assert!(!line.subpaths[0].closed); + let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); + assert_eq!(line_of(&line), centers, "in key order"); + } + // With a spine, the layout line is the spine. + let keys = vec![key(0.0), key(100.0), key(300.0)]; + assert_eq!(blend_layout_line(&keys, &BlendSpec::default()), blend_spine(&keys, &BlendSpec::default()).map(|(p, _)| p)); + assert!(blend_layout_line(&keys[..1], &spec).is_none(), "one key"); + } + #[test] fn steps_follow_a_bent_spine() { let keys = vec![key(0.0), key(100.0)]; @@ -1891,6 +1933,76 @@ mod tests { assert!(end.is_finite(), "{end}"); } + /// [`Spine::at_length`] by walking the segments from the first, as a reference for the search. + fn walked_at_length(sp: &Spine, s: f64) -> (Point, f64) { + let s = if s.is_finite() { s.clamp(0.0, sp.length()) } else { 0.0 }; + let n = sp.subpath.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.subpath.segment(i); + let end = sp.anchor_length(i + 1).unwrap_or(sp.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start = end; + } + panic!("a spine without segments") + } + + /// The search picks the same segment as walking them in order, so points and tangents stay the + /// same: on open and closed spines, one segment, curves, and zero-length segments whose anchors + /// share an arc length; at every anchor, between anchors, at both ends, past them, and for + /// values that are not finite. + #[test] + fn spine_lookup_matches_walking_the_segments() { + let p = Point::new; + let bow = SubPath::new( + vec![Anchor::smooth(p(0.0, 0.0), p(50.0, 50.0)), Anchor::smooth(p(100.0, 0.0), p(150.0, -50.0)), Anchor::corner(p(200.0, 0.0))], + false, + ); + let spines = [ + SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(30.0, 5.0), p(30.0, 5.0)], false), + SubPath::polyline(&[p(0.0, 0.0), p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0)], true), + SubPath::polyline(&[p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0), p(0.0, 0.0)], true), + SubPath::polyline(&[p(0.0, 0.0), p(25.0, 25.0)], false), + SubPath::polyline(&[p(0.0, 0.0), p(25.0, 25.0)], true), + bow.clone(), + SubPath { closed: true, ..bow }, + ]; + for sub in spines { + let sp = Spine::new(&PathData::single(sub)).unwrap(); + let len = sp.length(); + let mut lengths = vec![0.0, len, -1.0, len + 1.0, -len, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN]; + lengths.extend(sp.anchors.iter().flat_map(|&a| [a, a - 1e-9, a + 1e-9])); + lengths.extend((0..=400).map(|k| len * k as f64 / 400.0)); + for s in lengths { + assert_eq!(sp.at_length(s), walked_at_length(&sp, s), "s = {s} on {:?}", sp.anchors); + } + } + } + + /// Finding the segment for an arc length on a spine of thousands of segments takes a few + /// comparisons, not one for each segment before it. + #[test] + fn spine_lookup_compares_a_few_segment_ends() { + let pts: Vec = (0..=4096).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.0 } else { 1.0 })).collect(); + let sp = Spine::new(&PathData::single(SubPath::polyline(&pts, false))).unwrap(); + let n = sp.subpath.segment_count(); + assert_eq!(n, 4096); + for s in [sp.length() - 0.5, sp.length(), sp.length() / 2.0, 0.25] { + let mut compared = 0; + let i = segment_at(&sp.anchors, n, s, || compared += 1); + assert!(compared <= 13, "{compared} comparisons at {s}"); + assert_eq!(sp.at_length(s), walked_at_length(&sp, s)); + assert!(sp.anchors[i] <= s && (i + 1 == n || s <= sp.anchors[i + 1]), "segment {i} at {s}"); + } + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index ea2c44215e..e89d63cb54 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -140,6 +140,21 @@ pub fn tiff_orientation(bytes: &[u8]) -> Option { d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() } +/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when +/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with +/// extra samples) has its Orientation tag read from its first image ([`tiff_orientation`]). Reads +/// only the image's header and metadata; `None` for an [`oversized_tiff`], which no decoder opens. +pub fn exif_orientation(bytes: &[u8]) -> Option { + use image::ImageDecoder as _; + use image::metadata::Orientation; + if oversized_tiff(bytes).is_some() { + return None; + } + let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); + let tagged = || tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); + decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -151,24 +166,180 @@ fn planar(d: &mut tiff::decoder::Decoder>) -> bool { /// that many samples, which includes images whose planes together need more than the decoder's /// limit (256 MB of samples). pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { + Some(match read_samples_at_depth(d, n, per)? { + Samples::Eight(v) => v, + Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + }) +} + +/// Samples of an image, pixel by pixel, at the depth its file stores them. +#[derive(Clone, Debug, PartialEq)] +enum Samples { + Eight(Vec), + Sixteen(Vec), +} + +impl Samples { + fn as_slice(&self) -> SampleSlice<'_> { + match self { + Self::Eight(v) => SampleSlice::Eight(v), + Self::Sixteen(v) => SampleSlice::Sixteen(v), + } + } +} + +/// [`Samples`], borrowed. +#[derive(Clone, Copy, Debug)] +enum SampleSlice<'a> { + Eight(&'a [u8]), + Sixteen(&'a [u16]), +} + +/// [`read_samples`], keeping 16-bit samples at 16 bits. +fn read_samples_at_depth(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option { let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); let layout = d.read_image_to_buffer(&mut buf).ok()?; - let data = match buf { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; + match buf { + tiff::decoder::DecodingResult::U8(v) => chunky(v, layout.planes, n, per).map(Samples::Eight), + tiff::decoder::DecodingResult::U16(v) => chunky(v, layout.planes, n, per).map(Samples::Sixteen), + _ => None, + } +} + +/// `data`, the samples of `n` pixels, `per` to a pixel, stored in `planes` planes, as the samples +/// pixel by pixel; `None` when it doesn't hold that many samples or a plane per sample. +fn chunky(data: Vec, planes: usize, n: usize, per: usize) -> Option> { if Some(data.len()) != n.checked_mul(per) { return None; } - if layout.planes <= 1 { + if planes <= 1 { return Some(data); } - if layout.planes != per { + if planes != per { return None; } - let planes: Vec<&[u8]> = data.chunks_exact(n).collect(); - Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect()) + let planes: Vec<&[T]> = data.chunks_exact(n).collect(); + Some((0..n).flat_map(|i| planes.iter().filter_map(move |p| p.get(i).copied())).collect()) +} + +/// The color space an ICC profile's header names (bytes 16 to 19, such as `RGB `, `GRAY` or +/// `CMYK`); `None` when `icc` is shorter than a header. +pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> { + if icc.len() < 128 { + return None; + } + icc.get(16..20)?.try_into().ok() +} + +/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one. +fn tiff_icc(d: &mut tiff::decoder::Decoder>) -> Option> { + d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty()) +} + +/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own +/// limits, whatever the size of the image; `None` for other images and TIFFs without one. +pub fn tiff_icc_profile(bytes: &[u8]) -> Option> { + let (_, mut d) = tiff(bytes)?; + tiff_icc(&mut d) +} + +/// The ICC profile of a JPEG, put together from all its numbered `ICC_PROFILE` APP2 segments; +/// `None` without one. +fn jpeg_icc(bytes: &[u8]) -> Option> { + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default()); + d.decode_headers().ok()?; + d.icc_profile().filter(|icc| !icc.is_empty()) +} + +/// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in. +fn cmyk_icc(icc: Option>) -> Option> { + icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK")) +} + +/// The CMYK samples of a CMYK image at the depth its file stores them, and its ICC profile. +struct CmykSource { + width: u32, + height: u32, + samples: Samples, + icc: Option>, +} + +/// The samples of CMYK TIFF `bytes` (8 or 16 bits), their inks together or one plane per ink, with +/// the TIFF's CMYK ICC profile; `None` for other images and ones larger than [`MAX_CMYK_PIXELS`]. +fn tiff_source(bytes: &[u8]) -> Option { + let (kind, mut d) = tiff(bytes)?; + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { + return None; + } + let (width, height) = d.dimensions().ok()?; + let n = usize::try_from(pixels(width, height)?).ok()?; + let icc = cmyk_icc(tiff_icc(&mut d)); + let samples = read_samples_at_depth(&mut d, n, 4)?; + Some(CmykSource { width, height, samples, icc }) +} + +/// The ink amounts of a four-component JPEG, 8 bits each, with its CMYK ICC profile. +fn jpeg_source(bytes: &[u8]) -> Option { + let Inks { width, height, data } = jpeg_inks(bytes)?; + Some(CmykSource { width, height, samples: Samples::Eight(data), icc: cmyk_icc(jpeg_icc(bytes)) }) +} + +/// A Deflate-compressed CMYK TIFF of `width` × `height` pixels of `samples` (inks together, 8 or 16 +/// bits as they are given), declaring the resolution `ppi` when both of its values are finite and +/// above 0, and carrying `icc` as tag 34675 (ICCProfile, of type UNDEFINED); `None` when it can't be +/// written. +fn write_cmyk_tiff(width: u32, height: u32, samples: SampleSlice<'_>, ppi: Option<(f64, f64)>, icc: Option<&[u8]>) -> Option> { + use tiff::encoder::colortype::{CMYK8, CMYK16, ColorType}; + use tiff::encoder::{Compression, ImageEncoder, Rational, TiffEncoder, TiffKind, compression::DeflateLevel}; + use tiff::tags::{ResolutionUnit, Tag}; + fn tags( + img: &mut ImageEncoder<'_, W, C, K>, + ppi: Option<(f64, f64)>, + icc: Option<&[u8]>, + ) -> Option<()> { + if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; + } + if let Some(icc) = icc { + img.encoder().write_tag(Tag::IccProfile, Undefined(icc)).ok()?; + } + Some(()) + } + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + match samples { + SampleSlice::Eight(data) => { + let mut img = enc.new_image::(width, height).ok()?; + tags(&mut img, ppi, icc)?; + img.write_data(data).ok()?; + } + SampleSlice::Sixteen(data) => { + let mut img = enc.new_image::(width, height).ok()?; + tags(&mut img, ppi, icc)?; + img.write_data(data).ok()?; + } + } + Some(out.into_inner()) +} + +/// Bytes written as a TIFF field of type UNDEFINED, the type of an embedded ICC profile. +struct Undefined<'a>(&'a [u8]); + +impl tiff::encoder::TiffValue for Undefined<'_> { + const BYTE_LEN: u8 = 1; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED; + + fn count(&self) -> usize { + self.0.len() + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + std::borrow::Cow::Borrowed(self.0) + } } impl ImageBlob { @@ -180,20 +351,8 @@ impl ImageBlob { /// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per /// inch) when it is given and both are finite and above 0. pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option { - use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; - use tiff::tags::{ResolutionUnit, Tag}; - let mut out = Cursor::new(Vec::new()); - let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); - let mut img = enc.new_image::(inks.width, inks.height).ok()?; - if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { - // Ten-thousandths of a pixel per inch. - let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; - img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; - img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; - img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; - } - img.write_data(&inks.data).ok()?; - Some(Self::new("image/tiff", out.into_inner())) + let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), ppi, None)?; + Some(Self::new("image/tiff", tiff)) } /// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its @@ -318,30 +477,38 @@ fn tiff_inks(bytes: &[u8]) -> Option { /// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and /// Place store them: as they are, or, when they store one plane per ink, which the image decoder -/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution -/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +/// doesn't read, their inks together in a CMYK TIFF with the resolution `ppi`, at the 8 or 16 bits +/// the file has, carrying the file's CMYK ICC profile. `None` for other images and for CMYK TIFFs +/// whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { - let inks = tiff_inks(bytes)?; + let source = tiff_source(bytes)?; let (_, mut d) = tiff(bytes)?; if !planar(&mut d) { return Some(bytes.to_vec()); } - ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) + write_cmyk_tiff(source.width, source.height, source.samples.as_slice(), ppi, source.icc.as_deref()) } /// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their -/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by -/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned -/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`] -/// doesn't read. +/// inks turned and written again as a CMYK TIFF with the resolution `ppi` of the turned pixels, +/// with the turned width and height. A TIFF keeps its 8 or 16 bits, a JPEG has 8, and both keep +/// their CMYK ICC profile (a JPEG's put together from all its APP2 segments). `None` for other +/// images and for CMYK images whose inks [`ImageBlob::cmyk`] doesn't read. pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { - let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?; - // Four 8-bit inks to a pixel, turned as four 8-bit channels are. - let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + let CmykSource { width, height, samples, icc } = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_source(bytes) } else { tiff_source(bytes) }?; + // Four inks to a pixel, turned as four channels of the same depth are. + let mut px = match samples { + Samples::Eight(v) => image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(width, height, v)?), + Samples::Sixteen(v) => image::DynamicImage::ImageRgba16(image::ImageBuffer::from_raw(width, height, v)?), + }; px.apply_orientation(orientation); - let inks = Inks::new(px.width(), px.height(), px.into_bytes())?; - let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; - Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height))) + let size = (px.width(), px.height()); + let samples = match px { + image::DynamicImage::ImageRgba16(v) => Samples::Sixteen(v.into_raw()), + other => Samples::Eight(other.into_rgba8().into_raw()), + }; + let tiff = write_cmyk_tiff(size.0, size.1, samples.as_slice(), ppi, icc.as_deref())?; + Some((tiff, size)) } /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 708d0aee03..057a9df6a6 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -286,7 +286,9 @@ impl Document { self.next_id = self.next_id.max(id); } - /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data), then give + /// the objects outside the layer tree (opacity-mask art, symbols, graph designs and patterns) + /// whose id is missing or already taken a new one, so each stored object has an id of its own. pub fn fix_next_id(&mut self) { // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a // counter up there: new ids start under them and never reach them, where counting on from @@ -297,13 +299,12 @@ impl Document { max = max.max(id); } }; - for l in &self.layers { - l.walk(&mut |n| see(n.id.0)); - } + self.visit_stored(|n, _| see(n.id.0)); self.slices.iter().for_each(|s| see(s.id.0)); self.assets.iter().for_each(|a| see(a.id)); let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); self.next_id = counter.max(max.saturating_add(1)); + self.renumber_stored_duplicates(); } /// Find a node anywhere in the tree. diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 4ec7aab1f6..14d0404bfc 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -554,6 +554,13 @@ impl Document { t.cached_baselines.clear(); t.cached_ink = None; } + // Opacity-mask art is stored art too: the copy's gets ids of its own. + if let Some(art) = n.extras().mask.as_ref().map(|m| Arc::clone(&m.art)) { + let art = Arc::new(self.reid(&art)); + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = art; + } + } let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); if let Some(ch) = n.children_mut() { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a1122e5a7c..63a2676f77 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -142,6 +142,7 @@ mod graphic_style; mod guides; mod limits; mod records; +mod stored_art; mod units; pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; @@ -154,6 +155,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; +pub use stored_art::{StoredPlace, StoredRoot}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 39fe8cbc43..52453fd5a6 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -39,7 +39,7 @@ fn fifty() -> f64 { // ===================================================================================== pub use crate::blend::{ - Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, + Spine, blend_expand, blend_layout_line, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, steps_knockout_shows, }; @@ -568,23 +568,167 @@ mod tests { } } + /// The outline of a 200 × 100 box whose top side is `pieces`, end to end from (0, 0) to + /// (200, 0), and whose other sides are straight. + fn top_of_pieces(pieces: &[CubicBez]) -> PathData { + let pt = |p: Point| (p.x, p.y); + let mut knots: Vec = vec![]; + for (k, c) in pieces.iter().enumerate() { + let h_in = if k == 0 { pt(c.p0) } else { pt(pieces[k - 1].p2) }; + knots.push((pt(c.p0), h_in, pt(c.p1))); + } + let last = pieces.last().unwrap(); + knots.push(((200.0, 0.0), pt(last.p2), (200.0, 0.0))); + knots.extend([((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)), ((0.0, 100.0), (0.0, 100.0), (0.0, 100.0))]); + outline(&knots) + } + + /// A top curve whose parameter runs fast at its start and slow at its end. + const UNEVEN_TOP: CubicBez = + CubicBez { p0: Point::new(0.0, 0.0), p1: Point::new(120.0, -60.0), p2: Point::new(190.0, -30.0), p3: Point::new(200.0, 0.0) }; + #[test] - fn a_split_curve_moves_the_top_object_share_to_its_new_anchor() { - // Splitting the top curve at t = 0.3 keeps its shape but changes the polygon shares: the - // new anchor lands at the first piece's share, not at u = 0.3. - let top = CubicBez::new((0.0, 0.0), (40.0, -40.0), (160.0, -40.0), (200.0, 0.0)); - let (a, b) = (top.subsegment(0.0..0.3), top.subsegment(0.3..1.0)); - let path = outline(&[ - ((0.0, 0.0), (0.0, 0.0), a.p1.into()), - (a.p3.into(), a.p2.into(), b.p1.into()), - ((200.0, 0.0), b.p2.into(), (200.0, 0.0)), - ((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)), - ((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)), - ]); - let c = Coons::from_top_object(&path, None).unwrap(); + fn a_split_curve_keeps_the_top_object_patch() { + // Splitting the top curve at t = 0.3, or into thirds, keeps its shape, and the patch stays + // the unsplit curve's: the new anchors land at u = 0.3, 1/3 and 2/3 (to within how finely + // the side is sampled), where the pieces' control polygons would have put them several + // points away. + let whole = Coons::from_top_object(&top_of_pieces(&[UNEVEN_TOP]), None).unwrap(); + for cuts in [vec![0.3], vec![1.0 / 3.0, 2.0 / 3.0]] { + let ends: Vec = std::iter::once(0.0).chain(cuts.iter().copied()).chain([1.0]).collect(); + let pieces: Vec = ends.windows(2).map(|w| UNEVEN_TOP.subsegment(w[0]..w[1])).collect(); + let c = Coons::from_top_object(&top_of_pieces(&pieces), None).unwrap(); + let total: f64 = pieces.iter().map(|p| polygon(*p)).sum(); + for (k, t) in cuts.iter().enumerate() { + let anchor = pieces[k].p3; + assert!(c.eval(*t, 0.0).distance(anchor) < 0.02, "{cuts:?}: u {t} → {:?}, want {anchor:?}", c.eval(*t, 0.0)); + let share: f64 = pieces[..=k].iter().map(|p| polygon(*p)).sum::() / total; + assert!(c.eval(share, 0.0).distance(anchor) > 2.0, "{cuts:?}: the polygon share {share} lands on the anchor"); + } + for (u, v) in [(0.1, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 1e-9, "{cuts:?}: ({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + } + + #[test] + fn a_split_curve_keeps_the_top_object_patch_when_split_at_a_cusp() { + // The top curve stops at t = 0.25 (a cusp), and an anchor splits it there: both handles at + // the split lie on the anchor, and the patch with the corners given stays the unsplit + // curve's. + let cusp = CubicBez::new((0.0, 0.0), (30.0, -40.0), (-58.0, 24.0), (200.0, 0.0)); + let pieces = [cusp.subsegment(0.0..0.25), cusp.subsegment(0.25..1.0)]; + assert!(pieces[0].p2.distance(pieces[0].p3) < 1e-9 && pieces[1].p1.distance(pieces[1].p0) < 1e-9); + let whole = Coons::from_top_object(&top_of_pieces(&[cusp]), Some([0, 1, 2, 3])).unwrap(); + let c = Coons::from_top_object(&top_of_pieces(&pieces), Some([0, 2, 3, 4])).unwrap(); + for (u, v) in [(0.1, 0.0), (0.25, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 1e-6, "({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + + #[test] + fn a_split_curve_keeps_the_top_object_patch_where_it_barely_moves_and_runs_straight() { + // A straight top curve that barely moves at t = 0.25 and doesn't bend there: split there, + // the handles at the split are a few thousandths of a point long and the pieces don't bend + // at all, and the patch with the corners given stays the unsplit curve's. + let slow = CubicBez::new((0.0, 0.0), (28.578125, 0.0), (-57.109375, 0.0), (200.0, 0.0)); + let pieces = [slow.subsegment(0.0..0.25), slow.subsegment(0.25..1.0)]; + let (into, out) = (pieces[0].p3.distance(pieces[0].p2), pieces[1].p0.distance(pieces[1].p1)); + assert!(into > 0.0 && out > 0.0 && into.max(out) < 0.01, "{into} {out}"); + let whole = Coons::from_top_object(&top_of_pieces(&[slow]), Some([0, 1, 2, 3])).unwrap(); + let c = Coons::from_top_object(&top_of_pieces(&pieces), Some([0, 2, 3, 4])).unwrap(); + for (u, v) in [(0.1, 0.0), (0.25, 0.0), (0.3, 0.2), (0.5, 0.5), (0.62, 0.0), (0.9, 0.7), (0.97, 1.0)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 1e-6, "({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + + #[test] + fn a_top_object_joins_a_split_curve_saved_with_rounded_coordinates() { + // A file saves the pieces of a curve split into thirds, or into tenths, with its + // coordinates rounded to 0.001 pt, on an outline 5, 200 or 5,000 pt wide: the side is + // still that curve, to within 0.02 pt. + let round = |p: Point| Point::new((p.x * 1000.0).round() / 1000.0, (p.y * 1000.0).round() / 1000.0); + for cuts in [3u32, 10] { + let pieces: Vec = + (0..cuts).map(|k| UNEVEN_TOP.subsegment(f64::from(k) / f64::from(cuts)..f64::from(k + 1) / f64::from(cuts))).collect(); + for scale in [0.025, 1.0, 25.0] { + let mut split = top_of_pieces(&pieces); + split.transform(Affine::scale(scale)); + for a in split.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { + (a.p, a.h_in, a.h_out) = (round(a.p), round(a.h_in), round(a.h_out)); + } + let mut whole = top_of_pieces(&[UNEVEN_TOP]); + whole.transform(Affine::scale(scale)); + let (c, whole) = (Coons::from_top_object(&split, None).unwrap(), Coons::from_top_object(&whole, None).unwrap()); + for (u, v) in [(1.0 / 6.0, 0.0), (1.0 / 3.0, 0.0), (0.45, 0.0), (0.5, 0.5), (2.0 / 3.0, 0.0), (0.9, 0.3)] { + let (p, q) = (c.eval(u, v), whole.eval(u, v)); + assert!(p.distance(q) < 0.02, "{cuts} pieces at {scale}x: ({u}, {v}) → {p:?}, unsplit {q:?}"); + } + } + } + } + + #[test] + fn a_top_object_keeps_smoothly_joined_curves_apart() { + // The top side's second piece leaves the split anchor in line with the first piece's + // handle, but its far handle is 1 pt off the split curve's: the pieces are two curves, and + // each takes the share of its control polygon. + let (a, b) = (UNEVEN_TOP.subsegment(0.0..0.3), UNEVEN_TOP.subsegment(0.3..1.0)); + let b = CubicBez::new(b.p0, b.p1, b.p2 + Vec2::new(0.0, 1.0), b.p3); + let c = Coons::from_top_object(&top_of_pieces(&[a, b]), None).unwrap(); let share = polygon(a) / (polygon(a) + polygon(b)); assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0)); - assert!((share - 0.3).abs() > 0.01, "{share}"); + assert!((share - 0.3).abs() > 0.02, "{share}"); + } + + #[test] + fn a_top_object_side_partly_of_one_curve_keeps_the_polygon_shares() { + // The top side is a curve split at t = 0.3, then a straight segment: not all of it is one + // curve, so each piece keeps the share of its control polygon. + let curve = CubicBez::new((0.0, 0.0), (90.0, -45.0), (142.5, -22.5), (150.0, 0.0)); + let (a, b) = (curve.subsegment(0.0..0.3), curve.subsegment(0.3..1.0)); + let line = CubicBez::new((150.0, 0.0), (150.0 + 50.0 / 3.0, 0.0), (200.0 - 50.0 / 3.0, 0.0), (200.0, 0.0)); + let c = Coons::from_top_object(&top_of_pieces(&[a, b, line]), None).unwrap(); + let share = polygon(a) / (polygon(a) + polygon(b) + polygon(line)); + assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0)); + // Joined into their curve, the two pieces would have put the anchor 0.3 of the way along + // their part of the side. + let joined = 0.3 * (polygon(a) + polygon(b)) / (polygon(a) + polygon(b) + polygon(line)); + assert!(c.eval(joined, 0.0).distance(a.p3) > 1.0, "{share} vs {joined}"); + } + + #[test] + fn a_top_object_side_of_many_arcs_stays_on_them() { + // A 120° arc of radius 500 drawn as `n` circular arcs, each close to a piece of one cubic + // while the whole arc is not one: the side stays on the circle, and the content's middle + // stays on the middle anchor, as the arcs' polygon shares put it. + let centre = Point::new(300.0, 600.0); + for n in [64u32, 128, 256] { + let step = 120f64.to_radians() / f64::from(n); + let reach = 500.0 * 4.0 / 3.0 * (step / 4.0).tan(); + let on = |a: f64| centre + Vec2::new(a.cos(), a.sin()) * 500.0; + let tangent = |a: f64| Vec2::new(-a.sin(), a.cos()) * reach; + let mut anchors: Vec = (0..=n) + .map(|i| { + let a = (-150f64).to_radians() + step * f64::from(i); + let p = on(a); + let h_in = if i == 0 { p } else { p - tangent(a) }; + let h_out = if i == n { p } else { p + tangent(a) }; + Anchor { p, h_in, h_out, ..Anchor::corner(p) } + }) + .collect(); + let (start, end) = (on((-150f64).to_radians()), on((-30f64).to_radians())); + anchors.extend([Anchor::corner(Point::new(end.x, 800.0)), Anchor::corner(Point::new(start.x, 800.0))]); + let path = PathData::single(SubPath::new(anchors, true)); + let c = Coons::from_top_object(&path, Some([0, n, n + 1, n + 2])).unwrap(); + let off = (0..=4000).map(|k| (c.eval(f64::from(k) / 4000.0, 0.0).distance(centre) - 500.0).abs()).fold(0.0, f64::max); + assert!(off < 0.001, "{n} arcs: the side lies {off} pt off the circle"); + let mid = c.eval(0.5, 0.0); + assert!(mid.distance(Point::new(300.0, 100.0)) < 0.001, "{n} arcs: the middle lands at {mid:?}"); + } } #[test] diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index d912479464..b6d5bbc88d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -147,14 +147,18 @@ impl Coons { /// The patch of a top object's outline (the largest subpath of `path`), split into four sides /// at its corners. Each piece of a side between two anchors or corners takes a share of `u` or /// `v` in proportion to the length of its control polygon, spread along the piece by its own - /// curve parameter. A segment without handles counts as a cubic with its handles a third of - /// the way along, so the content spreads evenly along it. `corners` are the anchors (indices - /// into the outline, in its own order) at the top left, top right, bottom right and bottom - /// left, as a saved file gives them. They stay on those anchors when they run the way the - /// outline does or the opposite way, so a reflected outline keeps them; without them, or when - /// they don't fit the outline, each corner is found from the outline ([`top_object_corner`]). - /// A straight segment closes an open subpath, and segments of no length are left out. A top - /// object's envelope maps onto this patch. + /// curve parameter. A side whose pieces, from corner to corner, are all one cubic curve split at + /// anchors (each within 0.02 pt of that curve's piece) runs along that curve instead, by its + /// parameter: splitting the curve of such a side at more anchors, without changing its shape, + /// leaves how the side shares out the content as it was (corners found from the outline can + /// still move). A segment without handles counts as a cubic with its handles a third of the way + /// along, so the content spreads evenly along it. `corners` are the anchors (indices into the + /// outline, in its own order) at the top left, top right, bottom right and bottom left, as a + /// saved file gives them. They stay on those anchors when they run the way the outline does or + /// the opposite way, so a reflected outline keeps them; without them, or when they don't fit + /// the outline, each corner is found from the outline ([`top_object_corner`]). A straight + /// segment closes an open subpath, and segments of no length are left out. A top object's + /// envelope maps onto this patch. pub fn from_top_object(path: &PathData, corners: Option<[u32; 4]>) -> Option { let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?; let mut sp = sp.clone(); @@ -212,11 +216,12 @@ impl Coons { segs.get(i).map_or(Point::ZERO, |(c, _)| c.eval((s - i as f64).clamp(0.0, 1.0))) }; // The side from `a` to `b`: its points, and where along it (0 to 1) each lies. It runs - // through pieces of segments, split at `a` and `b`. Each piece takes its share of the side - // by the length of its control polygon and holds points at even steps of its parameter - // (at least 16, and at least `SIDE` points on the side), its ends included. A side of - // many pieces takes fewer points in each, so it holds at most `MAX_SIDE_POINTS` points, - // or one for each piece when it has more pieces than that. + // through pieces of segments, split at `a` and `b`; when all of them are one curve split + // at anchors ([`one_curve`]), it runs along that curve instead. Each piece takes its share + // of the side by the length of its control polygon and holds points at even steps of its + // parameter (at least 16, and at least `SIDE` points on the side), its ends included. A + // side of many pieces takes fewer points in each, so it holds at most `MAX_SIDE_POINTS` + // points, or one for each piece when it has more pieces than that. let side = |a: f64, b: f64| { let span = (b - a).rem_euclid(n); if !(span.is_finite() && span > 0.0) { @@ -232,8 +237,11 @@ impl Coons { } s = next; } - let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3); - let mut weights: Vec = pieces.iter().map(polygon).collect(); + let pieces = match one_curve(&pieces) { + Some(one) => vec![one], + None => pieces, + }; + let mut weights: Vec = pieces.iter().map(control_polygon).collect(); let mut total: f64 = weights.iter().sum(); if !(total.is_finite() && total > 0.0) { weights.iter_mut().for_each(|w| *w = 1.0); @@ -336,6 +344,76 @@ impl Coons { /// anchors. const MAX_SIDE_POINTS: usize = 1 << 16; +/// How far, in points, each control point of each piece of a top object's side may lie from the +/// matching piece of one cubic curve for the side to run along that curve ([`one_curve`]); the side +/// then lies within this of the pieces it is drawn with. A curve split into thirds or tenths and +/// saved with its coordinates rounded to 0.001 pt stays within it, on outlines from 5 to 5,000 pt +/// wide; coarser rounding, or a piece of a much smaller share of the curve, can take it past this. +/// A smooth side that isn't one curve, such as an arc of a circle drawn in many pieces, lies +/// further off as a whole, even where each pair of its pieces would not. +const ONE_CURVE: f64 = 0.02; + +/// The length of `c`'s control polygon. +fn control_polygon(c: &CubicBez) -> f64 { + c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3) +} + +/// The cubic curve that `pieces`, two or more end to end, are all pieces of, split at the anchors +/// between them, when each control point of each piece lies within [`ONE_CURVE`] of the matching +/// piece of that curve; `None` otherwise. Split at parameter `t`, a curve's handles on either side +/// of the split lie in line, their lengths in proportion to the spans of its parameter the two +/// pieces take; split where the curve stops (a cusp), both handles lie on the anchor, and the +/// pieces' second derivatives there, in proportion to the squares of the spans, give the proportion +/// instead (whichever of the two pairs is the longer). Those proportions give each piece's span, and the curve follows from the first +/// piece's start and its handle, and the last piece's end and its handle, scaled up by their spans. +/// Every piece is then checked against that curve's piece over its span, so error can't build up +/// from one split to the next (the first piece's start and handle, and the last piece's end and +/// handle, match by construction). +fn one_curve(pieces: &[CubicBez]) -> Option { + let (first, last) = (pieces.first()?, pieces.last()?); + if pieces.len() < 2 { + return None; + } + let mut spans = Vec::with_capacity(pieces.len()); + spans.push(1.0); + for pair in pieces.windows(2) { + let [a, b] = pair else { return None }; + let (into, out) = (a.p3.distance(a.p2), b.p0.distance(b.p1)); + // How far each piece bends away from the anchor goes with the squares of the spans. Split + // where the curve stops (a cusp), both handles lie on the anchor and say nothing of the + // spans, and the bends say it; where the curve runs straight through the split, the bends + // are nothing and the handles say it. Whichever pair is the longer is the one to go by. + let bend_in = ((a.p3 - a.p2) - (a.p2 - a.p1)).hypot(); + let bend_out = ((b.p2 - b.p1) - (b.p1 - b.p0)).hypot(); + let ratio = if into.min(out) >= bend_in.min(bend_out) { out / into } else { (bend_out / bend_in).sqrt() }; + let next = spans.last().copied().unwrap_or(1.0) * ratio; + if !(next.is_finite() && next > 0.0) { + return None; + } + spans.push(next); + } + let total: f64 = spans.iter().sum(); + let (head, tail) = (spans.first().copied().unwrap_or(1.0) / total, spans.last().copied().unwrap_or(1.0) / total); + if !(total.is_finite() && head > 0.0 && tail > 0.0) { + return None; + } + let one = CubicBez::new(first.p0, first.p0 + (first.p1 - first.p0) / head, last.p3 + (last.p2 - last.p3) / tail, last.p3); + if ![one.p1, one.p2].iter().all(|p| p.is_finite()) { + return None; + } + let near = |p: Point, q: Point| p.distance(q) <= ONE_CURVE; + let mut from = 0.0; + for (k, (piece, span)) in pieces.iter().zip(&spans).enumerate() { + let to = if k + 1 == pieces.len() { 1.0 } else { (from + span / total).min(1.0) }; + let part = one.subsegment(from..to); + if !(near(part.p0, piece.p0) && near(part.p1, piece.p1) && near(part.p2, piece.p2) && near(part.p3, piece.p3)) { + return None; + } + from = to; + } + Some(one) +} + /// How far into the outline's bounding box, as a share of its width and of its height, a corner /// of a top object looks for an anchor. const CORNER_REACH: f64 = 0.475; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 6f0db556e0..4ef6446247 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -144,6 +144,25 @@ impl Selection { self.key = None; } } + /// [`Self::remove`] for each of `ids`, going through the selected objects once. + pub fn remove_all(&mut self, ids: impl IntoIterator) { + let ids: HashSet = ids.into_iter().collect(); + if ids.is_empty() { + return; + } + self.target = None; + self.slices.clear(); + self.symbol_part = None; + self.on_path_member = None; + self.objects.retain(|x| !ids.contains(x)); + for id in &ids { + self.anchors.remove(id); + self.segments.remove(id); + } + if self.key.is_some_and(|k| ids.contains(&k)) { + self.key = None; + } + } /// Select slices `ids` (see [`Selection::slices`]) and nothing else. pub fn set_slices(&mut self, ids: impl IntoIterator) { self.clear(); diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d9df845129..b6090f8993 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -16,6 +16,35 @@ fn basic_ops() { assert!(s.is_empty()); } +#[test] +fn remove_all_does_what_removing_each_does() { + let mut s = Selection::default(); + for i in 1..=6 { + s.add(NodeId(i)); + } + for i in [2, 4, 5] { + s.anchors.insert(NodeId(i), BTreeSet::from([(0, 1)])); + s.segments.insert(NodeId(i), BTreeSet::from([(0, 0)])); + } + s.key = Some(NodeId(4)); + s.target = Some(NodeId(3)); + s.slices = vec![NodeId(9)]; + s.guides = vec![NodeId(10)]; + s.on_path_member = Some(NodeId(11)); + let mut one_by_one = s.clone(); + for id in [NodeId(4), NodeId(1), NodeId(4), NodeId(7)] { + one_by_one.remove(id); + } + let mut all = s.clone(); + all.remove_all([NodeId(4), NodeId(1), NodeId(4), NodeId(7)]); + assert_eq!(all, one_by_one); + assert_eq!(all.objects, [NodeId(2), NodeId(3), NodeId(5), NodeId(6)]); + // Removing nothing leaves the selection as it was. + let mut none = s.clone(); + none.remove_all([]); + assert_eq!(none, s); +} + #[test] fn guides_are_selected_on_their_own() { let mut s = Selection::default(); diff --git a/crates/doc/src/stored_art.rs b/crates/doc/src/stored_art.rs new file mode 100644 index 0000000000..e623e0ed0e --- /dev/null +++ b/crates/doc/src/stored_art.rs @@ -0,0 +1,376 @@ +//! Every object a document stores, wherever it is kept: in the layers, in opacity-mask art, and in +//! the art of symbols, graph designs and pattern swatches. [`Document::node`] finds objects in the +//! layer tree only; the lookups here find the others by their ids too, which +//! [`Document::fix_next_id`] keeps unique across all of them. + +use std::collections::HashSet; +use std::sync::Arc; + +use super::limits::MAX_NEXT_ID; +use super::{Document, Node, NodeId}; + +/// The art a stored object belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StoredRoot { + /// Layer `index` of [`Document::layers`]. + Layer(usize), + /// Symbol `index` of [`Document::symbols`]. + Symbol(usize), + /// Graph design `index` of [`Document::graph_designs`]. + GraphDesign(usize), + /// Object `art` of pattern swatch `pattern` of [`Document::patterns`]. + Pattern { pattern: usize, art: usize }, +} + +/// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs +/// to (the innermost one when masks hold masks). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StoredPlace { + pub root: StoredRoot, + pub mask_of: Option, +} + +impl StoredPlace { + /// Is the object in the layer tree itself, where [`Document::node`] finds it and the canvas + /// draws it as itself? + pub fn in_layers(self) -> bool { + matches!(self.root, StoredRoot::Layer(_)) && self.mask_of.is_none() + } +} + +/// One step down from a node: to its child at an index, or to its opacity-mask art. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Child(usize), + Mask, +} + +/// `n` and everything below it with its place, the opacity-mask art right after the object it +/// masks, then the children. +fn visit<'a>(n: &'a Arc, place: StoredPlace, f: &mut dyn FnMut(&'a Arc, StoredPlace)) { + f(n, place); + if let Some(m) = &n.extras().mask { + visit(&m.art, StoredPlace { mask_of: Some(n.id), ..place }, f); + } + for c in n.children().into_iter().flatten() { + visit(c, place, f); + } +} + +/// The steps from `n` to the first node in [`visit`] order with id `id`, and the object whose mask +/// holds it last on the way. +fn find(n: &Node, id: NodeId, steps: &mut Vec, mask_of: &mut Option) -> bool { + if n.id == id { + return true; + } + if let Some(m) = &n.extras().mask { + steps.push(Step::Mask); + let before = *mask_of; + *mask_of = Some(n.id); + if find(&m.art, id, steps, mask_of) { + return true; + } + *mask_of = before; + steps.pop(); + } + for (i, c) in n.children().into_iter().flatten().enumerate() { + steps.push(Step::Child(i)); + if find(c, id, steps, mask_of) { + return true; + } + steps.pop(); + } + false +} + +/// The node `steps` below `n`. +fn at<'a>(mut n: &'a Arc, steps: &[Step]) -> Option<&'a Arc> { + for s in steps { + n = match s { + Step::Mask => &n.extras().mask.as_ref()?.art, + Step::Child(i) => n.children()?.get(*i)?, + }; + } + Some(n) +} + +/// The node `steps` below `n` to change, the nodes on the way copied when shared. +fn at_mut<'a>(mut n: &'a mut Arc, steps: &[Step]) -> Option<&'a mut Node> { + for s in steps { + let node = Arc::make_mut(n); + n = match s { + Step::Mask => &mut node.extras_if_any()?.mask.as_mut()?.art, + Step::Child(i) => node.children_mut()?.get_mut(*i)?, + }; + } + Some(Arc::make_mut(n)) +} + +/// Gives every node from `n` down that `fresh` names a new id from `next`, counting nodes in +/// [`visit`] order from `*count`; only the nodes on the way to one are copied. Each part below is +/// renumbered through a second handle, so a new pointer marks a change. +fn renumber(n: &mut Arc, fresh: &HashSet, count: &mut usize, next: &mut u64) { + let own = fresh.contains(count); + *count += 1; + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + renumber(&mut art, fresh, count, next); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut slots = vec![]; + // Children's new ids by their old ones, for an Objects on Path group, which names its path and + // members by id. An id another child keeps still names that child. + let mut renamed = std::collections::HashMap::new(); + let mut kept = HashSet::new(); + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut new = Arc::clone(c); + renumber(&mut new, fresh, count, next); + if new.id == c.id { + kept.insert(c.id); + } else { + renamed.insert(c.id, new.id); + } + if !Arc::ptr_eq(&new, c) { + slots.push((i, new)); + } + } + renamed.retain(|old, _| !kept.contains(old)); + if !own && mask.is_none() && slots.is_empty() { + return; + } + let node = Arc::make_mut(n); + if own { + node.id = NodeId(*next); + *next += 1; + } + if let (Some(art), Some(m)) = (mask, node.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(children) = node.children_mut() { + for (i, c) in slots { + if let Some(slot) = children.get_mut(i) { + *slot = c; + } + } + } + if !renamed.is_empty() + && let Some(on_path) = node.extras_if_any().and_then(|e| e.objects_on_path.as_mut()) + { + on_path.rename(&renamed); + } +} + +impl Document { + /// The roots of the art kept outside the layers, each with its [`StoredRoot`]. + fn definition_roots(&self) -> impl Iterator, StoredRoot)> { + let symbols = self.symbols.iter().enumerate().map(|(i, s)| (&s.art, StoredRoot::Symbol(i))); + let designs = self.graph_designs.iter().enumerate().map(|(i, g)| (&g.art, StoredRoot::GraphDesign(i))); + let patterns = self + .patterns + .iter() + .enumerate() + .flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a }))); + symbols.chain(designs).chain(patterns) + } + + /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns. + fn stored_roots(&self) -> impl Iterator, StoredRoot)> { + self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots()) + } + + /// The root `root` names, to change. + fn stored_root_mut(&mut self, root: StoredRoot) -> Option<&mut Arc> { + match root { + StoredRoot::Layer(i) => self.layers.get_mut(i), + StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art), + StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art), + StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art), + } + } + + /// Visit every stored object with its place: the layer tree with the opacity-mask art of its + /// objects, then the art of symbols, graph designs and pattern swatches (their definitions, + /// once each, never the copies instances and tiles draw). + pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) { + self.visit_stored_arcs(|n, place| f(n, place)); + } + + /// [`Self::visit_stored`] with each object's shared handle, which keeps its identity from one + /// look to the next while the object is unchanged (for caches such as thumbnails). + pub fn visit_stored_arcs<'a>(&'a self, mut f: impl FnMut(&'a Arc, StoredPlace)) { + for (root, r) in self.stored_roots() { + visit(root, StoredPlace { root: r, mask_of: None }, &mut f); + } + } + + /// Where object `id` is stored: the root, and the steps from it. + fn stored_path(&self, id: NodeId) -> Option<(StoredPlace, Vec)> { + if let Some(path) = self.index_path(id) { + let (first, rest) = path.split_first()?; + return Some((StoredPlace { root: StoredRoot::Layer(*first), mask_of: None }, rest.iter().map(|i| Step::Child(*i)).collect())); + } + // Not in the layer tree: in mask art (in the layers too) or in a definition's art. + for (root, r) in self.stored_roots() { + let (mut steps, mut mask_of) = (vec![], None); + if find(root, id, &mut steps, &mut mask_of) { + return Some((StoredPlace { root: r, mask_of }, steps)); + } + } + None + } + + /// Stored object `id` (see [`Self::visit_stored`]) with its place: one in the layer tree as + /// [`Self::node`] finds it, else the first in visiting order. + pub fn stored_node(&self, id: NodeId) -> Option<(&Arc, StoredPlace)> { + let (place, steps) = self.stored_path(id)?; + let root = self.stored_roots().find(|(_, r)| *r == place.root)?.0; + Some((at(root, &steps)?, place)) + } + + /// [`Self::stored_node`] to change; the nodes on the way are copied when shared. + pub fn stored_node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + let (place, steps) = self.stored_path(id)?; + at_mut(self.stored_root_mut(place.root)?, &steps) + } + + /// Gives the objects outside the layer tree (in opacity-mask art and in the art of symbols, + /// graph designs and patterns) whose id is 0, past the id range, or already taken by an object + /// visited before them (the layer tree's own objects first) a new id, so every stored object + /// can be named by its id. Returns how many changed; the id counter then stands above them. + pub(crate) fn renumber_stored_duplicates(&mut self) -> usize { + let mut seen: HashSet = HashSet::new(); + for l in &self.layers { + l.walk(&mut |n| { + seen.insert(n.id); + }); + } + self.slices.iter().for_each(|s| { + seen.insert(s.id); + }); + // Per root, the visiting indices of the nodes that take a new id. + let mut fresh: Vec<(StoredRoot, HashSet)> = vec![]; + for (root, r) in self.stored_roots() { + let (mut i, mut here) = (0, HashSet::new()); + visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| { + if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) { + here.insert(i); + } + i += 1; + }); + if !here.is_empty() { + fresh.push((r, here)); + } + } + let total = fresh.iter().map(|(_, f)| f.len()).sum(); + if total == 0 { + return 0; + } + // No id left to give: the objects keep theirs rather than share one given out later. + if self.peek_next_id().saturating_add(total as u64) > MAX_NEXT_ID { + return 0; + } + let mut next = self.peek_next_id(); + for (r, f) in fresh { + if let Some(root) = self.stored_root_mut(r) { + renumber(root, &f, &mut 0, &mut next); + } + } + self.raise_next_id(next); + total + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{GraphDesign, OpacityMask, PatternDef, Symbol}; + use vectorcraft_geom::Rect; + + fn rect(id: u64) -> Node { + Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), crate::Appearance::default_art()) + } + + fn masked(id: u64, art: Node) -> Node { + rect(id).with_mask(Some(Box::new(OpacityMask::new(art, true)))) + } + + /// A document with object 10 in its layer, mask art 11 on it, and one object in a symbol, + /// a graph design and a pattern (ids `defs`). + fn doc(defs: [u64; 3]) -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, masked(10, rect(11))).unwrap(); + d.symbols.push(Symbol { name: "S".into(), art: Arc::new(rect(defs[0])), dynamic: false }); + d.graph_designs.push(GraphDesign { name: "G".into(), art: Arc::new(rect(defs[1])) }); + d.patterns.push(PatternDef::new("P", vec![Arc::new(rect(defs[2]))])); + d.raise_next_id(20); + d + } + + #[test] + fn stored_objects_are_found_in_every_art() { + let d = doc([12, 13, 14]); + let place = |id: u64| d.stored_node(NodeId(id)).map(|(n, p)| (n.id.0, p)); + assert!(place(10).unwrap().1.in_layers()); + assert_eq!(place(11).unwrap().1, StoredPlace { root: StoredRoot::Layer(0), mask_of: Some(NodeId(10)) }); + assert_eq!(place(12).unwrap().1.root, StoredRoot::Symbol(0)); + assert_eq!(place(13).unwrap().1.root, StoredRoot::GraphDesign(0)); + assert_eq!(place(14).unwrap().1.root, StoredRoot::Pattern { pattern: 0, art: 0 }); + assert!(d.node(NodeId(11)).is_none() && d.node(NodeId(12)).is_none(), "the layer lookup stays the layer tree's"); + assert!(d.stored_node(NodeId(99)).is_none()); + let mut seen = vec![]; + d.visit_stored(|n, _| seen.push(n.id.0)); + assert_eq!(seen, [d.layers[0].id.0, 10, 11, 12, 13, 14]); + } + + #[test] + fn stored_objects_change_by_id() { + let mut d = doc([12, 13, 14]); + let before = d.clone(); + d.stored_node_mut(NodeId(13)).unwrap().name = Some("changed".into()); + assert_eq!(d.graph_designs[0].art.name.as_deref(), Some("changed")); + assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art), "other art is not copied"); + assert_eq!(before.graph_designs[0].art.name, None, "the earlier document is unchanged"); + d.stored_node_mut(NodeId(11)).unwrap().name = Some("mask".into()); + assert_eq!(d.stored_node(NodeId(11)).unwrap().0.name.as_deref(), Some("mask")); + } + + #[test] + fn fix_next_id_gives_nested_duplicates_new_ids() { + // The symbol's object repeats the layer's object, the design's has no id, and the pattern's + // repeats the mask art's. + let mut d = doc([10, 0, 11]); + d.next_id = 1; + d.fix_next_id(); + let mut ids = vec![]; + d.visit_stored(|n, _| ids.push(n.id.0)); + let unique: HashSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(!ids.contains(&0)); + assert_eq!(&ids[..3], &[d.layers[0].id.0, 10, 11], "the layer tree and the first mask art keep theirs"); + assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id()); + // Ids already unique are kept, and a second pass changes nothing. + let again = d.clone(); + assert_eq!(d.renumber_stored_duplicates(), 0); + assert_eq!(d, again); + } + + #[test] + fn renumbered_objects_on_path_keep_their_path_and_members() { + // A symbol's Objects on Path group whose path repeats the layer's object 10 and whose + // member has no id. + let mut d = doc([12, 13, 14]); + let (path, member) = (rect(10), rect(0)); + let rec = crate::objects_on_path::ObjectsOnPath::new(path.id, &[&member], crate::objects_on_path::CENTER, 0.0); + let mut g = Node::new(NodeId(15), crate::NodeKind::Group { children: vec![Arc::new(path), Arc::new(member)], clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(rec)); + d.symbols[0].art = Arc::new(g); + d.fix_next_id(); + let art = &d.symbols[0].art; + let kids: Vec = art.children().unwrap().iter().map(|c| c.id).collect(); + assert!(kids.iter().all(|id| id.0 != 10 && id.0 != 0), "{kids:?}"); + let on_path = art.extras().objects_on_path.as_ref().unwrap(); + assert_eq!(on_path.path, kids[0]); + assert_eq!(on_path.members.iter().map(|m| m.id).collect::>(), [kids[1]]); + } +} diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 2cf260cbec..dde6621993 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -87,6 +87,8 @@ pub struct SymbolBoxes<'a> { sliced_placed: Cell, /// The most objects of symbol art [`Self::sliced_visual`] places: [`MAX_SYMBOL_NODES`]. sliced_most: u64, + /// The widths [`Self::measured_width`] has measured, by kind and symbol name. + widths: RefCell>>, } impl Document { @@ -107,6 +109,7 @@ impl Document { sliced: RefCell::default(), sliced_placed: Cell::new(0), sliced_most: MAX_SYMBOL_NODES, + widths: RefCell::default(), } } } @@ -247,6 +250,18 @@ impl<'a> SymbolBoxes<'a> { self.art(name)?.brush } + /// The width `measure` gives for the symbol named `name`, measured once for these boxes for each + /// `kind`, which names what `measure` measures. `measure` may call this again for other names. + pub fn measured_width(&self, kind: &'static str, name: &str, measure: impl FnOnce() -> Option) -> Option { + let key = (kind, name.to_string()); + if let Some(w) = self.widths.borrow().get(&key) { + return *w; + } + let w = measure(); + self.widths.borrow_mut().insert(key, w); + w + } + /// How far the visual box of an instance of the symbol named `name` grows in boxes made by /// [`Self::with_brush_art`]: `16 × max(w, 1) + 8` for the widest stroke `w` that /// [`Self::brush_width`] finds, as far as the renderer grows the cull bounds of a brushed path diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 14f9a0d08f..6d58ad9d74 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -29,3 +29,5 @@ serde_json = { workspace = true } vectorcraft-color = { workspace = true } # A face built in code from the bundled Source Sans 3 (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } +# Test live-effect plug-ins written in WebAssembly text. +wat = { workspace = true } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2b4953d151..b77f804de7 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -424,6 +424,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { if !kept { return None; } + // The members' own effects run first, as on the canvas. + crate::group::members_as_drawn(Some(d), &mut m); reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 52232cd54e..424beebe17 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,8 +182,8 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[]}), ), g( EXTRUDE, diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 959bd5e857..a6581fa449 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -209,6 +209,8 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod Some(area) => Some(reshaped(&area, n, doc)), None => { let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes); + // The members' own effects run first, as on the canvas. + members_as_drawn(doc, &mut m); let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes follow the effects as its members do. if carry.moves(&m.appearance) { @@ -225,6 +227,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); crate::reshape::outline_nested_text(doc, &mut art, false); + members_as_drawn(doc, &mut art); let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc); // The art's own fills and strokes follow the effects as its members do. if carry.moves(&art.appearance) { @@ -754,7 +757,8 @@ fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, hidden: bool, lo } /// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects -/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its +/// reshaping all its members as one piece (the whole art's bounds as their reference box) after +/// the members' own geometry and Pathfinder effects ([`members_as_drawn`]), and its /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below /// the members (after a clipping path) or above them, as its contents slot says. Its /// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live @@ -790,6 +794,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); + members_as_drawn(doc, &mut m); let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes paint the members as a whole: their gradients follow // the effects as the members' do. @@ -821,5 +826,83 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { Some(m) } +/// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every +/// depth of its groups and layers, by the art it draws ([`evaluate_container`], +/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members +/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the +/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with +/// everything in it. Calls nested in one another, through the +/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members +/// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at +/// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are. +pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { + let Some(_frame) = DrawnFrame::enter() else { return }; + let clip = usize::from(n.clips()); + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut().skip(clip).filter(|c| c.visible) { + if !DrawnFrame::take_member() { + return; + } + // A group or layer with a 3D effect stays whole: the geometry effects listed after its 3D + // effect reshape its 3D art. + if is_container(c) && (crate::has_solid(c) || crate::has_revolve(c)) { + continue; + } + let art = if !is_container(c) { + crate::reshape::own_effects_art(doc, c) + } else if has_pathfinder(c) || !crate::reshape::geometry_effects(c).is_empty() { + evaluate_container(doc, c) + } else { + None + }; + match art { + Some(a) => *c = Arc::new(a), + None if is_container(c) => members_as_drawn(doc, Arc::make_mut(c)), + None => {} + } + } +} + +thread_local! { + /// How many [`members_as_drawn`] calls are running, one inside another, and how many members + /// they have visited since the outermost one started. + static DRAWN: std::cell::Cell<(usize, u64)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// One running [`members_as_drawn`] call, counted in [`DRAWN`] until it is dropped. +struct DrawnFrame; + +impl DrawnFrame { + /// Count one more call; `None` when [`vectorcraft_doc::depth::MAX_DEPTH`] are running. The + /// outermost call starts the count of members again. + fn enter() -> Option { + let (frames, members) = DRAWN.with(|d| d.get()); + if frames >= vectorcraft_doc::depth::MAX_DEPTH { + return None; + } + DRAWN.with(|d| d.set((frames + 1, if frames == 0 { 0 } else { members }))); + Some(DrawnFrame) + } + + /// Count one more member visited; `false` once [`crate::SYMBOL_ART_MAX_NODES`] have been. + fn take_member() -> bool { + let (frames, members) = DRAWN.with(|d| d.get()); + if members >= crate::SYMBOL_ART_MAX_NODES { + return false; + } + DRAWN.with(|d| d.set((frames, members + 1))); + true + } +} + +impl Drop for DrawnFrame { + fn drop(&mut self) { + DRAWN.with(|d| { + let (frames, members) = d.get(); + d.set((frames.saturating_sub(1), members)); + }); + } +} + #[cfg(test)] mod tests; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c169c807b2..c6e2a78c73 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -555,6 +555,298 @@ fn a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_dist assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); } +/// The bounds of what `n` draws, as the renderer draws it: a group or layer with an appearance of +/// its own as the art [`evaluate_container`] makes of it, and each visible path and compound path +/// with the geometry effects still on it applied, then those of each visible fill and stroke that +/// has some. +fn drawn_bounds(n: &Node) -> Option { + if !n.visible { + return None; + } + if let Some(m) = evaluate_container(None, n) { + return drawn_bounds(&m); + } + if let Some(ch) = n.children() + && !matches!(n.kind, NodeKind::Compound { .. }) + { + return ch.iter().filter_map(|c| drawn_bounds(c)).reduce(|a, b| a.union(b)); + } + let (path, _) = crate::reshape::leaf_geometry(n)?; + let geometry = |fx: &[Effect]| -> Vec { fx.iter().filter(|e| e.visible && crate::is_geometry(&e.id)).cloned().collect() }; + let apply = |fx: &[Effect], p: &PathData| crate::apply_geometry(&geometry(fx), p, p.bounds().unwrap()); + let g = apply(&n.appearance.effects, &path); + let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i) && !geometry(i.effects()).is_empty()).collect(); + if items.is_empty() { + return g.bounds(); + } + items.iter().filter_map(|i| apply(i.effects(), &g).bounds()).reduce(|a, b| a.union(b)) +} + +fn black() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0) +} + +/// A black 100 pt square from (100, 100). +fn square_at_100() -> Node { + Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black()) +} + +fn effect(id: &str, params: serde_json::Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn offset_10() -> Effect { + effect("path.offsetPath", serde_json::json!({"offset": 10})) +} + +fn scaled(s: f64) -> Effect { + effect("distort.transform", serde_json::json!({"scaleH": s, "scaleV": s})) +} + +fn with_effect(mut n: Node, e: Effect) -> Node { + n.appearance.effects.push(e); + n +} + +/// The width that a group with `e` holding `member` draws at. +fn drawn_width(member: Node, e: Effect) -> f64 { + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), e); + drawn_bounds(&g).unwrap().width() +} + +/// A member's own Offset Path runs before its group's Transform: the square offset by 10 pt to +/// 120 pt, then scaled by half, is 60 pt wide (70 pt the other way round). +#[test] +fn a_members_own_offset_runs_before_its_groups_scale() { + let w = drawn_width(with_effect(square_at_100(), offset_10()), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A member's own Transform runs before its group's Offset Path: the square scaled to 200 pt, then +/// offset by 10 pt, is 220 pt wide (240 pt the other way round). +#[test] +fn a_members_own_scale_runs_before_its_groups_offset() { + let w = drawn_width(with_effect(square_at_100(), scaled(200.0)), offset_10()); + assert!((w - 220.0).abs() < 1e-6, "{w}"); +} + +/// A member inside a plain group inside the group takes its own effects first too. +#[test] +fn a_member_of_a_plain_nested_group_takes_its_own_effects_first() { + let w = drawn_width(Node::group(NodeId(5), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A nested group's own geometry effects run before the outer group's. +#[test] +fn a_nested_groups_own_effects_run_before_its_groups() { + let w = drawn_width(with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), offset_10()), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A nested group's Pathfinder effect runs before the outer group's geometry effects: the back +/// square minus the front one is 50 pt wide, and offset by 10 pt it is 70 pt (offsetting the +/// squares first leaves 50 pt). +#[test] +fn a_nested_groups_pathfinder_runs_before_its_groups_effects() { + let back = square_at_100(); + let front = Node::path(NodeId(2), shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)), black()); + let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(back), Arc::new(front)]), effect("pathfinder.subtract", serde_json::json!({}))); + let w = drawn_width(nested, offset_10()); + assert!((w - 70.0).abs() < 1e-6, "{w}"); +} + +/// The geometry effects of a member's own fill run before its group's: 60 pt, as for the +/// member's own effect. +#[test] +fn a_members_fill_effects_run_before_its_groups() { + let mut member = square_at_100(); + member.appearance.items[0].effects_mut().push(offset_10()); + let w = drawn_width(member, scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A member whose fills have geometry effects stays one element of a knockout group: its fills do +/// not knock each other out. +#[test] +fn a_member_drawn_as_its_fills_stays_one_knockout_element() { + let mut member = square_at_100(); + member.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))))); + member.appearance.items[0].effects_mut().push(offset_10()); + let mut g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), scaled(50.0)); + g.knockout = vectorcraft_doc::Knockout::On; + let art = evaluate_container(None, &g).unwrap(); + let elements = Node::knockout_elements(art.children().unwrap()); + assert_eq!(elements.len(), 1, "{:?}", elements.iter().map(|e| (e.id, &e.kind)).collect::>()); +} + +/// The art a Pathfinder operation or compound shape takes from a group with geometry effects has +/// the members' own effects applied first, as the canvas draws it. +#[test] +fn shape_art_of_a_group_applies_its_members_effects_first() { + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + let art = shape_art(None, &g, None).unwrap(); + let w = drawn_bounds(&art).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); + let leaves = member_leaves(None, &g, None); + let w = leaves.iter().filter_map(drawn_bounds).reduce(|a, b| a.union(b)).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "leaves: {w}"); +} + +/// A symbol instance's own geometry effects run after those of its symbol's members, as the canvas +/// draws it and in the art a Pathfinder operation takes from it. +#[test] +fn a_symbol_instances_effects_run_after_its_symbols_members_own() { + let art = Node::group(NodeId(20), vec![Arc::new(with_effect(square_at_100(), offset_10()))]); + let instance = + with_effect(Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }), scaled(50.0)); + let drawn = crate::reshape(None, &instance, Some(&art)).unwrap(); + let w = drawn_bounds(&drawn).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "drawn: {w}"); + let hook = move |_: &Node| Some(art.clone()); + let w = drawn_bounds(&shape_art(None, &instance, Some(&hook)).unwrap()).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "shape art: {w}"); +} + +/// Export bakes the group as the canvas draws it. +#[test] +fn a_baked_group_applies_its_members_effects_first() { + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + d.insert(Some(layer), 0, g).unwrap(); + let baked = crate::bake_document(&d).unwrap(); + let w = baked.node(NodeId(10)).unwrap().geometric_bounds().unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A group whose Transform effect only moves and copies keeps its type when baked; a nested +/// group's own Transform still runs before the copies: the nested square scaled by half to 50 pt +/// and copied 200 pt to the right spans 250 pt (copying first, then scaling both copies about +/// their middle, spans 150 pt). +#[test] +fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() { + if skip_without_craft_fonts() { + return; + } + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = Node::new(NodeId(3), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style)))); + let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), scaled(50.0)); + let g = with_effect( + Node::group(NodeId(10), vec![Arc::new(text), Arc::new(nested)]), + effect("distort.transform", serde_json::json!({"moveH": 200, "copies": 1})), + ); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, g).unwrap(); + let baked = crate::bake_document(&d).unwrap(); + let mut text = 0; + baked.walk(|n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(text, 2, "the type stays type, copied"); + let w = baked.node(NodeId(5)).unwrap().geometric_bounds().unwrap().width(); + assert!((w - 250.0).abs() < 1e-6, "{w}"); +} + +/// A clip group's clipping path keeps its own effects; the clipped members take theirs first. +#[test] +fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { + let mut clip = with_effect(square_at_100(), offset_10()); + clip.id = NodeId(2); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let clipped = + Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(with_effect(square_at_100(), offset_10()))], clip: true }); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(clipped)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let clip_group = art.children().unwrap()[0].clone(); + let ch = clip_group.children().unwrap(); + assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind); + assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path"); + assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art"); +} + +/// A member with a linked opacity mask keeps its own effects on it, so its mask moves with it +/// under the group's Transform; so does a member with a 3D effect, whose later geometry effects +/// apply to its 3D art. +#[test] +fn members_with_linked_masks_or_3d_effects_keep_their_own_effects() { + let mut masked = with_effect(square_at_100(), offset_10()); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new( + Node::path(NodeId(2), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black()), + true, + ))); + let mut solid = with_effect(square_at_100(), effect(crate::solids::EXTRUDE, serde_json::json!({}))); + solid.id = NodeId(3); + solid.appearance.effects.push(scaled(50.0)); + let moved = effect("distort.transform", serde_json::json!({"moveH": 50, "strokes": true})); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(masked), Arc::new(solid)]), moved); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert!(matches!(ch[0].kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(&ch[0]).len() == 1, "{:?}", ch[0].kind); + let mask = ch[0].extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask.x0 - 150.0).abs() < 1e-6, "the mask moves with the square: {mask:?}"); + assert!(matches!(ch[1].kind, NodeKind::Path { .. }) && ch[1].appearance.effects.len() == 2, "{:?}", ch[1].kind); +} + +/// A nested group with a 3D effect stays as it is, with its members: the Transform listed after +/// its Extrude still applies to its 3D art. +#[test] +fn a_nested_group_with_a_3d_effect_keeps_its_own_effects() { + let inner = with_effect(square_at_100(), offset_10()); + let mut solid = with_effect(Node::group(NodeId(5), vec![Arc::new(inner)]), effect(crate::solids::EXTRUDE, serde_json::json!({}))); + solid.appearance.effects.push(scaled(50.0)); + let moved = effect("distort.transform", serde_json::json!({"moveH": 50})); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(solid)]), moved); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert!( + matches!(ch[0].kind, NodeKind::Group { .. }) && ch[0].id == NodeId(5) && ch[0].appearance.effects.len() == 2, + "{:?}", + ch[0].appearance.effects + ); + let member = &ch[0].children().unwrap()[0]; + assert!(matches!(member.kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(member).len() == 1, "{:?}", member.kind); +} + +/// Members nested deeper than the depth limit keep their own effects as they are. +#[test] +fn members_past_the_depth_limit_stay_as_they_are() { + let mut n = with_effect(square_at_100(), offset_10()); + for i in 0..vectorcraft_doc::depth::MAX_DEPTH { + n = Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]); + } + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let mut deepest = None; + art.walk(&mut |c: &Node| { + if c.id == NodeId(1) { + deepest = Some(c.clone()); + } + }); + let deepest = deepest.unwrap(); + assert!(matches!(deepest.kind, NodeKind::Path { .. }), "{:?}", deepest.kind); + assert_eq!(crate::reshape::geometry_effects(&deepest).len(), 1); +} + +/// The depth limit counts nested groups with effects of their own too. +#[test] +fn nested_groups_with_effects_count_toward_the_depth_limit() { + let mut n = with_effect(square_at_100(), offset_10()); + for i in 0..vectorcraft_doc::depth::MAX_DEPTH { + n = with_effect(Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]), effect("distort.transform", serde_json::json!({"moveH": 1}))); + } + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let mut deepest = None; + art.walk(&mut |c: &Node| { + if c.id == NodeId(1) && matches!(c.kind, NodeKind::Path { .. }) { + deepest = Some(c.clone()); + } + }); + assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index fa06d00922..ac61746266 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -95,7 +95,8 @@ pub use solids::{ validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, }; pub use solids::{ - GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, rename_appearance_graphic_symbol, + GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, GRAPHICS_NOT_TAKEN, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, + rename_appearance_graphic_symbol, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 2bda0aa88f..89a258bb07 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -524,9 +524,10 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), its -/// fills and strokes carrying the geometry effects of the instance's active fill and stroke -/// ([`add_instance_row_effects`]). +/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as +/// the art its members draw when the instance has geometry effects of its own +/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the +/// instance's active fill and stroke ([`add_instance_row_effects`]). /// `None` for other kinds (images, paths, containers), and for an instance whose art placed /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. @@ -562,20 +563,16 @@ fn outline_art_with( Node::group(n.id, vec![Arc::new(a)]) } // The object as the one piece the copies of its Transform effect are made from. - _ if has_transform_art(n) || (apart && has_transform_copies(n)) => { - let mut one = n.clone(); - one.opacity = 1.0; - one.blend = Default::default(); - one.isolate = false; - one.extras_mut().mask = None; - one.appearance.effects.clear(); - Node::group(n.id, vec![Arc::new(one)]) - } + _ if has_transform_art(n) || (apart && has_transform_copies(n)) => one_piece(n), _ if vectorcraft_doc::live::is_live(n) => evaluated_with(symbols, n, outline, nodes), _ => return None, }; outline_nested_text(paint_doc, &mut art, false); if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + // The instance's own geometry effects reshape what the symbol's members draw. + if !geometry_effects(n).is_empty() { + crate::group::members_as_drawn(paint_doc, &mut art); + } add_instance_row_effects(symbols, n, &mut art); } art.opacity = 1.0; @@ -843,18 +840,64 @@ fn as_art_opts( } // The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip // group). - let mut out = art; - out.name = n.name.clone(); - out.visible = n.visible; - out.opacity = n.opacity; - out.blend = n.blend; - out.isolate = n.isolate; + Some(dressed(art, n)) +} + +/// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and +/// remaining (raster) effects. +fn dressed(mut art: Node, n: &Node) -> Node { + art.name = n.name.clone(); + art.visible = n.visible; + art.opacity = n.opacity; + art.blend = n.blend; + art.isolate = n.isolate; // One element: its pieces never knock each other out. - out.knockout = vectorcraft_doc::Knockout::Off; - out.knockout_shape = n.knockout_shape; - out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); - Some(out) + art.knockout = vectorcraft_doc::Knockout::Off; + art.knockout_shape = n.knockout_shape; + art.extras_mut().mask = n.extras().mask.clone(); + art.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); + art +} + +/// A group with `n`'s id holding `n` without its transparency, opacity mask and effects: the one +/// piece that `n`'s own geometry effects reshape. +fn one_piece(n: &Node) -> Node { + let mut one = n.clone(); + one.opacity = 1.0; + one.blend = Default::default(); + one.isolate = false; + one.extras_mut().mask = None; + one.appearance.effects.clear(); + Node::group(n.id, vec![Arc::new(one)]) +} + +/// Path or compound path `n` with its own geometry effects applied, as the art it draws: with +/// geometry effects in a visible fill or stroke of its own, a group of one path for each fill and +/// stroke, its effects and that fill's or stroke's applied ([`crate::bake_type_appearance`]); +/// otherwise a group with `n`'s id holding the path reshaped by its effects ([`reshape_leaves`]). +/// The art keeps `n`'s id, name, visibility, transparency, opacity mask and raster effects. `None` +/// for a guide, for other kinds of objects, for a path with a linked opacity mask or a 3D effect, +/// and when `n` has no visible geometry effect of its own or in a visible fill or stroke. +pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option { + if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) + || n.extras().mask.as_ref().is_some_and(|m| m.linked) + || crate::has_solid(n) + || crate::has_revolve(n) + { + return None; + } + if n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) { + // One element: its pieces never knock each other out. + return crate::bake_type_appearance(doc, n).map(|art| Node { knockout: vectorcraft_doc::Knockout::Off, ..art }); + } + let fx = geometry_effects(n); + if fx.is_empty() { + return None; + } + let mut art = one_piece(n); + reshape_leaves(&mut art, &fx, &n.appearance.effects, doc); + transform_items(&mut art, doc); + Some(dressed(art, n)) } /// Map every path under `art` (and its gradients), and every path in the art of the linked opacity @@ -1858,22 +1901,30 @@ fn paint_leaf( /// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most /// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths. fn piece_cost(path: &PathData) -> Budget { + Budget::of(path).plus(piece_share()) +} + +/// The share of the effects' budget each painted piece costs besides its path ([`piece_cost`]). +fn piece_share() -> Budget { let max = vectorcraft_plugins::objects::MAX_OBJECTS; - Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max }) + Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max } } /// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of -/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference -/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that -/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item`, the leaf's first -/// fill or stroke, with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's -/// other fills and strokes; one that gives none keeps the leaf's. Returns +/// `item`, run on `leaf`, a path of [`transform_piece`] painted by `item` (its first fill or stroke), +/// with reference box `bounds`: `leaf` becomes a group of one path for each piece returned, in +/// order. The plug-in is given `appearance`'s fills and strokes and told that it is on item `index` +/// of them. A piece that gives fills (on a fill) or strokes (on a stroke) is painted by copies of +/// `item` with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's other fills +/// and strokes; one that gives none keeps the leaf's. Returns /// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, /// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass /// `budget`. A deferred run is listed by `at`. +#[allow(clippy::too_many_arguments)] fn paint_piece( leaf: &mut Node, item: &AppearanceItem, + (appearance, index): (&Appearance, usize), (plugin, params): (&str, &serde_json::Value), bounds: Rect, budget: Budget, @@ -1881,7 +1932,7 @@ fn paint_piece( at: Option, ) -> Option { let (path, rule) = leaf_geometry(leaf)?; - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(appearance), item: Some(index), at }; let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); if !budget.fits(made) { @@ -2050,7 +2101,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), true, limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -2073,7 +2124,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt if moved && !held_later { follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); } - transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), false, limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -2086,7 +2137,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt leaf.kind = NodeKind::Group { children: pieces, clip: false }; } -/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a +/// Apply the visible geometry effects left on the fills or strokes of each path under `p` (a /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's /// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of @@ -2095,19 +2146,32 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// the effects after it, and with Transform Objects off those before it, as they are /// ([`keep_listed`]). /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path -/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect -/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before -/// the piece's effects that makes copies or moves at random holds back, and the effects before -/// such a Transform effect among the piece's effects. The geometry effects then +/// for each piece it returns ([`paint_piece`]). A path with several fills or strokes (those a +/// painted plug-in returned) takes such an effect, a live-effect plug-in or a geometry effect on +/// strokes once for each of them, in paint order, given its own place among them +/// ([`shares_stage`]): each run's pieces replace only that fill or stroke, and a run that fails or +/// would pass the budget leaves that fill or stroke on the path as it was. The raster effects +/// before a Transform effect follow it ([`follow_piece`]), except the first `held` effects, which +/// a Transform effect before the piece's effects that makes copies or moves at random holds back, +/// and the effects before such a Transform effect among the piece's effects. The geometry effects then /// leave the fills and strokes; their other effects stay. The paths keep to `max` together with /// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in /// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the /// fill or stroke the piece was made from, and the index among that item's effects of each effect -/// the piece kept, which a deferred live-effect plug-in is listed by. -fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// the piece kept, which a deferred live-effect plug-in is listed by. `painted`: `p`'s paths are +/// the pieces of a painted run, each of which `used` counts at [`piece_cost`]. +fn transform_piece( + p: &mut Node, + (seed, source, kept): (u64, usize, &[usize]), + held: usize, + painted: bool, + max: Budget, + used: &mut Budget, + doc: Option<&Document>, +) { let mut count = 0; for_each_leaf(p, &mut |l| { - if let Some(item) = l.appearance.items.first() { + for item in &l.appearance.items { count = count.max(item.effects().len()); } }); @@ -2115,60 +2179,207 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h // random are held back from the Transform effects before it too. let mut held = held; for_each_leaf(p, &mut |l| { - if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { - held = held.max(last); + for item in &l.appearance.items { + if let Some(last) = item.effects().iter().rposition(crate::raster::holds_back) { + held = held.max(last); + } } }); + // Whether `used` counts each path under `p`, in the order `for_each_leaf` visits them, at + // [`piece_cost`] (its geometry and a painted piece's share) rather than at its geometry alone. + // The pieces of a painted run are counted so, and so is the first path an effect makes of one + // that was: the paths of a Transform effect's copies share their original's share. + let mut shares = vec![painted; leaf_count(p)]; for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; let from = held.min(j); let mut holds = false; - for_each_leaf(p, &mut |leaf| { - let Some(item) = leaf.appearance.items.first().cloned() else { return }; - let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; - holds |= crate::raster::holds_back(&e); - // The effects before this one as they were before it moved the path. - let was = item.effects().get(from..j).unwrap_or_default(); - let Some((path, rule)) = leaf_geometry(leaf) else { return }; - // What the other paths leave of the budget. - let before = Budget::of(&path); - let room = max.minus(used.minus(before)); - let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) }; - if let Some(plugin) = painted_plugin(&e) { - if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) { - *used = used.minus(before).plus(made); - } - return; - } - if reaches_appearance(&e) { - let params = merged_params(&e.id, &e.params); - let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); - let options = crate::distort::TransformOptions::of(¶ms); - let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let keep = |k: usize| k > j || (k < j && !options.objects); - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep))); + let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) }; + let at = PieceStage { j, from, seed, bounds, doc, stage }; + // Stage `j` on `leaf`, a path `used` counts at `piece_cost` when `share`: whether `used` + // counts each path it became so, or `None` when it stays as it was. + let mut on_leaf = |leaf: &mut Node, share: bool| -> Option> { + let effect = |item: &AppearanceItem| item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned(); + let items = &leaf.appearance.items; + let first = items.first()?; + let (path, _) = leaf_geometry(leaf)?; + // What `used` counts the path at; what the other paths leave of the budget is the rest. + let geometry = Budget::of(&path); + let counted = if share { piece_cost(&path) } else { geometry }; + if shares_stage(items, j) { + let e = effect(first)?; + holds |= crate::raster::holds_back(&e); + let appearance = leaf.appearance.clone(); + // A painted run's pieces each cost a share of their own ([`paint_piece`]); the + // paths of any other effect keep the leaf's. + let painted = painted_plugin(&e).is_some(); + let before = if painted { counted } else { geometry }; + let room = max.minus(used.minus(before)); + let made = piece_stage(leaf, &e, &at, (&appearance, 0), room)?; *used = used.minus(before).plus(made); - if moved { - follow_piece(leaf, was, from, &e); - } - return; + return Some((0..leaf_count(leaf)).map(|k| painted || (share && k == 0)).collect()); } + // Each fill or stroke takes the effect on its own path, given the leaf's fills and + // strokes and its own place among them; those left with the same path as the one + // before them stay on it. Each path costs its geometry and a painted piece's share. + // Each run is first given all the room the art leaves. When a fill or stroke that keeps + // the leaf's path then finds no room for its copy of it, the results are placed again, + // each but the last leaving room for one more copy of the leaf's own path; a result + // that does not fit that room is left out. Only a fill or stroke that keeps the leaf's + // path right after one that changed costs such a copy (the ones after it stay on that + // copy), so every copy fits but the first fill's or stroke's, when the art leaves no + // room for it: the leaf then stays as it was. A live-effect plug-in is given all the + // room the art leaves, and each placing checks that its result fits; a built-in effect + // builds within the room of each placing. let appearance = leaf.appearance.clone(); - let stroke = match &item { - AppearanceItem::Stroke(s) => Some(s), - AppearanceItem::Fill(_) => None, + let one = piece_cost(&path); + let n = appearance.items.len(); + let base = used.minus(counted); + let alone = |item: &AppearanceItem| { + let mut sub = leaf.clone(); + sub.appearance.items = vec![item.clone()]; + sub }; - let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) }; - let mut carry = crate::Carry::default(); - let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry)); - *used = used.minus(before).plus(Budget::of(&out)); - // The fill's or stroke's gradient follows the effect as the object's does. - if carry.moves(&leaf.appearance) { - carry.apply(&mut leaf.appearance); - } - set_geometry(leaf, out, rule); - follow_piece(leaf, was, from, &e); + // `item`, the `k`th fill or stroke, alone on the leaf's path with effect `e` applied + // within `room`, and what its paths cost; `None` when it failed or found no room. + let apply = |item: &AppearanceItem, k: usize, e: &Effect, room: Budget| { + let painted = painted_plugin(e).is_some(); + let room = if painted { room } else { room.minus(piece_share()) }; + let mut sub = alone(item); + let made = piece_stage(&mut sub, e, &at, (&appearance, k), room)?; + Some((sub, if painted { made } else { made.plus(piece_share()) })) + }; + // Each fill's or stroke's live-effect plug-in run. A result is kept only while a + // placing places it on a path of its own; one that does not fit is dropped, with what + // it cost, and the plug-in runs again in the second placing only when that much room + // is left there (a result small enough to cache comes back from the cache). A result + // with the same path as the one before it costs nothing: it is placed on that path + // whatever room is left, and dropped once its fill or stroke is moved there. The + // results kept at once cost at most what the two placings place. + let mut runs: Vec = vec![PluginRun::Not; n]; + let mut kept_cost = Budget { anchors: 0, lines: 0 }; + // Each placing gives the paths, whether `used` counts each of the paths they hold at + // `piece_cost` (all of a painted run's, and the first of any other result's), and what + // the art then spends. + let mut split = |reserve: bool| -> Option<(Vec, Vec, Budget)> { + let mut spent = base; + let mut out: Vec = vec![]; + let mut counts: Vec = vec![]; + for (k, (item, ran)) in appearance.items.iter().zip(runs.iter_mut()).enumerate() { + let room = if reserve && k + 1 < n { max.minus(spent).minus(one) } else { max.minus(spent) }; + let e = effect(item); + let paints = e.as_ref().is_some_and(|e| painted_plugin(e).is_some()); + // Does `sub` stay on the path of the result before it, at no cost? + let merges = |sub: &Node| out.last().is_some_and(|last| same_path(last, sub)); + // What the kept plug-in result this fill or stroke takes cost. + let mut taken = None; + let result = match e { + None => None, + Some(e) => { + holds |= crate::raster::holds_back(&e); + if vectorcraft_plugins::effect::plugin_id(&e.id).is_some() { + let again = match ran { + PluginRun::Not | PluginRun::Merged => true, + PluginRun::Left(cost) => room.fits(*cost), + PluginRun::Kept(..) | PluginRun::Failed => false, + }; + if again { + #[cfg(test)] + PLUGIN_RUNS.with(|n| n.set(n.get() + 1)); + *ran = match apply(item, k, &e, max.minus(base)) { + None => PluginRun::Failed, + Some((sub, cost)) if !room.fits(cost) && !merges(&sub) => PluginRun::Left(cost), + Some((sub, cost)) => { + kept_cost = kept_cost.plus(cost); + PluginRun::Kept(sub, cost) + } + }; + } + match ran { + PluginRun::Kept(sub, cost) if room.fits(*cost) || merges(sub) => { + taken = Some(*cost); + Some((sub.clone(), *cost)) + } + PluginRun::Kept(_, cost) => { + let cost = *cost; + kept_cost = kept_cost.minus(cost); + *ran = PluginRun::Left(cost); + None + } + PluginRun::Not | PluginRun::Merged | PluginRun::Left(_) | PluginRun::Failed => None, + } + } else { + // A built-in effect that finds no room within `room` leaves the + // leaf's path. When its result is not on the path before it, it + // is built again with all the room the art leaves and taken only + // when that result is on the path before it, which costs nothing. + let made = apply(item, k, &e, room); + let short = match &made { + None => true, + Some((sub, cost)) => !merges(sub) && (!room.fits(*cost) || same_path(sub, leaf)), + }; + match out.last() { + Some(_) if short => match apply(item, k, &e, max.minus(base)) { + Some((sub, cost)) if merges(&sub) => Some((sub, cost)), + _ => made, + }, + _ => made, + } + } + } + }; + let placed = match result { + Some((sub, cost)) if room.fits(cost) || merges(&sub) => Some((sub, cost, paints)), + _ => None, + }; + // A fill or stroke whose result is not placed stays on the leaf's path, which + // is copied only when the path before it is another. + let (mut sub, cost, all) = match placed { + Some(placed) => placed, + None => match out.last_mut() { + Some(last) if same_path(last, leaf) => { + last.appearance.items.push(item.clone()); + continue; + } + _ => (alone(item), one, true), + }, + }; + match out.last_mut() { + Some(last) if same_path(last, &sub) => { + last.appearance.items.append(&mut sub.appearance.items); + if let Some(cost) = taken { + kept_cost = kept_cost.minus(cost); + *ran = PluginRun::Merged; + } + } + _ if !max.minus(spent).fits(cost) => return None, + _ => { + spent = spent.plus(cost); + counts.extend((0..leaf_count(&sub)).map(|k| all || k == 0)); + out.push(sub); + } + } + #[cfg(test)] + HELD_PEAK.with(|p| p.set(p.get().at_least(kept_cost))); + } + Some((out, counts, spent)) + }; + let (mut out, counts, spent) = split(false).or_else(|| split(true))?; + *used = spent; + *leaf = match out.len() { + 1 => out.pop().unwrap_or_else(|| leaf.clone()), + _ => Node::group(leaf.id, out.into_iter().map(Arc::new).collect()), + }; + Some(counts) + }; + let mut next: Vec = vec![]; + let mut visited = 0; + for_each_leaf(p, &mut |leaf| { + let share = shares.get(visited).copied().unwrap_or(false); + visited += 1; + next.extend(on_leaf(leaf, share).unwrap_or_else(|| vec![share])); }); + shares = next; if holds { held = held.max(j); } @@ -2180,6 +2391,134 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h }); } +/// A fill's or stroke's live-effect plug-in run in a placing of [`transform_piece`]: not run yet, +/// its result and what that costs while a placing places it on a path of its own, what a result +/// that did not fit cost (the result itself is dropped), a result dropped once its fill or stroke +/// was placed on the path before it, or failed. +#[derive(Clone)] +enum PluginRun { + Not, + Kept(Node, Budget), + Left(Budget), + Merged, + Failed, +} + +#[cfg(test)] +thread_local! { + /// The most the live-effect plug-in results [`transform_piece`] keeps from one fill or stroke + /// to the next have cost at once on this thread. + pub(crate) static HELD_PEAK: std::cell::Cell = const { std::cell::Cell::new(Budget { anchors: 0, lines: 0 }) }; + /// How many times [`transform_piece`] has run a fill's or stroke's live-effect plug-in on this + /// thread when they did not share the stage. + pub(crate) static PLUGIN_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Where a stage of [`transform_piece`] runs: `j`, the index of its effect among the piece's +/// effects; `from`, the first of the effects before it whose raster effects follow a Transform +/// effect; the object's `seed`; the reference box `bounds` of all the piece's paths; the document +/// that painted pieces' swatches and patterns must be in; and the `stage` a deferred live-effect +/// plug-in is listed by. +struct PieceStage<'a> { + j: usize, + from: usize, + seed: u64, + bounds: Rect, + doc: Option<&'a Document>, + stage: vectorcraft_plugins::deferred::Stage, +} + +/// Can stage `j` of [`transform_piece`] run once for all of a path's `items`: there is at most one, +/// or they hold the same effects and the effect at `j` is not a visible geometry effect, is a +/// Transform effect, or is a built-in geometry effect and they are all fills (an effect that +/// changes a fill's path does not depend on which fill it is on)? +fn shares_stage(items: &[AppearanceItem], j: usize) -> bool { + let Some(first) = items.first() else { return true }; + if items.len() == 1 { + return true; + } + if !items.iter().all(|i| i.effects() == first.effects()) { + return false; + } + match first.effects().get(j) { + Some(e) if e.visible && is_geometry(&e.id) => { + vectorcraft_plugins::effect::plugin_id(&e.id).is_none() && (reaches_appearance(e) || items.iter().all(AppearanceItem::is_fill)) + } + _ => true, + } +} + +/// Effect `e`, a visible geometry effect at stage `at` of [`transform_piece`], applied to `leaf`, a +/// path painted by its first fill or stroke (and, when they share the stage, [`shares_stage`], the +/// others): `appearance` is what the effect is given of the path's fills and strokes and `index` +/// which of them it is on. A live-effect plug-in that paints what it returns makes `leaf` a group +/// of its pieces ([`paint_piece`]), and a Transform effect that reaches its appearance transforms +/// it ([`transform_leaf`]). Returns what the new paths cost, within `room`; `None` when `leaf` +/// stays as it is. +fn piece_stage(leaf: &mut Node, e: &Effect, at: &PieceStage, (appearance, index): (&Appearance, usize), room: Budget) -> Option { + let item = leaf.appearance.items.first()?.clone(); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(at.from..at.j).unwrap_or_default(); + let (path, rule) = leaf_geometry(leaf)?; + if let Some(plugin) = painted_plugin(e) { + let params = merged_params(&e.id, &e.params); + return paint_piece(leaf, &item, (appearance, index), (plugin, ¶ms), at.bounds, room, at.doc, Some(at.stage)); + } + if reaches_appearance(e) { + let params = merged_params(&e.id, &e.params); + let (_, copies) = crate::distort::transform_map(at.bounds, ¶ms, at.seed); + let options = crate::distort::TransformOptions::of(¶ms); + let map = crate::distort::transform_affine(at.bounds, ¶ms, at.seed); + let j = at.j; + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep))); + if moved { + follow_piece(leaf, was, at.from, e); + } + return Some(made); + } + let stroke = match &item { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }; + let ctx = GeomContext { stroke, rule, seed: at.seed, appearance: Some(appearance), item: Some(index), doc: at.doc, stage: Some(at.stage) }; + let mut carry = crate::Carry::default(); + let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(e), &path, at.bounds, &ctx, room, Some(&mut carry)); + let made = Budget::of(&out); + // The fill's or stroke's gradient follows the effect as the object's does. + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } + set_geometry(leaf, out, rule); + follow_piece(leaf, was, at.from, e); + Some(made) +} + +/// Are `a` and `b` paths or compound paths that differ only in their fills and strokes? +fn same_path(a: &Node, b: &Node) -> bool { + let leaf = |n: &Node| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + if !leaf(a) || !leaf(b) { + return false; + } + // Every field but the fills and strokes, compared in place; the geometry last. + let Node { id, name, visible, locked, opacity, blend, isolate, knockout, knockout_shape, appearance, kind, bbox_angle, extras } = a; + let Appearance { items: _, effects, contents_index } = &**appearance; + *id == b.id + && *name == b.name + && *visible == b.visible + && *locked == b.locked + && *opacity == b.opacity + && *blend == b.blend + && *isolate == b.isolate + && *knockout == b.knockout + && *knockout_shape == b.knockout_shape + && *bbox_angle == b.bbox_angle + && *extras == b.extras + && *effects == b.appearance.effects + && *contents_index == b.appearance.contents_index + && *kind == b.kind +} + /// The effects a Transform effect in a fill's or stroke's own list leaves as they were /// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its /// indices keep their values. `None` for an object's own Transform effect. diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index e9d64864d0..9281a802bf 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -27,7 +27,16 @@ pub fn is_revolve(id: &str) -> bool { /// Most bytes the copies of the object's paint in the faces may take together. Each face and the /// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of /// thousands of faces took gigabytes; past this the source path is drawn. -const MAX_PAINT_COPIES: usize = 256 << 20; +pub(crate) const MAX_PAINT_COPIES: usize = 256 << 20; + +/// The bytes of an object's paint copies so far: those of its surfaces' faces, and those of the parts +/// of its graphics, each within [`MAX_PAINT_COPIES`] of its own. Graphics past their budget are left +/// out without taking room the surfaces need. +#[derive(Default)] +struct Copies { + surfaces: usize, + graphics: usize, +} /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. @@ -96,13 +105,18 @@ pub fn revolve_options(p: &Value) -> Revolve { smooth_shading: true, cap_ends: flag(p, "cap", true), curve_samples: None, + map_surface: false, } } /// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with -/// parameters `p` sweeps its surface with. +/// parameters `p` sweeps its surface with. A Revolve with graphics lays out its side for them. fn options_of(id: &str, p: &Value) -> Revolve { - if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) } + if id == CLASSIC_REVOLVE { + classic::options(p) + } else { + Revolve { map_surface: id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty(), ..revolve_options(p) } + } } /// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with. @@ -140,6 +154,17 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; + // Without graphics, or without the document they draw from, no member is measured for them. + if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) { + match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) { + Some(s) => check_graphics(Some(doc), id, &s.path, b, options, p, &mut 0)?, + // With no member to carry them, the graphics' art must still fit its limits. + None => { + crate::solids::graphics::validate_symbols(doc, p)?; + crate::solids::graphics::validate_surface_symbols(doc, p)?; + } + } + } let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); return check_shadow(p, casting.map(|s| &s.path), b, options); } @@ -151,9 +176,44 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?; check_shadow(p, [&path], b, options_of(id, p)) } +/// An error when the graphics in Revolve (`id`) parameters `p`, with the symbols of `doc`, are past +/// their limits on the surface of `path` about the axis of `b`: their art, or the parts they draw +/// on its faces ([`crate::solids::graphics::surface_parts`]), whose paint copies count after +/// `copies`, the bytes of the copies of the object's graphics so far ([`Copies`]). The parts' +/// copies are added to `copies` when they fit, as drawing adds them. Without `doc` nothing is +/// drawn, and nothing is checked. +fn check_graphics(doc: Option<&Document>, id: &str, path: &PathData, b: Rect, options: Revolve, p: &Value, copies: &mut usize) -> Result<(), String> { + let graphics = crate::solids::mapped_graphics(p); + let Some(doc) = doc.filter(|_| id == REVOLVE && !graphics.is_empty()) else { return Ok(()) }; + crate::solids::graphics::validate_symbols(doc, p)?; + crate::solids::graphics::validate_surface_symbols(doc, p)?; + let faces = vectorcraft_three_d::revolve(path, b, options)?; + let mut used = *copies; + match crate::solids::graphics::surface_parts(Some(doc), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), &mut used) { + Some(_) => { + *copies = used; + Ok(()) + } + None => Err("The graphics are too complex for this surface: simplify the symbols' art or reduce Segments".into()), + } +} + +/// The member of a group whose side takes the group's graphics: the first with the longest +/// profile among those with a fill or stroke. +fn graphics_member(shapes: &[Profile], b: Rect, options: Revolve) -> Option { + shapes + .iter() + .enumerate() + .filter(|(_, s)| surface_item(&s.items).is_some()) + .filter_map(|(k, s)| vectorcraft_three_d::revolve_surface_size(&s.path, b, options).map(|(_, h)| (k, h))) + .fold(None, |best: Option<(usize, f64)>, (k, h)| if best.is_some_and(|(_, top)| top >= h) { best } else { Some((k, h)) }) + .map(|(k, _)| k) +} + /// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast /// from, about the axis of `b`, are past the limits together. fn check_shadow<'a>(p: &Value, paths: impl IntoIterator, b: Rect, options: Revolve) -> Result<(), String> { @@ -168,16 +228,17 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator, b: /// appearance item `target` of `n` ([`validate_item_of`]). #[cfg(test)] pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { - validate_item_of(n, REVOLVE, p, target, index) + validate_item_of(None, n, REVOLVE, p, target, index) } /// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index /// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke /// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible -/// object-level Revolve draws in place of those surfaces. -pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> { +/// object-level Revolve draws in place of those surfaces. With `doc`, `target`'s graphics must fit +/// its surface ([`check_graphics`]). +pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> { if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { - return validate_in(None, n, id, p); + return validate_in(doc, n, id, p); } validate_params_of(id, p)?; const EMPTY: &str = "Revolve requires a shape with at least two anchors"; @@ -187,7 +248,7 @@ pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, ind } let ctx = GeomContext::of(n); let path = geometry(&path, &n.appearance.effects, &ctx); - let mut copies = 0; + let (mut copies, mut graphic_copies) = (0, 0); for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) }; @@ -200,6 +261,12 @@ pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, ind let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + // The graphics of every item add their paints' copies to the graphics' budget, as they + // are drawn; only the edited item's graphics are refused here. + let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies); + if i == target { + graphics?; + } check_shadow(¶ms, [&path], b, options)?; } Ok(()) @@ -234,7 +301,7 @@ fn shared_options(shapes: &[Profile], options: Revolve) -> Result Option<&AppearanceItem> { +pub(crate) fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> { items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last()) } @@ -255,6 +322,7 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { /// only its own parameters, each in its range. pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; + crate::solids::graphics::validate(id, p)?; if id == CLASSIC_REVOLVE { classic::validate(p)?; } @@ -303,10 +371,13 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R } /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts -/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face -/// with its classic surface, as Extrude & Bevel (Classic) does. +/// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face +/// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the +/// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): +/// a face with a part of a graphic is a group of the face and that part. #[allow(clippy::too_many_arguments)] fn surface( + doc: Option<&Document>, n: &Node, path: &PathData, b: Rect, @@ -316,14 +387,14 @@ fn surface( opacity: f32, blend: vectorcraft_doc::color::BlendMode, expand: bool, - copies: &mut usize, + copies: &mut Copies, ) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; - *copies = copies.saturating_add(paint_copies(facets.len(), &paint)); - if *copies > MAX_PAINT_COPIES { + copies.surfaces = copies.surfaces.saturating_add(paint_copies(facets.len(), &paint)); + if copies.surfaces > MAX_PAINT_COPIES { return None; } let depths = facets.iter().map(|f| f.depth).collect(); @@ -332,14 +403,33 @@ fn surface( } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; - *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); - if *copies > MAX_PAINT_COPIES { + copies.surfaces = copies.surfaces.saturating_add(paint_copies(faces.len(), &paint)); + if copies.surfaces > MAX_PAINT_COPIES { return None; } + let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)); + let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored); + // Graphics past the limits are left out, and the surface draws without them and their paints' + // copies. + let mut used = copies.graphics; + let mut parts = if graphics.is_empty() { + vec![] + } else { + match crate::solids::graphics::surface_parts(doc, n.id, &graphics, &faces, &shade, &mut used) { + Some(parts) => { + copies.graphics = used; + parts + } + None => vec![], + } + }; + let drawn = parts.iter().any(|p| !p.is_empty()); // Visibility is an expansion option, never a change to live geometry or export baking. // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility - // failure also retains the complete surface rather than losing any of the user's art. + // failure also retains the complete surface rather than losing any of the user's art. With + // graphics drawn, every face stays, each with its part of the graphics. let visible = (expand + && !drawn && flag(&fx.params, "expandVisibleOnly", true) && paint.color().is_some() && opacity == 1.0 @@ -414,7 +504,16 @@ fn surface( .collect(); Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() })) }) - .collect(); + .collect::>(); + let children = if drawn { + children + .into_iter() + .zip(parts.iter_mut().map(std::mem::take).chain(std::iter::repeat_with(Vec::new))) + .map(|(face, part)| if part.is_empty() { face } else { Arc::new(Node::group(n.id, std::iter::once(face).chain(part).collect())) }) + .collect() + } else { + children + }; let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; @@ -422,11 +521,33 @@ fn surface( } /// A surface of one object about the bounds of `path`, built with options derived from `fx`. -/// `copies` is the count [`surface`] adds to. -fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option { +/// `copies` is the count [`surface`] adds to; its graphics take the symbols of `doc`. +fn object_surface( + doc: Option<&Document>, + n: &Node, + path: &PathData, + fx: &Effect, + item: &AppearanceItem, + expand: bool, + copies: &mut Copies, +) -> Option { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); - surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) + surface(doc, n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) +} + +/// The paint of a graphic's solid color `c` on `face`, shaded as the face shades a solid fill of +/// that color: across the face with its smooth shading, under colored lights (`colored`) with +/// their color. +fn graphic_paint(face: &vectorcraft_three_d::Face, c: Color, colored: bool) -> Paint { + if colored { + return face + .light_gradient + .map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c)); + } + let [r, g, b] = c.to_rgb(); + let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32); + face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0)) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -592,12 +713,12 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } let n = &*resolve_for(doc, n, "Revolve", false).ok()?; if is_container(n) { - return group_art(n, expand); + return group_art(doc, n, expand); } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; - let mut copies = 0; + let mut copies = Copies::default(); let mut remaining = n.appearance.effects.clone(); if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = n.appearance.effects.get(index)?; @@ -606,7 +727,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let surface = object_surface(n, &path, fx, item, expand, &mut copies)?; + let surface = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; if let Some(b) = path.bounds() && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()? { @@ -632,7 +753,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; + let mut art = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()? @@ -774,7 +895,7 @@ pub(crate) fn overlaps(a: Rect, b: Rect) -> bool { /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, /// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. -fn group_art(n: &Node, expand: bool) -> Option { +fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?; let fx = n.appearance.effects.get(index)?; let mut shapes = members(n, "").ok()?; @@ -782,17 +903,22 @@ fn group_art(n: &Node, expand: bool) -> Option { let b = union_bounds(&shapes)?; let options = shared_options(&shapes, fx_options(fx)).ok()?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut copies = 0; + let mut copies = Copies::default(); let mut surfaces = vec![]; let mut opaque = true; - for s in &shapes { + // The graphics go once, on the side of the member with the longest profile. Without graphics + // no member is measured for them. + let mapped = fx.id == REVOLVE && !crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)).is_empty(); + let longest = if mapped { graphics_member(&shapes, b, options) } else { None }; + for (k, s) in shapes.iter().enumerate() { let Some(item) = surface_item(&s.items) else { continue }; // The opacity and blending of the member and the groups it is in apply through its scopes. let (paint, opacity, blend) = paint_of(item); opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty(); // A member with transparency of its own keeps every face, as a transparent object does. let expand = expand && s.scopes.is_empty(); - let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; + let doc = doc.filter(|_| longest == Some(k)); + let (depths, art) = surface(doc, n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; surfaces.push((depths, art, s.scopes.as_slice())); } if surfaces.is_empty() { @@ -1517,3 +1643,7 @@ mod tests { assert_eq!(expand_revolve_art(&sample), revolve_art(&sample)); } } + +#[cfg(test)] +#[path = "revolve/tests_graphics.rs"] +mod tests_graphics; diff --git a/crates/effects/src/revolve/tests_graphics.rs b/crates/effects/src/revolve/tests_graphics.rs new file mode 100644 index 0000000000..5d9500628b --- /dev/null +++ b/crates/effects/src/revolve/tests_graphics.rs @@ -0,0 +1,576 @@ +use serde_json::json; +use vectorcraft_doc::Symbol; +use vectorcraft_doc::color::Color; +use vectorcraft_geom::{BezPath, shapes}; + +use super::*; +use crate::solids::three_d_params_as; + +fn bar(r: Rect, color: Color) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(color), Paint::None, 0.0)) +} + +/// A document with symbols "Red" and "Blue" (10 × 20 bars) and "Wide" (a red 300 × 10 bar). +fn document() -> Document { + let mut d = Document::new(1000.0, 1000.0); + for (name, r, color) in [ + ("Red", Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(1.0, 0.0, 0.0)), + ("Blue", Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(0.0, 0.0, 1.0)), + ("Wide", Rect::new(0.0, 0.0, 300.0, 10.0), Color::rgb(1.0, 0.0, 0.0)), + ("Flat", Rect::new(0.0, 0.0, 10.0, 2.0), Color::rgb(1.0, 0.0, 0.0)), + ] { + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(bar(r, color)) }); + } + d +} + +/// A gray open profile through `points` (its first and last on the axis at x 0), revolved with +/// `params` over a view straight on without shading. +fn revolved(points: &[(f64, f64)], params: Value) -> Node { + let mut b = BezPath::new(); + b.move_to(points[0]); + points[1..].iter().for_each(|p| b.line_to(*p)); + let mut p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false}); + p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone()); + let mut n = Node::path(NodeId(7), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + n +} + +/// A cylinder of radius 50 about x 0, from y 0 to 200. +const CYLINDER: [(f64, f64); 4] = [(0.0, 0.0), (50.0, 0.0), (50.0, 200.0), (0.0, 200.0)]; + +/// A profile that narrows from 50 to 20 from the axis at y 100 and widens again. +const NECK: [(f64, f64); 5] = [(0.0, 0.0), (50.0, 0.0), (20.0, 100.0), (50.0, 200.0), (0.0, 200.0)]; + +/// The red channel of each color `paint` draws when it is a shade of pure red or pure blue (`blue`): +/// a solid color or the stops of a gradient. `None` for any other paint. +fn shade_of(paint: &Paint, blue: bool) -> Option> { + let hue = |c: &Color| { + let [r, g, b] = c.to_rgb(); + let (on, off) = if blue { (b, r) } else { (r, b) }; + (on > 0.02 && off < 0.01 && g < 0.01).then_some(on) + }; + match paint { + Paint::Solid { .. } => Some(vec![hue(&paint.color()?)?]), + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| hue(&s.color)).collect(), + _ => None, + } +} + +/// The paths in `art` filled in a shade of red (or blue), with their shades. +fn parts(art: &Node, blue: bool) -> Vec<(PathData, Vec)> { + let mut out = vec![]; + art.walk(&mut |n| { + let Some(path) = n.path_data() else { return }; + let fill = n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => shade_of(&f.paint, blue), + AppearanceItem::Stroke(_) => None, + }); + if let Some(shades) = fill { + out.push((path.clone(), shades)); + } + }); + out +} + +fn reach(parts: &[(PathData, Vec)]) -> Rect { + parts.iter().filter_map(|(p, _)| p.bounds()).reduce(|a, b| a.union(b)).unwrap() +} + +fn near(a: f64, b: f64, tolerance: f64) -> bool { + (a - b).abs() <= tolerance +} + +#[test] +fn a_graphic_wraps_around_the_side_and_narrows_toward_the_silhouette() { + let d = document(); + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}, {"symbol": "Blue", "x": 60.0}]})); + let art = revolve_art_in(Some(&d), &n).unwrap(); + // At the middle of the side, facing the viewer, the bar keeps its size: 10 wide at the axis's + // line, from 10 above the middle of the profile to 10 below. + let front = reach(&parts(&art, false)); + let half = 50.0 * (5.0f64 / 50.0).sin(); + assert!(near(front.x0, -half, 0.05) && near(front.x1, half, 0.05), "{front:?}"); + assert!(near(front.y0, 90.0, 1e-6) && near(front.y1, 110.0, 1e-6), "{front:?}"); + // 60 to its right the side has turned 1.1 to 1.3 radians away: the same bar draws less than + // half as wide, at the sine of those angles. + let turned = reach(&parts(&art, true)); + assert!(near(turned.x0, 50.0 * 1.1f64.sin(), 0.15) && near(turned.x1, 50.0 * 1.3f64.sin(), 0.15), "{turned:?}"); + assert!(turned.width() < front.width() / 2.0); +} + +#[test] +fn the_part_past_the_silhouette_and_on_the_back_is_not_drawn() { + let d = document(); + // A bar wider than the half of the side seen from the front reaches the silhouette and no + // further. + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Wide"}]})); + let wide = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + assert!(wide.x0 > -50.0 - 1e-6 && wide.x1 < 50.0 + 1e-6, "{wide:?}"); + assert!(wide.x0 < -49.5 && wide.x1 > 49.5, "{wide:?}"); + // On the back, half a turn around the axis, nothing is drawn. + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red", "x": 150.0}]})); + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); + // Turned half a turn, the back faces the viewer. + let n = revolved(&CYLINDER, json!({"rotationY": 180.0, "graphics": [{"symbol": "Red", "x": 150.0}]})); + assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +#[test] +fn a_graphic_follows_the_profile_and_keeps_its_size_where_it_comes_nearer_the_axis() { + let d = document(); + // A 10 wide bar keeps its length around the axis at every distance from it: at the neck, 20 + // from the axis, it spans 0.5 radians; half way up the slanted side (52.2 along it, 35 from + // the axis, at y 50), 2/7 of a radian. Straight on, both draw nearly 10 wide. + let slant = (30.0f64 * 30.0 + 100.0 * 100.0).sqrt(); + let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat"}, {"symbol": "Flat", "y": -slant / 2.0}]})); + let art = revolve_art_in(Some(&d), &n).unwrap(); + let all = parts(&art, false); + let neck: Vec<_> = all.iter().filter(|(p, _)| p.bounds().unwrap().center().y > 75.0).cloned().collect(); + let up: Vec<_> = all.iter().filter(|(p, _)| p.bounds().unwrap().center().y <= 75.0).cloned().collect(); + let (neck, up) = (reach(&neck), reach(&up)); + // Each bar is 2 tall, so its ends are up to 0.3 farther from the axis than its middle. + assert!(near(neck.width(), 2.0 * 20.0 * 0.25f64.sin(), 0.15), "{neck:?}"); + assert!(near(neck.center().y, 100.0, 0.05), "{neck:?}"); + assert!(near(up.width(), 2.0 * 35.0 * (5.0f64 / 35.0).sin(), 0.15), "{up:?}"); + assert!(near(up.center().y, 50.0, 0.05), "{up:?}"); + // At the neck a bar 25 to the right lies 1 to 1.5 radians around, near the silhouette; one 40 + // to the right lies 1.75 to 2.25 radians around, past the silhouette, and is not drawn. + let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat", "x": 25.0}]})); + let side = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + assert!(near(side.x0, 20.0 * 1.0f64.sin(), 0.3) && near(side.x1, 20.0 * 1.5f64.sin(), 0.3), "{side:?}"); + let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat", "x": 40.0}]})); + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +#[test] +fn with_the_axis_on_the_right_a_graphic_starts_on_the_front() { + let d = document(); + // The cylinder of radius 50 about x 0, swept from its right edge. + let profile = [(0.0, 0.0), (-50.0, 0.0), (-50.0, 200.0), (0.0, 200.0)]; + let n = revolved(&profile, json!({"edge": "right", "graphics": [{"symbol": "Red"}]})); + let front = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + let half = 50.0 * (5.0f64 / 50.0).sin(); + assert!(near(front.x0, -half, 0.05) && near(front.x1, half, 0.05), "{front:?}"); + assert!(near(front.y0, 90.0, 1e-6) && near(front.y1, 110.0, 1e-6), "{front:?}"); + // x increases to the viewer's right here too. + let n = revolved(&profile, json!({"edge": "right", "graphics": [{"symbol": "Red", "x": 25.0}]})); + let right = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + assert!(near(right.center().x, 50.0 * 0.5f64.sin(), 0.3), "{right:?}"); +} + +#[test] +fn a_graphic_is_shaded_with_the_surface() { + let d = document(); + // The light is on the left: the bar left of the middle is lighter than the one on its right, + // and neither keeps its unlit red. + let n = revolved(&CYLINDER, json!({"shade": true, "graphics": [{"symbol": "Red", "x": -30.0}, {"symbol": "Red", "x": 30.0}]})); + let all = parts(&revolve_art_in(Some(&d), &n).unwrap(), false); + let mean = |left: bool| { + let shades: Vec = + all.iter().filter(|(p, _)| (p.bounds().unwrap().center().x < 0.0) == left).flat_map(|(_, s)| s.iter().copied()).collect(); + shades.iter().sum::() / shades.len() as f32 + }; + assert!(mean(true) > mean(false) + 0.05, "{} {}", mean(true), mean(false)); + assert!(all.iter().all(|(_, s)| s.iter().all(|r| *r < 0.999))); +} + +#[test] +fn overlapping_graphics_keep_their_order_on_every_face() { + let d = document(); + // The wide red bar, listed second, reaches across the narrow blue one, listed first. Shaded, + // each face is one triangle. + let n = revolved(&CYLINDER, json!({"shade": true, "graphics": [{"symbol": "Blue", "x": 30.0}, {"symbol": "Wide"}]})); + let art = revolve_art_in(Some(&d), &n).unwrap(); + let mut both = 0; + art.walk(&mut |g| { + // A face with parts of graphics: the face's path, then the parts' paths. + let NodeKind::Group { children, clip: false } = &g.kind else { return }; + if !children.iter().all(|c| matches!(c.kind, NodeKind::Path { .. })) { + return; + } + let colors: Vec = children + .iter() + .filter_map(|c| { + let blue = !parts(c, true).is_empty(); + (blue || !parts(c, false).is_empty()).then_some(blue) + }) + .collect(); + if colors.contains(&true) && colors.contains(&false) { + both += 1; + assert!(colors.iter().position(|b| !b) > colors.iter().position(|b| *b), "{colors:?}"); + } + }); + assert!(both > 0); +} + +#[test] +fn a_graphic_past_the_end_reaches_the_curved_rim_and_no_further() { + let d = document(); + // A tube 50 from the axis with a cap at each end, in 10 segments, tilted to show its top: the + // bottom rim is an arc on the page whose lowest point, in front, lies between two corners of + // the faces. The wide bar runs 5 past the bottom of the profile. + for shade in [true, false] { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((0.0, 200.0)); + let p = json!({"offset": 50.0, "segments": 10, "rotationX": -30.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": shade, "graphics": [{"symbol": "Wide", "y": 100.0}]}); + let mut n = Node::path(NodeId(7), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + let art = revolve_art_in(Some(&d), &n).unwrap(); + let mut lowest = f64::NEG_INFINITY; + art.walk(&mut |c| { + if let Some(b) = c.path_data().and_then(PathData::bounds) { + lowest = lowest.max(b.y1); + } + }); + let bar = reach(&parts(&art, false)); + assert!(near(bar.y1, lowest, 0.05), "shade {shade}: {bar:?} {lowest}"); + // A thin bar wholly past the end, 0.35 to 0.85 below it in front, lies between the faces' + // straight lower edge and the rim's arc there: it is drawn, inside the rim. + let mut thin = n.clone(); + let p = json!({"offset": 50.0, "segments": 10, "rotationX": -30.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": shade, "graphics": [{"symbol": "Flat", "y": 100.6, "scale": 25.0}]}); + thin.appearance.effects = vec![crate::new_effect(REVOLVE, &p).unwrap()]; + let past = parts(&revolve_art_in(Some(&d), &thin).unwrap(), false); + assert!(!past.is_empty(), "shade {shade}"); + assert!(reach(&past).y1 <= lowest + 1e-6, "shade {shade}: {:?} {lowest}", reach(&past)); + } +} + +#[test] +fn under_several_lights_a_graphic_takes_each_faces_own_lit_art() { + let d = document(); + let lights = json!([{"color": "#ff8080", "rotation": 120.0}, {"color": "#8080ff", "rotation": -30.0}]); + let clips = |n: &Node| { + let mut count = 0; + revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(matches!(c.kind, NodeKind::Group { clip: true, .. }))); + count + }; + let plain = revolved(&CYLINDER, json!({"shade": true, "lights": lights})); + let mapped = revolved(&CYLINDER, json!({"shade": true, "lights": lights, "graphics": [{"symbol": "Red"}]})); + // Each part of the bar is the face's lit art clipped to the part: one clip more for the + // part, one for the face's own art inside it. + assert!(clips(&mapped) > clips(&plain), "{} {}", clips(&mapped), clips(&plain)); + // A lit face draws its paint under the face's light: a layer of the light's three channels, + // multiplied over the paint. Every part of the bar takes such a layer of its own, so the bar + // is shaded with each face's colored light; a bar drawn in flat red would add none. + let lit = |n: &Node| { + let mut count = 0; + revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(c.blend == vectorcraft_doc::color::BlendMode::Multiply)); + count + }; + assert!(lit(&mapped) > lit(&plain), "{} {}", lit(&mapped), lit(&plain)); + // An opaque part also draws its lit art clipped to the band of its seam stroke; a translucent + // part, whose overlap would show, does not. + let mut d = d.clone(); + let mut clear = bar(Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + if let Some(AppearanceItem::Fill(f)) = clear.appearance.items.first_mut() { + f.opacity = 0.5; + } + d.symbols.push(Symbol { dynamic: false, name: "Clear".into(), art: Arc::new(clear) }); + let clips = |n: &Node| { + let mut count = 0; + revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(matches!(c.kind, NodeKind::Group { clip: true, .. }))); + count + }; + let translucent = revolved(&CYLINDER, json!({"shade": true, "lights": lights, "graphics": [{"symbol": "Clear"}]})); + let (base, opaque, translucent) = (clips(&plain), clips(&mapped), clips(&translucent)); + assert!(translucent > base && opaque - base == 2 * (translucent - base), "{base} {opaque} {translucent}"); +} + +#[test] +fn graphics_past_the_point_limit_are_refused() { + let mut d = document(); + let dots: Vec> = (0..2000) + .map(|i| { + let c = Point::new(f64::from(i % 50) * 12.0, f64::from(i / 50) * 12.0); + Arc::new(Node::path( + NodeId(0), + shapes::ellipse(Rect::new(c.x, c.y, c.x + 10.0, c.y + 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }) + .collect(); + d.symbols.push(Symbol { dynamic: false, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), dots)) }); + let n = revolved(&CYLINDER, json!({})); + let p = |scale: f64| json!({"graphics": [{"symbol": "Dots", "scale": scale}]}); + assert!(validate_revolve_in(Some(&d), &n, &p(100.0)).is_ok()); + // At 100 times the size each dot is cut into hundreds of straight pieces. + let e = validate_revolve_in(Some(&d), &n, &p(10_000.0)).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); +} + +#[test] +fn graphics_cut_into_too_many_parts_are_refused_on_the_object_and_on_a_fill() { + let mut d = document(); + // 4,000 thin bars across the side: few points each, but each is cut at every face it crosses. + let bars: Vec> = + (0..4000).map(|i| Arc::new(bar(Rect::new(0.0, f64::from(i) * 0.05, 300.0, f64::from(i) * 0.05 + 0.02), Color::BLACK))).collect(); + d.symbols.push(Symbol { dynamic: false, name: "Bars".into(), art: Arc::new(Node::group(NodeId(0), bars)) }); + let p = json!({"segments": 128, "graphics": [{"symbol": "Bars"}]}); + let n = revolved(&CYLINDER, json!({})); + let e = validate_revolve_in(Some(&d), &n, &p).unwrap_err(); + assert!(e.contains("too complex for this surface"), "{e}"); + assert!(validate_revolve_in(Some(&d), &n, &json!({"graphics": [{"symbol": "Red"}]})).is_ok()); + // The same on a Revolve on the object's fill. + let mut filled = n.clone(); + let fx = filled.appearance.effects.remove(0); + if let Some(AppearanceItem::Fill(f)) = filled.appearance.items.first_mut() { + f.effects.push(fx); + } + let e = crate::solids::validate_three_d_at_in(Some(&d), &filled, REVOLVE, &p, (Some(0), 0)).unwrap_err(); + assert!(e.contains("too complex for this surface"), "{e}"); +} + +#[test] +fn without_the_document_nothing_is_drawn_and_expand_keeps_the_graphics() { + let d = document(); + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}]})); + assert!(parts(&revolve_art(&n).unwrap(), false).is_empty()); + let shown = parts(&revolve_art_in(Some(&d), &n).unwrap(), false); + let expanded = parts(&expand_revolve_art_in(Some(&d), &n).unwrap(), false); + assert!(!shown.is_empty()); + assert_eq!(shown, expanded); + // In a group the graphics go once, on the side of the member with the longest profile. + let mut member = n.clone(); + member.appearance.effects.clear(); + let mut short = member.clone(); + if let NodeKind::Path { path, .. } = &mut short.kind { + *path = PathData::from_bezpath(&{ + let mut b = BezPath::new(); + b.move_to((0.0, 80.0)); + b.line_to((30.0, 80.0)); + b.line_to((30.0, 120.0)); + b.line_to((0.0, 120.0)); + b + }); + } + let mut group = Node::group(NodeId(9), vec![Arc::new(short), Arc::new(member)]); + group.appearance.effects = n.appearance.effects.clone(); + assert_eq!(parts(&revolve_art_in(Some(&d), &group).unwrap(), false).len(), shown.len()); +} + +#[test] +fn revolve_takes_graphics_on_its_side_only() { + let front = json!({"graphics": [{"symbol": "Red"}]}); + let back = json!({"graphics": [{"symbol": "Red", "face": "back"}]}); + assert!(crate::solids::graphics::validate(REVOLVE, &front).is_ok()); + assert!(crate::solids::graphics::validate(REVOLVE, &back).unwrap_err().contains("side")); + assert!(crate::solids::graphics::validate(CLASSIC_REVOLVE, &front).is_err()); + let n = revolved(&CYLINDER, json!({})); + assert!(validate_revolve(&n, &back).is_err()); + // Changing the type keeps the front's graphics on Revolve's side and drops the back's. + let both = json!({"graphics": [{"symbol": "Red"}, {"symbol": "Blue", "face": "back"}]}); + let kept = three_d_params_as(crate::EXTRUDE, &both, REVOLVE); + assert_eq!(kept["graphics"].as_array().map(Vec::len), Some(1)); + assert_eq!(kept["graphics"][0]["symbol"], "Red"); + assert_eq!(three_d_params_as(REVOLVE, &front, crate::INFLATE).get("graphics"), None); +} + +#[test] +fn revolve_classic_draws_no_graphics_its_parameters_carry() { + let d = document(); + let mut n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}]})); + assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); + // A file can hold graphics on Revolve (Classic), which commands refuse: Revolve (Classic) + // draws its classic surface, without them. + if let Some(fx) = n.appearance.effects.first_mut() { + fx.id = CLASSIC_REVOLVE.into(); + } + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +#[test] +fn a_group_with_no_member_to_carry_graphics_still_checks_their_art() { + let mut d = document(); + let dots: Vec> = (0..2000) + .map(|i| { + let c = Point::new(f64::from(i % 50) * 12.0, f64::from(i / 50) * 12.0); + Arc::new(Node::path( + NodeId(0), + shapes::ellipse(Rect::new(c.x, c.y, c.x + 10.0, c.y + 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }) + .collect(); + d.symbols.push(Symbol { dynamic: false, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), dots)) }); + let n = revolved(&CYLINDER, json!({})); + let mut member = n.clone(); + member.appearance.effects.clear(); + member.appearance.items.clear(); + let mut group = Node::group(NodeId(9), vec![Arc::new(member)]); + group.appearance.effects = n.appearance.effects.clone(); + // No member has a fill or stroke, so none carries the graphics, but art past the limits is + // refused as it is on a group whose member carries them. + let e = validate_in(Some(&d), &group, REVOLVE, &json!({"graphics": [{"symbol": "Dots", "scale": 10_000.0}]})).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); + assert!(validate_in(Some(&d), &group, REVOLVE, &json!({"graphics": [{"symbol": "Red"}]})).is_ok()); +} + +#[test] +fn a_full_turn_draws_a_graphic_on_either_side_of_the_cut_at_the_back() { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(bar(Rect::new(0.0, 0.0, 2.0, 20.0), Color::rgb(1.0, 0.0, 0.0))) }); + // Half a turn around a radius of 50 is about 157 to each side of the middle: turned to face + // the viewer, a graphic just inside either end is drawn. + for x in [155.0, -155.0] { + let n = revolved(&CYLINDER, json!({"rotationY": 180.0, "graphics": [{"symbol": "Thin", "x": x}]})); + assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty(), "x {x}"); + } +} + +/// A gray cylinder of radius 50 whose side is 200 edges along the profile, from y 0 to 200. +fn ribbed() -> Vec<(f64, f64)> { + let mut points = vec![(0.0, 0.0)]; + points.extend((0..=200).map(|k| (50.0, f64::from(k)))); + points.push((0.0, 200.0)); + points +} + +/// A symbol `name` of one red path whose contours are `contours`, each a polygon through its +/// points. +fn contours_symbol(name: &str, contours: &[Vec]) -> Symbol { + let mut b = BezPath::new(); + for contour in contours { + b.move_to(contour[0]); + contour[1..].iter().for_each(|p| b.line_to(*p)); + b.close_path(); + } + let art = Node::path(NodeId(0), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)); + Symbol { dynamic: false, name: name.into(), art: Arc::new(art) } +} + +#[test] +fn contours_away_from_a_face_are_not_cut_at_it() { + // 2,000 small squares in two clusters on the back, at opposite corners of the side: the path's + // bounds reach across every face, but no square reaches a face that is drawn. + let squares: Vec> = (0..2000) + .map(|i| { + let (x, y) = if i < 1000 { (0.0, 0.0) } else { (290.0, 190.0) }; + let (x, y) = (x + f64::from(i % 10) * 0.1, y + f64::from(i % 1000 / 10) * 0.1); + vec![Point::new(x, y), Point::new(x + 0.05, y), Point::new(x + 0.05, y + 0.05), Point::new(x, y + 0.05)] + }) + .collect(); + let mut d = document(); + d.symbols.push(contours_symbol("Clusters", &squares)); + let n = revolved(&ribbed(), json!({})); + let p = json!({"graphics": [{"symbol": "Clusters"}]}); + assert_eq!(validate_revolve_in(Some(&d), &n, &p), Ok(())); +} + +#[test] +fn a_contour_that_reaches_every_face_and_draws_on_none_is_refused() { + // One contour of 10,000 points: a thin frame around the whole side, past its ends and on the + // back, so it is cut at every face and keeps nothing. + let edge = |from: Point, to: Point| (0..2500).map(move |k| from.lerp(to, f64::from(k) / 2500.0)); + let (outer, inner) = ([(0.0, 0.0), (400.0, 0.0), (400.0, 400.0), (0.0, 400.0)], [(1.0, 1.0), (1.0, 399.0), (399.0, 399.0), (399.0, 1.0)]); + let ring = |corners: [(f64, f64); 4]| (0..4).flat_map(move |k| edge(corners[k].into(), corners[(k + 1) % 4].into())).collect::>(); + let mut frame = ring(outer); + frame.extend(ring(inner)); + let mut d = document(); + d.symbols.push(contours_symbol("Frame", &[frame])); + let n = revolved(&ribbed(), json!({})); + let e = validate_revolve_in(Some(&d), &n, &json!({"graphics": [{"symbol": "Frame"}]})).unwrap_err(); + assert!(e.contains("too complex for this surface"), "{e}"); + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +/// A red linear gradient of `stops` stops, each with `opacity`. +fn ramp(stops: usize, opacity: f32) -> Paint { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = (0..stops) + .map(|i| { + let mut stop = GradientStop::new(i as f32 / stops as f32, Color::rgb(1.0, 0.0, 0.0)); + stop.opacity = opacity; + stop + }) + .collect(); + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))) +} + +#[test] +fn the_copies_of_a_graphics_paint_have_a_budget_of_their_own() { + let mut d = document(); + for (name, opacity) in [("Ramp", 1.0), ("Clear ramp", 0.5)] { + let art = + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 10.0)), Appearance::basic(ramp(4096, opacity), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + let p = json!({"graphics": [{"symbol": "Ramp"}]}); + let n = revolved(&CYLINDER, json!({})); + let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(path, path.bounds().unwrap(), options_of(REVOLVE, &p)).unwrap(); + let parts_of = |symbol: &str, copies: &mut usize| { + let graphics = crate::solids::mapped_graphics(&json!({"graphics": [{"symbol": symbol}]})); + crate::solids::graphics::surface_parts(Some(&d), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), copies).map(|p| p.concat().len()) + }; + // Each opaque part copies the paint into its fill and its seam stroke; a part with + // transparent stops takes no seam stroke and copies it once. + let (opaque, clear) = (paint_bytes(&ramp(4096, 1.0)), paint_bytes(&ramp(4096, 0.5))); + let mut used = 0; + let count = parts_of("Ramp", &mut used).unwrap(); + assert!(count >= 2, "the graphic draws on several faces"); + assert_eq!(used, 2 * count * opaque); + let mut used = 0; + assert_eq!(parts_of("Clear ramp", &mut used), Some(count)); + assert_eq!(used, count * clear); + // Past the budget the graphic is refused. + assert_eq!(parts_of("Ramp", &mut (MAX_PAINT_COPIES - opaque)), None); + // A surface whose own gradient takes nearly the whole budget for surfaces still takes the + // graphic, on the object, on one fill of two and on a member of a group. + let stop = paint_bytes(&ramp(2, 1.0)) - paint_bytes(&ramp(1, 1.0)); + let base = paint_bytes(&ramp(1, 1.0)) - stop; + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let full = ramp(((MAX_PAINT_COPIES - paint_copies(faces.len(), &gray)) / (faces.len() * 4) - base) / stop, 1.0); + assert!(MAX_PAINT_COPIES - paint_copies(faces.len(), &gray) - paint_copies(faces.len(), &full) < opaque); + let mut object = n.clone(); + if let Some(AppearanceItem::Fill(f)) = object.appearance.items.first_mut() { + f.paint = ramp(((MAX_PAINT_COPIES - opaque) / (faces.len() * 4) - base) / stop, 1.0); + } + assert_eq!(validate_revolve_in(Some(&d), &object, &p), Ok(())); + let fill = |paint: Paint, params: Value| { + let mut fill = FillLayer::new(paint); + fill.effects.push(crate::new_effect(REVOLVE, ¶ms).unwrap()); + AppearanceItem::Fill(fill) + }; + let mut two = n.clone(); + two.appearance = Appearance { items: vec![fill(gray.clone(), p.clone()), fill(full.clone(), json!({}))], ..Default::default() }.into(); + assert_eq!(crate::solids::validate_three_d_at_in(Some(&d), &two, REVOLVE, &json!({}), (Some(1), 0)), Ok(())); + let mut member = n.clone(); + member.appearance.effects.clear(); + let mut second = member.clone(); + if let Some(AppearanceItem::Fill(f)) = second.appearance.items.first_mut() { + f.paint = full; + } + let mut group = Node::group(NodeId(9), vec![Arc::new(member), Arc::new(second)]); + group.appearance.effects = n.appearance.effects.clone(); + assert_eq!(validate_in(Some(&d), &group, REVOLVE, &p), Ok(())); +} + +/// Whether each part of `art` filled with a gradient has a stroke over its seams. +fn gradient_seams(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |n| { + let items = &n.appearance.items; + let red = |p: &Paint| matches!(p, Paint::Gradient(_)) && shade_of(p, false).is_some(); + if n.path_data().is_some() && matches!(items.first(), Some(AppearanceItem::Fill(f)) if red(&f.paint)) { + out.push(items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))); + } + }); + out +} + +#[test] +fn a_gradient_with_transparent_stops_takes_no_stroke_over_its_seams() { + let mut d = document(); + for (name, opacity) in [("Opaque", 1.0), ("Clear", 0.5)] { + let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 10.0)), Appearance::basic(ramp(2, opacity), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + let seams = |symbol: &str| gradient_seams(&revolve_art_in(Some(&d), &revolved(&CYLINDER, json!({"graphics": [{"symbol": symbol}]}))).unwrap()); + let opaque = seams("Opaque"); + assert!(!opaque.is_empty() && opaque.iter().all(|s| *s), "{opaque:?}"); + let clear = seams("Clear"); + assert!(!clear.is_empty() && clear.iter().all(|s| !*s), "{clear:?}"); +} diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 52739983a5..9b364c3d3a 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; -use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; +use vectorcraft_pathops::BoolOp; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -20,13 +21,14 @@ pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLO mod source; pub(crate) use source::{resolve, resolve_for}; -mod graphics; +pub(crate) mod graphics; pub use graphics::{ GRAPHICS, GraphicFrame, MAX_GRAPHICS, MappedGraphic, graphic_symbols, mapped_graphics, missing_symbol, names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, takes_graphics, with_graphic, }; pub use graphics::{ - MAX_OFFSET as GRAPHIC_MAX_OFFSET, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, rename_appearance_graphic_symbol, + MAX_OFFSET as GRAPHIC_MAX_OFFSET, NOT_TAKEN as GRAPHICS_NOT_TAKEN, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, + rename_appearance_graphic_symbol, }; pub(crate) mod shadow; @@ -79,8 +81,9 @@ pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value { } } } - // A plane has only a front, so graphics on the back cap are dropped. - if to == ROTATE + // A plane has only a front, and Revolve's graphics go on its side, named the front, so graphics + // on the back cap are dropped. + if (to == ROTATE || to == crate::REVOLVE) && let Some(Value::Array(list)) = out.get_mut(GRAPHICS) { list.retain(|g| g.get("face").and_then(Value::as_str) != Some(PlanarFace::Back.id())); @@ -504,7 +507,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va (Some(_), _) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } - (Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index), + (Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(doc, n, id, p, item, index), (None, _) if crate::revolve::is_revolve(id) => crate::revolve::validate_in(doc, n, id, p), (item, index) => validate_solid_for(doc, n, id, p, (item, Some(index))), } @@ -678,7 +681,8 @@ fn built_solids<'a>( /// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's /// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of /// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its -/// strokes that covers the fill's whole edge. +/// strokes that covers the fill's whole edge. A face that [`convex_face`] takes also keeps its +/// polygon and plane, for [`local_order`]; every other face has an empty `outline` and no `plane`. struct Placed { depth: f64, side: bool, @@ -686,6 +690,8 @@ struct Placed { bounds: Rect, member: usize, paints: Paints, + outline: Vec, + plane: Option, } /// The part of its shape a face's solid is built from ([`Placed`]). @@ -694,13 +700,22 @@ enum Paints { Fill, /// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without /// dashes, a width profile or a brush, painted with a solid color or a gradient without - /// transparent stops, on a shape whose subpaths are all closed. + /// transparent stops, on a shape whose subpaths are all closed, extruded (an inflated fill can + /// rise in front of its stroke). Stroke, Other, } +/// Can a stroke's solid on a shape with outline `path` hold the fill's edge ([`Paints::Stroke`]) +/// under the effect `effect`? Only when the subpaths are all closed and the effect extrudes: an +/// inflated fill can rise in front of its stroke. +fn holds_edge(effect: &str, path: &PathData) -> bool { + effect != INFLATE && path.subpaths.iter().all(|p| p.closed) +} + impl Paints { - /// What `item` of a shape is, the shape's subpaths all closed when `closed`. + /// What `item` of a shape is, its stroke's solid able to hold the fill's edge when `closed` + /// ([`holds_edge`]). fn of(item: &AppearanceItem, closed: bool) -> Self { match item { AppearanceItem::Fill(_) => Paints::Fill, @@ -729,10 +744,446 @@ impl Placed { fn of(f: &Facet, member: usize, paints: Paints) -> Self { let mut points = f.contours.iter().flatten(); let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q))); - Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints } + let local = convex_face(f).is_some(); + let outline = f.contours.first().filter(|_| local).cloned().unwrap_or_default(); + let plane = f.plane.filter(|_| local); + Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints, outline, plane } } } +/// The most pairs of faces [`local_order`] compares, and the most [`cut_stroke_faces`] compares; +/// the pairs they have not reached by then keep the order of their depths. +const MAX_LOCAL_CHECKS: usize = 4_000_000; + +/// The polygon of `f` and the sign of its turns, when `f` is a face turned toward the viewer other +/// than a planar end, with a plane and one convex contour: a face [`local_order`] and +/// [`cut_stroke_faces`] work on. +fn convex_face(f: &Facet) -> Option<(&[Point], f64)> { + if f.away || f.end.is_some() || f.plane.is_none() || f.contours.len() != 1 { + return None; + } + let c = f.contours.first()?; + Some((c, convex(c)?)) +} + +/// The bounds of polygon `p` (`Rect::ZERO` when it has no points). +fn polygon_bounds(p: &[Point]) -> Rect { + let mut points = p.iter(); + points.next().map_or(Rect::ZERO, |q| points.fold(Rect::from_points(*q, *q), |r, q| r.union_pt(*q))) +} + +/// Do rectangles `a` and `b` overlap (more than along an edge)? +fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + +/// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces +/// and whose paint copies take `paint_bytes` so far: as many as keep the faces within +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies +/// of the costliest stroke's paint. +fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { + let per_face = items + .filter(|item| matches!(item, AppearanceItem::Stroke(_))) + .map(|item| { + let mut copies = 0usize; + crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + copies + }) + .max() + .unwrap_or(0) + .max(1); + let affordable = crate::revolve::MAX_PAINT_COPIES.saturating_sub(paint_bytes) / per_face; + MAX_OBJECT_FACES.saturating_sub(faces).min(affordable) +} + +/// In `solids`, each solid's part ([`Paints`]), its shape's index and its faces, cut each face of a +/// stroke that does not cover the fill's edge ([`Paints::Other`]) that passes through the plane of a +/// face of the same shape's fill, or of a face of another of the shape's strokes, where the two +/// overlap on the drawing ([`cut_through`]). Of two such strokes, the later one's faces are cut +/// against the earlier one's; a covering stroke's faces ([`Paints::Stroke`]) are never cut, and an +/// other stroke's faces are cut against them. At most `room` pieces are added, the object's solids +/// together. +fn cut_stroke_faces(solids: &mut [(Paints, usize, &mut Vec)], mut room: usize) { + let mut checks = 0usize; + for k in 0..solids.len() { + let Some((Paints::Other, shape)) = solids.get(k).map(|(p, s, _)| (*p, *s)) else { continue }; + let walls: Vec = (0..solids.len()) + .filter(|&j| { + solids.get(j).is_some_and(|(p, s, _)| { + *s == shape + && match p { + Paints::Fill | Paints::Stroke => true, + Paints::Other => j < k, + } + }) + }) + .collect(); + for j in walls { + let (wall, stroke) = if j < k { + let (before, from) = solids.split_at_mut(k); + (&**before[j].2, &mut *from[0].2) + } else { + let (before, from) = solids.split_at_mut(j); + (&**from[0].2, &mut *before[k].2) + }; + cut_through(wall, stroke, &mut room, &mut checks); + } + } +} + +/// Cut each face of `stroke` that passes through the plane of a face of `solid` (the fill's or +/// another stroke's) where their polygons overlap on the drawing, along the line where their depths +/// meet, so that each piece is wholly in front of or behind that face there ([`local_order`] can +/// then order them). A dash's end, which crosses the edge of the fill it is drawn on, is cut into its +/// part outside the fill, in front of the fill's wall, and its part inside, behind it. A piece keeps +/// the part of the face's curved outline on its side of the cut. The first piece of a face takes its +/// place, the others go at the end. Each cut adds a face out of `room`, and each pair compared counts +/// in `checks` ([`MAX_LOCAL_CHECKS`]); cutting stops when either runs out. +fn cut_through(solid: &[Facet], stroke: &mut Vec, room: &mut usize, checks: &mut usize) { + // The faces from left to right: a face is compared with those that start left of its end. + let mut walls: Vec<(&Facet, &[Point], f64, Rect)> = + solid.iter().filter_map(|f| convex_face(f).map(|(c, turn)| (f, c, turn, polygon_bounds(c)))).collect(); + if walls.is_empty() { + return; + } + walls.sort_by(|a, b| a.3.x0.total_cmp(&b.3.x0)); + let mut k = 0; + while k < stroke.len() { + let mut extent = stroke.get(k).and_then(convex_face).map(|(c, _)| polygon_bounds(c)); + for &(wall, outline, turn, bounds) in &walls { + let Some(at) = extent.filter(|e| bounds.x0 < e.x1) else { break }; + if *checks >= MAX_LOCAL_CHECKS || *room == 0 { + return; + } + *checks += 1; + if !rects_overlap(at, bounds) { + continue; + } + let Some(s) = stroke.get(k) else { break }; + let Some((c, _)) = convex_face(s) else { break }; + if let Some((near, far)) = cut(s, c, wall, outline, turn) { + *room -= 1; + extent = convex_face(&near).map(|(c, _)| polygon_bounds(c)); + stroke[k] = near; + stroke.push(far); + } + } + k += 1; + } +} + +/// Face `s` (polygon `c`) cut where its depth meets that of face `wall` (convex polygon `outline`, +/// turning with sign `turn`): its piece nearer the viewer than the wall's plane and its piece +/// farther. `None` when the two do not overlap on the drawing or `s` is on one side of the wall's +/// plane wherever they do. +fn cut(s: &Facet, c: &[Point], wall: &Facet, outline: &[Point], turn: f64) -> Option<(Facet, Facet)> { + let ahead = |p: Point| Some(s.depth_at(p)? - wall.depth_at(p)?); + let overlap = clip_convex(c, outline, turn); + centroid(&overlap)?; + let tolerance = 1e-6; + let (mut nearer, mut farther) = (false, false); + for p in &overlap { + let d = ahead(*p)?; + nearer |= d > tolerance; + farther |= d < -tolerance; + } + if !(nearer && farther) { + return None; + } + let (a, b) = (s.plane?, wall.plane?); + let values: Vec = match (a.eye, b.eye) { + (Some(e), Some(f)) if e == f && a.center == b.center => { + // With perspective the depths themselves are not linear on the drawing, but where they + // meet is a line: (dA - aA e)(nzB - aB) = (dB - aB e)(nzA - aA), with aX = (nX · p) / e, + // whose difference is linear. Its sign is that of the depths' difference over the + // product of the two denominators, taken at the middle of the face. + let at = |p: Point| { + let (x, y) = (p.x - a.center.x, p.y - a.center.y); + let (aa, ab) = ((a.normal[0] * x + a.normal[1] * y) / e, (b.normal[0] * x + b.normal[1] * y) / e); + let (da, db) = (a.normal[2] - aa, b.normal[2] - ab); + ((a.offset - aa * e) * db - (b.offset - ab * e) * da, da * db) + }; + let sign = at(centroid(c)?).1.signum(); + c.iter().map(|p| at(*p).0 * sign).collect() + } + _ => c.iter().map(|p| ahead(*p)).collect::>()?, + }; + let piece = |sign: f64| { + let points = half(c, &values, sign); + let mid = centroid(&points)?; + let mut f = s.clone(); + f.depth = s.depth_at(mid).unwrap_or(s.depth); + f.contours = vec![points]; + f.outline = s.outline.as_ref().and_then(|o| clip_to_side(o, c, &values, sign)); + Some(f) + }; + Some((piece(1.0)?, piece(-1.0)?)) +} + +/// Curved outline `outline` of a face with polygon `c`, cut along the line where `values` (one per +/// point of `c`, varying linearly along each edge) is 0, keeping the side where `sign` times them is +/// at least 0. `None` when the line does not cross `c` at two points or nothing is left. +fn clip_to_side(outline: &PathData, c: &[Point], values: &[f64], sign: f64) -> Option { + let mut crossings = vec![]; + for (j, (&a, &va)) in c.iter().zip(values).enumerate() { + let k = (j + 1) % c.len(); + let (Some(&b), Some(&vb)) = (c.get(k), values.get(k)) else { continue }; + if (va >= 0.0) != (vb >= 0.0) { + crossings.push(a.lerp(b, va / (va - vb))); + } + } + let &[q0, q1] = crossings.as_slice() else { return None }; + let along = q1 - q0; + let length = along.hypot(); + if length.is_nan() || length <= 1e-9 { + return None; + } + let along = along / length; + let (inside, _) = c.iter().zip(values).find(|(_, v)| **v * sign > 0.0)?; + let mut across = Vec2::new(-along.y, along.x); + if (*inside - q0).dot(across) < 0.0 { + across = -across; + } + let bounds = outline.bounds()?.union(polygon_bounds(c)); + let reach = 2.0 * (bounds.width() + bounds.height() + (q0 - bounds.center()).hypot()) + 1.0; + let mut side = BezPath::new(); + side.move_to(q0 - along * reach); + side.line_to(q0 + along * reach); + side.line_to(q0 + along * reach + across * reach); + side.line_to(q0 - along * reach + across * reach); + side.close_path(); + let kept = vectorcraft_pathops::boolean(outline, FillRule::NonZero, &PathData::from_bezpath(&side), FillRule::NonZero, BoolOp::Intersect); + kept.bounds().is_some().then_some(kept) +} + +/// The part of polygon `p` where `sign` times `values` (one per point, varying linearly along each +/// edge) is at least 0. +fn half(p: &[Point], values: &[f64], sign: f64) -> Vec { + let mut out = vec![]; + for (j, (&a, &va)) in p.iter().zip(values).enumerate() { + let k = (j + 1) % p.len(); + let (Some(&b), Some(&vb)) = (p.get(k), values.get(k)) else { continue }; + let (sa, sb) = (va * sign, vb * sign); + if sa >= 0.0 { + out.push(a); + } + if (sa >= 0.0) != (sb >= 0.0) { + out.push(a.lerp(b, sa / (sa - sb))); + } + } + out +} + +/// `order` (back to front), when it draws a face of one of a shape's strokes and an overlapping face +/// of the same shape's fill or strokes the wrong way round for their depths where they overlap on +/// the drawing, with every pair of overlapping faces that keep a polygon and a plane +/// ([`convex_face`]: turned toward the viewer, not a planar end), of any shape, put in the order of +/// their depths where they overlap: the face nearer the viewer there goes after the other. A fill +/// wall and a face of the same shape's covering stroke ([`Paints::Stroke`]) keep their order. Each +/// face otherwise keeps its place in `order`, so a fill wall in the gap between two dashes stays +/// where its depth puts it. A cycle of such pairs is broken at the face that comes first in `order`. +/// A face drawn last (a planar end turned toward the viewer, at depth `f64::MAX`) keeps its place: +/// no face moves across it. Otherwise `order` as it is. +fn local_order(faces: &[Placed], order: Vec) -> Vec { + use std::cmp::Reverse; + // The faces of each shape that take part, from left to right for a sweep along x. + let mut shapes: Vec> = vec![]; + for (k, f) in faces.iter().enumerate() { + if f.outline.len() < 3 || f.plane.is_none() { + continue; + } + if shapes.len() <= f.member { + shapes.resize_with(f.member + 1, Vec::new); + } + shapes[f.member].push(k); + } + if !shapes.iter().any(|list| list.iter().any(|&k| faces[k].paints != Paints::Fill)) { + return order; + } + for list in &mut shapes { + list.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0)); + } + // A fill wall and a face of the same shape's covering stroke keep the order [`draw_order`] gave + // them: the stroke's solid holds the wall, which never shows. + let covered = |f: &Placed, s: &Placed| f.paints == Paints::Fill && f.side && s.paints == Paints::Stroke && f.member == s.member; + // Is there a face of a shape's strokes and a face of the same shape's fill or strokes that the + // order of their depths draws the wrong way round where they overlap? + let mut position = vec![0usize; faces.len()]; + for (at, &k) in order.iter().enumerate() { + position[k] = at; + } + let mut checks = 0usize; + let mut misordered = false; + 'shapes: for list in &shapes { + for (i, &f) in list.iter().enumerate() { + let a = &faces[f]; + for &s in list.iter().skip(i + 1).take_while(|&&s| faces[s].bounds.x0 < a.bounds.x1) { + if checks >= MAX_LOCAL_CHECKS { + break 'shapes; + } + checks += 1; + let b = &faces[s]; + if (a.paints == Paints::Fill && b.paints == Paints::Fill) || covered(a, b) || covered(b, a) || !rects_overlap(a.bounds, b.bounds) { + continue; + } + if nearer_where_overlapping(a, b).is_some_and(|a_nearer| a_nearer != (position[f] > position[s])) { + misordered = true; + break 'shapes; + } + } + } + } + if !misordered { + return order; + } + // Moving those faces must not carry them past any other face they overlap, so every pair of + // faces with a polygon and a plane that overlap goes in the order of their depths there. + let mut sweep: Vec = (0..faces.len()).filter(|&k| faces[k].outline.len() >= 3 && faces[k].plane.is_some()).collect(); + sweep.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0)); + // For each face, the faces drawn after it, and for each, how many go before it. + let mut after: Vec> = vec![vec![]; faces.len()]; + let mut waits = vec![0usize; faces.len()]; + 'pairs: for (i, &k) in sweep.iter().enumerate() { + let a = &faces[k]; + for &j in sweep.iter().skip(i + 1).take_while(|&&j| faces[j].bounds.x0 < a.bounds.x1) { + if checks >= MAX_LOCAL_CHECKS { + break 'pairs; + } + checks += 1; + let b = &faces[j]; + if !rects_overlap(a.bounds, b.bounds) { + continue; + } + let a_later = if covered(a, b) || covered(b, a) { + position[k] > position[j] + } else { + let Some(a_nearer) = nearer_where_overlapping(a, b) else { continue }; + a_nearer + }; + let (back, front) = if a_later { (j, k) } else { (k, j) }; + after[back].push(front); + waits[front] += 1; + } + } + // Each step draws the face that comes first in `order` of those whose faces before are drawn. + let mut ready: std::collections::BinaryHeap> = + (0..faces.len()).filter(|&k| waits[k] == 0).map(|k| Reverse((position[k], k))).collect(); + // The faces drawn last, in `order`: each goes only after every face before it in `order`. + let last: Vec = order.iter().copied().filter(|&k| faces[k].depth >= f64::MAX).collect(); + let mut next_last = 0usize; + let mut drawn = vec![false; faces.len()]; + let mut out = Vec::with_capacity(order.len()); + let mut first = 0usize; + while out.len() < order.len() { + while last.get(next_last).is_some_and(|&k| drawn[k]) { + next_last += 1; + } + let limit = last.get(next_last).map_or(usize::MAX, |&k| position[k]); + let k = match ready.peek() { + Some(&Reverse((at, k))) if at < limit => { + ready.pop(); + k + } + _ => { + // A cycle, or the faces left to draw before a face drawn last wait for faces after + // it: the first face in `order` not drawn yet goes next. + while order.get(first).is_some_and(|&k| drawn[k]) { + first += 1; + } + let Some(&k) = order.get(first) else { break }; + k + } + }; + if drawn[k] { + continue; + } + drawn[k] = true; + out.push(k); + for &j in &after[k] { + waits[j] = waits[j].saturating_sub(1); + if waits[j] == 0 && !drawn[j] { + ready.push(Reverse((position[j], j))); + } + } + } + out +} + +/// Whether face `a` is nearer the viewer than face `b` where their polygons overlap on the drawing, +/// measured at the middle of the overlap. `None` when they do not overlap, when either polygon is +/// not convex, or when they are at the same depth there. +fn nearer_where_overlapping(a: &Placed, b: &Placed) -> Option { + let (pa, pb) = (a.plane?, b.plane?); + let turn = convex(&b.outline)?; + convex(&a.outline)?; + let overlap = clip_convex(&a.outline, &b.outline, turn); + let mid = centroid(&overlap)?; + let (da, db) = (pa.depth_at(mid)?, pb.depth_at(mid)?); + let tolerance = 1e-6 * (1.0 + da.abs().max(db.abs())); + ((da - db).abs() > tolerance).then_some(da > db) +} + +/// The sign of the turns of convex polygon `p` (`None` when it is not convex or has no area). +fn convex(p: &[Point]) -> Option { + let n = p.len(); + let mut sign = 0.0; + for i in 0..n { + let (a, b, c) = (p[i], p[(i + 1) % n], p[(i + 2) % n]); + let cross = (b - a).cross(c - b); + if cross.abs() <= 1e-12 { + continue; + } + if sign == 0.0 { + sign = cross.signum(); + } else if cross.signum() != sign { + return None; + } + } + (sign != 0.0).then_some(sign) +} + +/// The part of polygon `subject` inside convex polygon `clip`, whose turns have sign `turn` +/// (Sutherland–Hodgman). +fn clip_convex(subject: &[Point], clip: &[Point], turn: f64) -> Vec { + let mut out = subject.to_vec(); + for i in 0..clip.len() { + if out.is_empty() { + break; + } + let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); + let side = |p: Point| (e1 - e0).cross(p - e0) * turn; + let input = std::mem::take(&mut out); + for j in 0..input.len() { + let (p, q) = (input[j], input[(j + 1) % input.len()]); + let (sp, sq) = (side(p), side(q)); + if sp >= 0.0 { + out.push(p); + } + if (sp >= 0.0) != (sq >= 0.0) { + let t = sp / (sp - sq); + out.push(p.lerp(q, t)); + } + } + } + out +} + +/// The centroid of polygon `p`, `None` when it has (almost) no area. It is measured from the +/// polygon's first point, so a small polygon far from the origin keeps its area. +fn centroid(p: &[Point]) -> Option { + let origin = *p.first()?; + let n = p.len(); + let (mut area, mut cx, mut cy) = (0.0, 0.0, 0.0); + for i in 0..n { + let (a, b) = (p[i] - origin, p[(i + 1) % n] - origin); + let cross = a.x * b.y - b.x * a.y; + area += cross; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + } + (area.abs() > 1e-9).then(|| origin + Vec2::new(cx / (3.0 * area), cy / (3.0 * area))) +} + /// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep /// their depths. const MAX_OVERLAP_CHECKS: usize = 4_000_000; @@ -742,7 +1193,8 @@ const MAX_OVERLAP_CHECKS: usize = 4_000_000; /// of a shape's fill turned toward the viewer is drawn before each face of the same shape's /// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own. /// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under -/// any other face. +/// any other face. A shape's fill faces and the faces of its other strokes then go in the order of +/// their depths where they overlap ([`local_order`]). fn draw_order(faces: &[Placed], sealed: bool) -> Vec { // Each face's depth to sort by, and whether it was moved before a stroke face at that depth. let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect(); @@ -781,7 +1233,7 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec { // A stable sort: faces at the same depth keep the order of the appearance, and a fill's side // face moved to a stroke face's depth goes before it. order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1))); - order + local_order(faces, order) } /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` @@ -806,7 +1258,9 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as /// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's -/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the +/// fill walls before the faces of its covering strokes that overlap them, and its fill's faces and +/// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the +/// order of their depths where they overlap: [`draw_order`]), the /// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask /// ([`in_runs`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and @@ -995,18 +1449,22 @@ type BuiltSurface<'s> = (Vec, Node, &'s [Scope], Vec> /// [`with_graphics`] of built surfaces, each drawn whole. fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { - with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), arts) + with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), 0, arts) } -/// `surfaces` drawn whole, each with its scopes, and the graphics `arts` ([`placed_graphics`]) each -/// right after its surface, with that surface's scopes and its opacity mask (a gradient's -/// transparency), so a graphic is hidden where its cap is. -fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { +/// `surfaces` (those from index `first`) drawn whole, each with its scopes, and the graphics `arts` +/// ([`placed_graphics`]) of those surfaces each right after its surface, with that surface's scopes +/// and its opacity mask (a gradient's transparency), so a graphic is hidden where its cap is. +fn with_graphics<'s>( + surfaces: Vec<(&'s [Scope], Arc)>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], +) -> Vec<(&'s [Scope], Arc)> { let mut out = Vec::with_capacity(surfaces.len() + arts.len()); for (k, (scopes, s)) in surfaces.into_iter().enumerate() { let mask = s.extras().mask.clone(); out.push((scopes, s)); - out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| { + out.extend(arts.iter().filter(|(at, ..)| *at == first + k).map(|(.., art)| { let mut art = art.clone(); if let Some(mask) = &mask { Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); @@ -1058,7 +1516,7 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile } } let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks); - let out = with_graphics(out, &arts); + let out = with_graphics(out, 0, &arts); return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); @@ -1085,6 +1543,43 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); + let closed = |shape: usize| shapes.get(shape).is_some_and(|s| holds_edge(&fx.id, &s.path)); + // A solid whose surface is one composite of all its facets ([`surface`]: a solid color under + // colored lights with smooth shading) has no faces to order. + let composite = |item: &AppearanceItem, f: &[Facet]| colored && paint_ref(item).color().is_some() && f.iter().any(|f| f.rgb_gradient.is_some()); + // The faces are drawn in one order (below) unless a composite draws the surfaces whole. Under + // a member's opacity mask or blending, each member's faces go in one order of their own when its + // fills and strokes are opaque, blend normally, carry no opacity mask and are not composites. + let one_order = opaque && !solids.iter().any(|(item, _, _, f, _)| composite(item, f)); + let shape_order: Vec = (0..shapes.len()) + .map(|shape| { + solids.iter().filter(|s| s.4 == shape).all(|(item, member, _, f, _)| { + let (paint, opacity, blend) = paint_of(item, *member); + opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && !composite(item, f) + }) + }) + .collect(); + let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order }; + if solids.iter().any(|s| ordered(s.4)) { + // A stroke face that passes through a fill wall or another stroke's face is cut where it + // does. The pieces count against the limits as the faces did: cutting stops before the + // faces pass MAX_OBJECT_FACES or their paint copies pass the allowance, so an effect that + // draws uncut still draws. + let counts: Vec = solids.iter().map(|(.., f, _)| f.len()).collect(); + let room = cut_room(solids.iter().map(|(item, ..)| *item), counts.iter().sum(), paint_bytes); + let mut cuts: Vec<(Paints, usize, &mut Vec)> = solids + .iter_mut() + .filter(|(.., shape)| ordered(*shape)) + .map(|(item, _, _, f, shape)| (Paints::of(item, closed(*shape)), *shape, f)) + .collect(); + cut_stroke_faces(&mut cuts, room); + for ((item, _, _, f, _), before) in solids.iter().zip(counts) { + let added = f.get(before..).unwrap_or_default(); + if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(added, paint_ref(item), colored), paint_ref(item)) { + return None; + } + } + } let arts = if !graphics_on { vec![] } else { @@ -1100,11 +1595,11 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile .collect(); placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) }; + let members: Vec = solids.iter().map(|s| s.4).collect(); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { - let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed)); - let paints = Paints::of(item, closed); + let paints = Paints::of(item, closed(shape)); let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect(); let (paint, opacity, blend) = paint_of(item, member); @@ -1112,50 +1607,86 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile (depths, art, scopes, ends) }) .collect(); - if masks { - return Some(masked(n.id, whole_surfaces(surfaces, &arts))); - } - // One order takes a plain group of faces from each solid, one face for each facet. With - // colored lights a surface can instead be one clipped composite of all its facets. A composite - // has no faces to order, so when any surface is one, every surface of the solid, masked ones - // included, draws whole in member order. - let plain = - surfaces.iter().all(|(depths, s, _, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - // A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs - // ([`in_runs`]), and each run carries the mask. With graphics as well, the surfaces draw whole, - // each graphic right after its surface. - let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); - let masked_faces = masks.iter().any(Option::is_some); - if !opaque || !plain || (masked_faces && !arts.is_empty()) { - return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); - } - let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); - for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { - if let NodeKind::Group { children, .. } = s.kind { - placed.extend(depths); - faces.extend(children.into_iter().map(|face| Some((surface, face)))); - ends.extend(e); - } - } let sealed = match fx.id.as_str() { INFLATE => inflate_options(&p).cap, CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, _ => extrude_options(&p).cap, }; + // One order takes a plain group of faces from each solid, one face for each facet. With + // colored lights a surface can instead be one clipped composite of all its facets. A composite + // has no faces to order, so it draws whole. + let plain = |(depths, s, _, _): &BuiltSurface| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()); + if masks { + // Each member's surfaces, in the scopes of its opacity mask or blending: its faces in one + // order when it is opaque and plain, otherwise each surface whole. + let mut out = vec![]; + let mut surfaces = surfaces.into_iter().zip(members).enumerate().peekable(); + while let Some((first, (s, member))) = surfaces.next() { + let mut run = vec![s]; + while let Some((_, (s, _))) = surfaces.next_if(|(_, (_, m))| *m == member) { + run.push(s); + } + let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + if ordered(member) && flat { + let scopes = run.first().map_or(&[][..], |s| s.2); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + } else { + out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + } + } + return Some(masked(n.id, out)); + } + if !opaque || !surfaces.iter().all(plain) { + return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); + } + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) +} + +/// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in +/// one order ([`draw_order`], `sealed` for solids with caps), each of the graphics `arts` of those +/// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops +/// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of +/// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. +fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { + let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); + let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); + let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); + for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { + if let NodeKind::Group { children, .. } = s.kind { + placed.extend(depths); + faces.extend(children.into_iter().map(|face| Some((surface, face)))); + ends.extend(e.into_iter().map(|end| end.map(|end| (surface, end)))); + } + } let order = draw_order(&placed, sealed); - if masked_faces { - let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); - return Some(vec![Arc::new(Node::group(n.id, in_runs(n.id, faces, &masks)))]); - } - // Each face's graphics go right after the last of the faces they are drawn on. - let last: Vec> = - arts.iter().map(|(_, face, _)| order.iter().rposition(|k| ends.get(*k).copied().flatten() == Some(*face))).collect(); - let mut children = Vec::with_capacity(order.len() + arts.len()); + // Each graphic goes right after the last of the faces it is drawn on, among the surfaces that + // share it ([`placed_graphics`]): a masked surface's own, or else the run of surfaces without a + // mask that ends at its surface. + let last: Vec> = arts + .iter() + .map(|(at, face, _)| { + let at = at - first; + let from = if masks.get(at).is_some_and(Option::is_some) { + at + } else { + masks.get(..at).and_then(|before| before.iter().rposition(Option::is_some)).map_or(0, |k| k + 1) + }; + order.iter().rposition(|k| ends.get(*k).copied().flatten().is_some_and(|(surface, end)| end == *face && (from..=at).contains(&surface))) + }) + .collect(); + let mut drawn: Vec<(usize, Arc)> = Vec::with_capacity(order.len() + arts.len()); for (at, k) in order.into_iter().enumerate() { - children.extend(faces.get_mut(k).and_then(Option::take).map(|(_, face)| face)); - children.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (.., art))| art.clone())); + drawn.extend(faces.get_mut(k).and_then(Option::take)); + drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (surface, .., art))| { + let mut art = art.clone(); + if let Some(mask) = masks.get(surface - first).cloned().flatten() { + Arc::make_mut(&mut art).extras_mut().mask = Some(mask); + } + // An index past the surfaces: the graphic goes in a run of its own, with its own mask. + (masks.len(), art) + })); } - Some(vec![Arc::new(Node::group(n.id, children))]) + if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces @@ -1311,9 +1842,10 @@ pub fn graphic_frame(doc: Option<&Document>, n: &Node, item: Option) -> O } /// [`graphic_frame`] of the 3D effect at `index` in that list, which must be a visible Extrude & -/// Bevel or Rotate. +/// Bevel, Rotate or Revolve. Revolve's frame is its unrolled side seen from the front, centered on +/// the origin, with no face maps: it sizes a new graphic and places no handles. pub fn graphic_frame_at(doc: Option<&Document>, n: &Node, item: Option, index: usize) -> Option { - frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && SOLIDS.contains(&e.id.as_str())).map(|e| (index, e))) + frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && is_three_d(&e.id)).map(|e| (index, e))) } /// Does a visible geometry effect come after the 3D effect at `index` in the effects of `n` (on the @@ -1357,6 +1889,21 @@ fn frame_of(doc: Option<&Document>, n: &Node, item: Option, pick: &Pick<' } let p = crate::merged_params(&fx.id, &fx.params); let b = union_bounds(&shapes)?; + if fx.id == crate::REVOLVE { + // Revolve's graphics lie on its unrolled side (in a group, the side of the member with the + // longest profile): the frame is the part of it seen from the front, with no planar face + // to place a handle on. + let options = crate::revolve::revolve_options(&p); + let (w, h) = shapes + .iter() + .filter(|s| crate::revolve::surface_item(&s.items).is_some()) + .filter_map(|s| vectorcraft_three_d::revolve_surface_size(&s.path, b, options)) + .fold((0.0f64, 0.0f64), |best, size| if size.1 > best.1 { size } else { best }); + if w <= 0.0 || h <= 0.0 { + return None; + } + return Some(graphics::GraphicFrame::new(Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0), None, None)); + } let view = view_options(&p); if fx.id == ROTATE { let plane = vectorcraft_three_d::plane_face_map(b, view); diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs index 6c5d8686a7..7c0087e15b 100644 --- a/crates/effects/src/solids/graphics.rs +++ b/crates/effects/src/solids/graphics.rs @@ -8,6 +8,11 @@ //! seen from the front of that face), its size as a share of the symbol's own, and its turn //! (degrees, counterclockwise). On the back cap, x runs to the right as the back is seen from //! behind, so the graphic reads the right way round there. +//! +//! On Revolve the graphics lie on the swept side surface, unrolled flat +//! ([`vectorcraft_three_d::SurfaceCell`]): x and y are the offset of the graphic's center from the +//! middle of that surface, x around the axis and y along the profile, and each face of the side +//! draws the part of the graphics that falls on it ([`surface_parts`]). use std::sync::Arc; @@ -15,7 +20,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol}; use vectorcraft_geom::kurbo::{CubicBez, ParamCurve, PathEl, Shape}; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use vectorcraft_three_d::{FaceMap, Facet, PlanarFace}; /// The effect parameter that holds the graphics. @@ -41,6 +46,33 @@ const MAX_NESTING: usize = 8; /// places: as deep as an object may be drawn. const MAX_LEVELS: usize = 4 * vectorcraft_doc::MAX_DEPTH; +/// The error for graphics on a 3D effect that takes none. +pub const NOT_TAKEN: &str = "Graphics map onto the Plane, Extrude and Revolve object types"; + +/// The buckets around the unrolled side surface that the pieces of Revolve's graphics are sorted +/// into, so each face looks only at the pieces near it. +const SURFACE_BUCKETS: usize = 256; + +/// How closely the curves of a graphic on Revolve's side follow their shape when they are cut into +/// straight pieces, in points of the unrolled surface. +const SURFACE_TOLERANCE: f64 = 0.05; + +/// How far from the middle of Revolve's unrolled side a curve of a graphic may reach, in points: +/// as far as a Revolve profile's coordinates may. +const SURFACE_REACH: f64 = 1e8; + +/// The most points the parts of Revolve's graphics draw on its faces together, a graphic's area cut +/// at every face it crosses. Past it no graphic is drawn on that surface. +const MAX_SURFACE_POINTS: usize = 2_000_000; + +/// The most points of Revolve's graphics cut at its faces together, counted before each cut whether +/// or not it keeps any of them. Past it no graphic is drawn on that surface. +const MAX_SURFACE_CUTS: usize = 32 * MAX_SURFACE_POINTS; + +/// The width of the stroke of the same paint around each opaque part of a graphic on Revolve's +/// side, which covers the seams between the parts on neighboring faces. +const SURFACE_SEAM: f64 = 0.3; + /// The size range of a graphic, as a percentage of its symbol's own size. pub const SCALE_RANGE: (f64, f64) = (1.0, 10_000.0); @@ -116,10 +148,10 @@ impl MappedGraphic { } } -/// The 3D effects that take graphics: Extrude & Bevel (its caps) and Rotate (the plane), the two -/// object types of the 3D and Materials panel with planar faces. +/// The 3D effects that take graphics: Extrude & Bevel (its caps), Rotate (the plane) and Revolve +/// (its swept side). pub fn takes_graphics(id: &str) -> bool { - id == super::EXTRUDE || id == super::ROTATE + id == super::EXTRUDE || id == super::ROTATE || id == crate::REVOLVE } /// The graphics in parameters `p`, leaving out entries that are not valid (a file may hold @@ -131,8 +163,9 @@ pub fn mapped_graphics(p: &Value) -> Vec { } } -/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel and Rotate take -/// graphics, and a plane has only a front. +/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel, Rotate and +/// Revolve take graphics; a plane has only a front, and Revolve's graphics go on its side, named +/// the front. pub fn validate(id: &str, p: &Value) -> Result<(), String> { let list = match p.get(GRAPHICS) { None => return Ok(()), @@ -143,7 +176,7 @@ pub fn validate(id: &str, p: &Value) -> Result<(), String> { return Ok(()); } if !takes_graphics(id) { - return Err("Graphics map onto planar faces: use the Extrude or Plane object type".into()); + return Err(NOT_TAKEN.into()); } if list.len() > MAX_GRAPHICS { return Err(format!("`{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics")); @@ -153,6 +186,9 @@ pub fn validate(id: &str, p: &Value) -> Result<(), String> { if id == super::ROTATE && g.face == PlanarFace::Back { return Err("A plane has one face: map its graphics onto the front".into()); } + if id == crate::REVOLVE && g.face == PlanarFace::Back { + return Err("Revolve maps graphics onto its side: map them onto the front".into()); + } } Ok(()) } @@ -483,6 +519,411 @@ pub(super) fn face_art( Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, children))], clip: true })) } +/// One area of a graphic placed on Revolve's unrolled side surface, cut into straight pieces. +struct Unrolled { + polygons: Vec>, + /// The bounds of each of `polygons`. + boxes: Vec, + bounds: Rect, + rule: FillRule, + paint: Paint, + opacity: f32, + blend: BlendMode, + /// Where the graphic's art goes on the surface: its gradients and patterns are placed with it. + place: Affine, +} + +/// The areas of `graphics` (those of the front: Revolve's side) placed on the unrolled side +/// surface, cut into straight pieces. A graphic whose symbol `doc` does not have, or whose art is +/// past the limits of [`pieces_of`], is left out. `None` when the pieces take more than +/// [`MAX_ELEMENTS`] points together. +fn unrolled(doc: &Document, graphics: &[MappedGraphic]) -> Option> { + let mut pieces = vec![]; + let mut points = 0usize; + for g in graphics.iter().filter(|g| g.face == PlanarFace::Front) { + let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; + let Ok(art) = pieces_of(doc, symbol) else { continue }; + let Some(bounds) = art.iter().filter_map(|p| p.path.bounds()).reduce(|a, b| a.union(b)) else { continue }; + let place = g.placement(bounds, Point::ZERO); + for piece in art { + let placed = place * piece.path.to_bezpath(); + let mut polygons = straight_pieces(&placed, &mut points)?; + polygons.retain(|p| p.len() >= 3 && p.iter().all(|q| q.is_finite())); + let boxes: Vec = + polygons.iter().filter_map(|polygon| polygon.iter().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b))).collect(); + let Some(bounds) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { continue }; + pieces.push(Unrolled { + polygons, + boxes, + bounds, + rule: piece.rule, + paint: piece.paint, + opacity: piece.opacity, + blend: piece.blend, + place, + }); + } + } + Some(pieces) +} + +/// The contours of `path` cut into straight pieces within [`SURFACE_TOLERANCE`], adding the points +/// made to `points`. Each curve is cut on its own and the count is checked before the next one, so +/// the work stops at the first curve past [`MAX_ELEMENTS`] points: `None` then, and for a path with +/// a coordinate of a line or a curve farther than [`SURFACE_REACH`] from the origin, which bounds +/// the pieces of one curve and the arithmetic of the cuts at the faces. +fn straight_pieces(path: &BezPath, points: &mut usize) -> Option>> { + let near = |p: &Point| p.x.abs() <= SURFACE_REACH && p.y.abs() <= SURFACE_REACH; + let mut polygons: Vec> = vec![]; + let mut current: Vec = vec![]; + let (mut start, mut last) = (Point::ZERO, Point::ZERO); + for el in path.elements() { + if *points > MAX_ELEMENTS { + return None; + } + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) if !near(&p) => return None, + PathEl::MoveTo(p) => { + polygons.push(std::mem::take(&mut current)); + current.push(p); + *points += 1; + (start, last) = (p, p); + } + PathEl::LineTo(p) => { + current.push(p); + *points += 1; + last = p; + } + PathEl::ClosePath => { + polygons.push(std::mem::take(&mut current)); + last = start; + } + PathEl::QuadTo(a, p) | PathEl::CurveTo(_, a, p) => { + let first = if let PathEl::CurveTo(b, ..) = *el { b } else { a }; + if ![last, first, a, p].iter().all(near) { + return None; + } + vectorcraft_geom::kurbo::flatten([PathEl::MoveTo(last), *el], SURFACE_TOLERANCE, |cut| { + if let PathEl::LineTo(q) = cut { + current.push(q); + *points += 1; + } + }); + last = p; + } + } + } + polygons.push(current); + (*points <= MAX_ELEMENTS).then_some(polygons) +} + +/// Do the graphics in Revolve parameters `p` fit the limits with the symbols of `doc`? Their art, +/// cut into straight pieces on the side surface, may take up to [`MAX_ELEMENTS`] points together. +pub(crate) fn validate_surface_symbols(doc: &Document, p: &Value) -> Result<(), String> { + let list = mapped_graphics(p); + if list.is_empty() || unrolled(doc, &list).is_some() { + return Ok(()); + } + Err("The graphics are too complex: simplify the symbols' art".into()) +} + +/// The parts of `graphics` that fall on each of `faces`, a revolved surface's faces in drawing +/// order, for an object with id `id`: for each face, the nodes to draw right after it. Only faces +/// of the swept side that show their outside to the viewer carry a part, so a graphic narrows +/// toward the silhouette, is hidden past it and is covered by the nearer faces drawn after it. +/// `shade` gives the paint of a solid color on a face, as the surface under it is shaded; the +/// colors of gradients and patterns are multiplied by the face's light. A graphic whose symbol +/// `doc` does not have, or that is past the limits of its art, draws nothing. `None` past +/// [`MAX_ELEMENTS`] points together before they are cut at the faces, [`MAX_SURFACE_POINTS`] +/// after, [`MAX_SURFACE_CUTS`] points cut, or when the copies of their paints in the parts take +/// `copies`, the bytes of the copies of the object's graphics so far, past Revolve's budget for +/// them: commands refuse such graphics, and a loaded file draws the surface without them. +pub(crate) fn surface_parts( + doc: Option<&Document>, + id: NodeId, + graphics: &[MappedGraphic], + faces: &[vectorcraft_three_d::Face], + shade: &dyn Fn(&vectorcraft_three_d::Face, vectorcraft_doc::color::Color) -> Paint, + copies: &mut usize, +) -> Option>>> { + let mut out: Vec>> = vec![vec![]; faces.len()]; + let Some(doc) = doc else { return Some(out) }; + let pieces = unrolled(doc, graphics)?; + let Some(all) = pieces.iter().map(|p| p.bounds).reduce(|a, b| a.union(b)) else { return Some(out) }; + // Each piece listed in the buckets of the surface's x its bounds reach across. + let width = (all.width() / SURFACE_BUCKETS as f64).max(1e-9); + let bucket = |x: f64| (((x - all.x0) / width).floor().max(0.0) as usize).min(SURFACE_BUCKETS - 1); + let mut buckets: Vec> = vec![vec![]; SURFACE_BUCKETS]; + for (k, p) in pieces.iter().enumerate() { + for b in buckets.get_mut(bucket(p.bounds.x0)..=bucket(p.bounds.x1)).into_iter().flatten() { + b.push(k); + } + } + let mut seen = vec![usize::MAX; pieces.len()]; + let mut drawn = 0usize; + let mut cut_points = 0usize; + let mut triangle_id = 0usize; + for (face, parts) in faces.iter().zip(out.iter_mut()) { + let Some(cell) = face.surface.as_ref().filter(|c| c.outward && !face.cap) else { continue }; + if cell.uv.len() != cell.page.len() || !(3..=4).contains(&cell.uv.len()) { + continue; + } + let triangles: &[[usize; 3]] = if cell.uv.len() == 3 { &[[0, 1, 2]] } else { &[[0, 1, 2], [0, 2, 3]] }; + for corners in triangles { + triangle_id += 1; + let pick = |of: &[Point]| corners.iter().map(|k| of.get(*k).copied()).collect::>>(); + let (Some(uv), Some(page)) = (pick(&cell.uv), pick(&cell.page)) else { continue }; + let (Some(map), Some(window)) = (triangle_map(&uv, &page), counterclockwise(&uv)) else { continue }; + // A face along an end cap's ring has that edge curved on the page: its parts are cut + // from a larger triangle and then to the face's own outline, so they reach the curve + // where the graphic runs past the end of the profile. A four-cornered face is cut into + // two triangles along its diagonal from corner 0 to corner 2: each takes the part of + // the outline on its own side of it. + let window = if face.outline.is_some() { grown(window, 3.0) } else { window }; + // The pieces are looked up over the whole of the window they are cut to. + let Some(reach) = window.iter().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b)) else { continue }; + if reach.x1 < all.x0 || reach.x0 > all.x1 || reach.y1 < all.y0 || reach.y0 > all.y1 { + continue; + } + // The pieces near the triangle, each once, in the graphics' drawing order. + let mut near = vec![]; + for b in buckets.get(bucket(reach.x0)..=bucket(reach.x1)).into_iter().flatten() { + for &k in b { + let Some(slot) = seen.get_mut(k) else { continue }; + if *slot != triangle_id { + *slot = triangle_id; + near.push(k); + } + } + } + near.retain(|k| { + pieces + .get(*k) + .is_some_and(|p| p.bounds.x1 >= reach.x0 && p.bounds.x0 <= reach.x1 && p.bounds.y1 >= reach.y0 && p.bounds.y0 <= reach.y1) + }); + if near.is_empty() { + continue; + } + near.sort_unstable(); + let own = corners.iter().find(|k| **k != 0 && **k != 2).and_then(|k| cell.page.get(*k)).filter(|_| triangles.len() == 2); + let rim = face.outline.as_ref().map(|outline| match (own, cell.page.first(), cell.page.get(2)) { + (Some(&toward), Some(&a), Some(&b)) => { + let side = side_of(a, b, toward); + vectorcraft_pathops::boolean(outline, FillRule::NonZero, &side, FillRule::NonZero, vectorcraft_pathops::BoolOp::Intersect) + } + _ => outline.clone(), + }); + let rim = rim.as_ref(); + for k in near { + let Some(piece) = pieces.get(k) else { continue }; + let mut path = BezPath::new(); + // Each contour is cut only where its own bounds reach the triangle. + for (polygon, b) in piece.polygons.iter().zip(&piece.boxes) { + if b.x1 < reach.x0 || b.x0 > reach.x1 || b.y1 < reach.y0 || b.y0 > reach.y1 { + continue; + } + cut_points = cut_points.saturating_add(polygon.len()); + if cut_points > MAX_SURFACE_CUTS { + return None; + } + let cut = clip_to(polygon, &window); + let Some((first, rest)) = cut.split_first() else { continue }; + path.move_to(map * *first); + rest.iter().for_each(|q| path.line_to(map * *q)); + path.close_path(); + drawn = drawn.saturating_add(cut.len()); + } + if drawn > MAX_SURFACE_POINTS { + return None; + } + if path.elements().is_empty() { + continue; + } + let (path, rule) = match rim { + Some(outline) => { + let cut = vectorcraft_pathops::boolean( + &PathData::from_bezpath(&path), + piece.rule, + outline, + FillRule::NonZero, + vectorcraft_pathops::BoolOp::Intersect, + ); + (cut.to_bezpath(), FillRule::NonZero) + } + None => (path, piece.rule), + }; + if path.elements().is_empty() { + continue; + } + // Each part copies its paint into its fill, and into the stroke over its seams when + // it takes one. + if !crate::revolve::paint_copies_add(copies, 1 + usize::from(takes_seam(piece)), &piece.paint) { + return None; + } + parts.push(Arc::new(surface_piece(id, piece, (path, rule), map, face, shade))); + } + } + } + Some(out) +} + +/// The node of the part `path` (on the page, filled under `rule`) of `piece`, which `map` takes +/// from the unrolled surface onto `face`. +fn surface_piece( + id: NodeId, + piece: &Unrolled, + (path, rule): (BezPath, FillRule), + map: Affine, + face: &vectorcraft_three_d::Face, + shade: &dyn Fn(&vectorcraft_three_d::Face, vectorcraft_doc::color::Color) -> Paint, +) -> Node { + let gradient_map = map * piece.place; + // Under several lights a face's light varies across it in each channel: a solid color draws + // as the face's own lit art in that color, clipped to the part. An opaque part also draws that + // art clipped to a band of the seam stroke's width around its edges, as the other parts take + // a stroke over their seams. + if let (Some(c), Some(light)) = (piece.paint.color().filter(|_| matches!(piece.paint, Paint::Solid { .. })), face.rgb_gradient) { + let lit = Arc::new(crate::lighting::rgb_gradient_art(id, &face.path(), &Paint::solid(c), light)); + let clipped = |area: &BezPath, rule: FillRule| { + let mut clip = Node::path(id, PathData::from_bezpath(area), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut clip.kind { + *r = rule; + } + Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), lit.clone()], clip: true }) + }; + let mut group = clipped(&path, rule); + if takes_seam(piece) { + let style = vectorcraft_geom::kurbo::Stroke::new(SURFACE_SEAM).with_join(vectorcraft_geom::kurbo::Join::Round); + let band = vectorcraft_geom::kurbo::stroke(path.iter(), &style, &vectorcraft_geom::kurbo::StrokeOpts::default(), SURFACE_TOLERANCE); + group = Node::group(id, vec![Arc::new(group), Arc::new(clipped(&band, FillRule::NonZero))]); + } + group.opacity = piece.opacity; + group.blend = piece.blend; + return group; + } + let mut paint = piece.paint.clone(); + if let Paint::Pattern { xf, .. } = &mut paint { + *xf = gradient_map * *xf; + } + let solid = paint.color().filter(|_| matches!(paint, Paint::Solid { .. })); + let mut fill = FillLayer::new(paint); + fill.opacity = piece.opacity; + fill.blend = piece.blend; + let mut appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }; + appearance.transform_gradients(gradient_map); + for item in appearance.items.iter_mut() { + if let AppearanceItem::Fill(f) = item { + match solid { + Some(c) => f.paint = shade(face, c), + None => { + vectorcraft_doc::recolor::map_paint(&mut f.paint, &|c| crate::lighting::shade(c, face.illumination)); + } + } + } + } + // Parts on neighboring faces meet edge to edge: an opaque part takes a narrow stroke of its + // paint over the seam, as the faces do. + if takes_seam(piece) + && let Some(AppearanceItem::Fill(f)) = appearance.items.first() + { + let mut seam = vectorcraft_doc::StrokeLayer::new(f.paint.clone(), SURFACE_SEAM); + seam.join = vectorcraft_doc::LineJoin::Round; + appearance.items.push(AppearanceItem::Stroke(seam)); + } + let mut node = Node::path(id, PathData::from_bezpath(&path), appearance); + if let NodeKind::Path { rule: r, .. } = &mut node.kind { + *r = rule; + } + node +} + +/// Does each part of `piece` take a stroke of its paint over its seams? Only an opaque part does: +/// the overlap of a transparent one, of one whose gradient has a stop or a freeform point that is +/// not fully opaque, or of a pattern, whose tile may hold transparent art, would show. +fn takes_seam(piece: &Unrolled) -> bool { + let clear = match &piece.paint { + Paint::Gradient(g) => { + g.gradient.stops.iter().any(|s| s.opacity < 1.0) || g.freeform.as_ref().is_some_and(|f| f.points.iter().any(|p| p.opacity < 1.0)) + } + Paint::Pattern { .. } => true, + _ => false, + }; + piece.opacity >= 1.0 && piece.blend == BlendMode::Normal && !clear +} + +/// A region far larger than the face on the side of the line through `a` and `b` that `toward` is +/// on: a rectangle with one edge along the line, reaching a thousand times the line's length past +/// its ends and away from it. +fn side_of(a: Point, b: Point, toward: Point) -> PathData { + let along = b - a; + let mut across = Vec2::new(-along.y, along.x); + if across.dot(toward - a) < 0.0 { + across = -across; + } + let (far, wide) = (across * 1000.0, along * 1000.0); + let mut out = BezPath::new(); + out.move_to(a - wide); + out.line_to(b + wide); + out.line_to(b + wide + far); + out.line_to(a - wide + far); + out.close_path(); + PathData::from_bezpath(&out) +} + +/// Triangle `t` scaled by `factor` about its centroid, wound as it was. +fn grown(t: [Point; 3], factor: f64) -> [Point; 3] { + let c = Point::new((t[0].x + t[1].x + t[2].x) / 3.0, (t[0].y + t[1].y + t[2].y) / 3.0); + t.map(|p| c + (p - c) * factor) +} + +/// The affine map that takes the corners `from` of a triangle onto `to`. `None` for a triangle +/// with no area or a map that is not finite. +fn triangle_map(from: &[Point], to: &[Point]) -> Option { + let ([u0, u1, u2], [p0, p1, p2]) = (<[Point; 3]>::try_from(from).ok()?, <[Point; 3]>::try_from(to).ok()?); + let (a, b) = (u1 - u0, u2 - u0); + let det = a.x * b.y - b.x * a.y; + if det.abs() <= 1e-12 * (a.hypot2() + b.hypot2()).max(1e-300) { + return None; + } + let (c, d) = (p1 - p0, p2 - p0); + let m = [(c.x * b.y - d.x * a.y) / det, (c.y * b.y - d.y * a.y) / det, (d.x * a.x - c.x * b.x) / det, (d.y * a.x - c.y * b.x) / det]; + let xf = Affine::new([m[0], m[1], m[2], m[3], p0.x - m[0] * u0.x - m[2] * u0.y, p0.y - m[1] * u0.x - m[3] * u0.y]); + xf.as_coeffs().iter().all(|v| v.is_finite()).then_some(xf) +} + +/// Triangle `t` wound counterclockwise (y up), as [`clip_to`] reads its window. `None` without +/// three corners. +fn counterclockwise(t: &[Point]) -> Option<[Point; 3]> { + let [a, b, c] = <[Point; 3]>::try_from(t).ok()?; + Some(if (b - a).cross(c - a) < 0.0 { [a, c, b] } else { [a, b, c] }) +} + +/// The part of `polygon` inside the convex `window` (one step of Sutherland and Hodgman's clip for +/// each of its edges). A polygon that is not convex keeps its area, with edges along the window's +/// sides where it leaves and comes back. +fn clip_to(polygon: &[Point], window: &[Point; 3]) -> Vec { + let mut out = polygon.to_vec(); + for k in 0..3 { + let (a, b) = (window[k], window[(k + 1) % 3]); + let side = |p: Point| (b - a).cross(p - a); + let input = std::mem::take(&mut out); + for (i, &p) in input.iter().enumerate() { + let q = input[(i + 1) % input.len()]; + let (sp, sq) = (side(p), side(q)); + if sp >= 0.0 { + out.push(p); + } + if (sp >= 0.0) != (sq >= 0.0) { + out.push(p.lerp(q, sp / (sp - sq))); + } + } + if out.len() < 3 { + return vec![]; + } + } + out +} + /// The bounds of the art symbol `name` of `doc` draws as a graphic, in the symbol's coordinates. pub(super) fn art_bounds(doc: &Document, name: &str) -> Option { let symbol = doc.symbols.iter().find(|s| s.name == name)?; diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index b4e012f977..5126b76660 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -253,6 +253,468 @@ fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() { } } +/// The faces of the solids effect `id` with `params` builds for `n`, in the order they are drawn, +/// as [`surfaces_for`] orders them, each with the index of the part ([`solid_parts`]) it was built +/// from and whether it is a stroke's. +fn drawn_parts(n: &Node, id: &str, params: &Value) -> Vec<(Facet, usize, bool)> { + let (path, rule) = profile(n).unwrap(); + let shapes = [Profile { path, rule, items: solid_paints(n), opacity: 1.0, scopes: vec![] }]; + let p = crate::merged_params(id, params); + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + let mut solids = vec![]; + for (part, ((item, _, _, _, _, member), f)) in parts.iter().zip(built_solids(id, &p, &parts, bounds, MAX_OBJECT_FACES)).enumerate() { + let Some(f) = f.unwrap() else { continue }; + let closed = holds_edge(id, &shapes[*member].path); + solids.push((Paints::of(item, closed), *member, f, part, matches!(item, AppearanceItem::Stroke(_)))); + } + let mut cuts: Vec<(Paints, usize, &mut Vec)> = solids.iter_mut().map(|(paints, member, f, _, _)| (*paints, *member, f)).collect(); + cut_stroke_faces(&mut cuts, MAX_OBJECT_FACES); + let (mut placed, mut all) = (vec![], vec![]); + for (paints, member, f, part, stroke) in solids { + for facet in f { + placed.push(Placed::of(&facet, member, paints)); + all.push((facet, part, stroke)); + } + } + let sealed = match id { + INFLATE => inflate_options(&p).cap, + CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, + _ => extrude_options(&p).cap, + }; + draw_order(&placed, sealed).into_iter().map(|k| all[k].clone()).collect() +} + +/// Is `p` inside the polygon of `f` (nonzero winding)? +fn facet_contains(f: &Facet, p: Point) -> bool { + let mut winding = 0i32; + for c in &f.contours { + for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { + let cross = (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y); + if a.y <= p.y && b.y > p.y && cross > 0.0 { + winding += 1; + } else if a.y > p.y && b.y <= p.y && cross < 0.0 { + winding -= 1; + } + } + } + winding != 0 +} + +/// The points of a 1 pt grid over `faces` (drawn in that order, each with whether it is a stroke's) +/// where the face drawn on top is the fill's while a face of the stroke turned toward the viewer is +/// nearer the viewer there by more than 0.5 pt, or the other way round. Points within 0.3 pt of an +/// edge are left out. +fn wrong_on_top(faces: &[(Facet, bool)]) -> Vec { + let polygons: Vec<&Facet> = faces.iter().map(|(f, _)| f).collect(); + let extent = + polygons.iter().flat_map(|f| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); + let mut wrong = vec![]; + let mut y = extent.y0.floor() + 0.5; + while y < extent.y1 { + let mut x = extent.x0.floor() + 0.5; + while x < extent.x1 { + let p = Point::new(x, y); + let well_inside = |f: &Facet| { + [(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy))) + }; + let top = faces.iter().rposition(|(f, _)| facet_contains(f, p)); + if let Some((f, stroke)) = top.and_then(|k| faces.get(k)).filter(|(f, _)| !f.away && well_inside(f)) { + let nearest = |of_stroke: bool| { + faces.iter().filter(|(g, s)| *s == of_stroke && !g.away && well_inside(g)).filter_map(|(g, _)| g.depth_at(p)).reduce(f64::max) + }; + if let (Some(ds), Some(df)) = (nearest(true), nearest(false)) + && (ds - df).abs() > 0.5 + && (ds > df) != *stroke + && f.depth_at(p).is_some() + { + wrong.push(p); + } + } + x += 1.0; + } + y += 1.0; + } + wrong +} + +/// The points of a 1 pt grid over `faces` (drawn in that order) where the face drawn on top is +/// farther from the viewer than another face turned toward the viewer, of any part, by more than +/// 0.5 pt. Points within 0.3 pt of an edge, and faces without a plane, are left out. +fn misdrawn(faces: &[(Facet, usize, bool)]) -> Vec { + let extent = + faces.iter().flat_map(|(f, ..)| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); + let mut wrong = vec![]; + let mut y = extent.y0.floor() + 0.5; + while y < extent.y1 { + let mut x = extent.x0.floor() + 0.5; + while x < extent.x1 { + let p = Point::new(x, y); + let well_inside = |f: &Facet| { + [(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy))) + }; + let top = faces.iter().rposition(|(f, ..)| facet_contains(f, p)).and_then(|k| faces.get(k)).filter(|(f, ..)| !f.away && well_inside(f)); + if let Some(on_top) = top.and_then(|(f, ..)| f.depth_at(p)) { + let nearest = faces.iter().filter(|(g, ..)| !g.away && well_inside(g)).filter_map(|(g, ..)| g.depth_at(p)).reduce(f64::max); + if nearest.is_some_and(|d| d - on_top > 0.5) { + wrong.push(p); + } + } + x += 1.0; + } + y += 1.0; + } + wrong +} + +/// The faces of a shape's fill and strokes are drawn in the order of their depths where they +/// overlap: on a turned C, with or without perspective, under a stroke that does not cover the +/// fill's whole edge (dashed, with a width profile, or with a brush), under a plain stroke and +/// under two strokes whose faces pass through each other, no point shows a face farther from the +/// viewer than another, in Extrude & Bevel and Extrude & Bevel (Classic), and only a few in +/// Inflate, where curved puffs cross. +#[test] +fn fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap() { + use vectorcraft_doc::{Dash, WidthProfile}; + let views = [ + json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), + json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10}), + json!({"rotationX": -30, "rotationY": 50, "rotationZ": 5, "perspectiveAngle": 40}), + json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}), + ]; + type Style<'a> = (&'a str, &'a dyn Fn(&mut Node)); + let first = |n: &mut Node, set: &dyn Fn(&mut StrokeLayer)| { + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + set(s); + } + }; + let dash = |pattern: Vec, offset: f64| Some(Dash { pattern, offset, align_corners: false }); + let styles: [Style; 6] = [ + ("plain", &|_| {}), + ("dashed", &|n| first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0))), + ("profiled", &|n| first(n, &|s| s.profile = Some(WidthProfile::lens()))), + ("brushed", &|n| first(n, &|s| s.brush = Some("brush".into()))), + ("dashed with perspective", &|n| first(n, &|s| s.dash = dash(vec![20.0, 6.0], 3.0))), + ("two strokes", &|n| { + first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0)); + let mut wide = StrokeLayer::new(Paint::solid(RED), 14.0); + wide.profile = Some(WidthProfile::lens()); + n.appearance.items.push(AppearanceItem::Stroke(wide)); + }), + ]; + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + for (v, view) in views.iter().enumerate() { + for (style, set) in &styles { + // The last two views, for the plain and the two strokes only. + if v >= 2 && !["plain", "two strokes"].contains(style) { + continue; + } + let mut params = view.clone(); + params["depth"] = json!(60); + if style.contains("perspective") { + params["perspectiveAngle"] = json!(40); + } + let mut n = stroked(&letter_c()); + set(&mut n); + let parts = drawn_parts(&n, id, ¶ms); + let faces: Vec<(Facet, bool)> = parts.iter().map(|(f, _, s)| (f.clone(), *s)).collect(); + assert!(faces.iter().any(|(_, s)| *s) && faces.iter().any(|(_, s)| !*s), "{id} {view} {style}: faces of both"); + // Inflate's curved puffs cross each other along curves its flat facets only follow + // roughly: a few points next to those crossings may still show the farther face. + let allowed = if id == INFLATE { 5 } else { 0 }; + let wrong = wrong_on_top(&faces); + assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points drawn in the wrong paint, first {:?}", wrong.len(), wrong.first()); + let wrong = misdrawn(&parts); + assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points show a farther face, first {:?}", wrong.len(), wrong.first()); + } + } + } +} + +/// The paths of `art`'s faces in drawing order, leaving out the graphics (clip groups). +fn face_paths(art: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Group { clip: true, .. } => {} + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)), + _ => out.extend(n.path_data().cloned()), + } + } + let mut out = vec![]; + walk(art, &mut out); + out +} + +/// A member of a 3D group with an opacity mask or a blending mode of its own draws its fill's and +/// its strokes' faces in one order inside that mask, the order the same member draws them in +/// without it, its dashes cut where they pass through the fill's walls. A member whose stroke is +/// translucent still draws each surface whole. +#[test] +fn a_masked_members_fill_and_stroke_faces_go_in_one_order() { + let mut member = stroked(&letter_c()); + if let Some(AppearanceItem::Stroke(s)) = member.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + let solid = |member: Node| { + let mut g = Node::group(NodeId(2), vec![Arc::new(member)]); + g.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}))); + solid_art(&g).unwrap() + }; + let cover = Node::path( + NodeId(900), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + let mut masked = member.clone(); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true))); + let mut multiplied = member.clone(); + multiplied.blend = BlendMode::Multiply; + let unmasked = face_paths(&solid(member.clone())); + for (what, scoped) in [("masked", masked.clone()), ("multiplied", multiplied)] { + let art = solid(scoped); + let mut scopes = 0; + art.walk(&mut |n| scopes += usize::from(n.extras().mask.is_some() || n.blend == BlendMode::Multiply)); + assert!(scopes > 0, "{what}: drawn in its scope"); + assert_eq!(face_paths(&art), unmasked, "{what}: the faces in the member's one order"); + } + // A translucent stroke keeps its surface whole: the fill's faces, then the stroke's. + if let Some(AppearanceItem::Stroke(s)) = masked.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.opacity = 0.5; + } + let art = solid(masked); + let mut groups = vec![]; + art.walk(&mut |n| { + if n.opacity == 0.5 { + groups.push(n.children().map_or(0, Vec::len)); + } + }); + assert_eq!(groups.len(), 1, "the stroke's surface is one translucent group"); +} + +/// Under a light that is not white, Inflate in gradients, whose faces are drawn one by one, draws the +/// fill's and the dashed stroke's faces in one order, as it does under a white light. A solid color, +/// drawn as one composite of each surface's facets, still draws each surface whole. +#[test] +fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + // How many surfaces the art draws, and how many faces the first holds. + let drawn = |n: &Node, lights: Option| { + let mut n = n.clone(); + let mut p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + if let Some(lights) = lights { + p["lights"] = lights; + } + n.appearance.effects.push(fx(INFLATE, p)); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + (surfaces.len(), surfaces[0].children().map_or(0, Vec::len)) + }; + let colored = json!([{"color": "#ff8080"}]); + let white = drawn(&n, None); + assert_eq!(white.0, 1, "one order"); + let cut = drawn_parts(&n, INFLATE, &json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})).len(); + assert_eq!(white.1, cut, "the white light's faces, cut"); + assert_eq!(drawn(&n, Some(colored.clone())).0, 1, "one order under the colored light"); + // A solid color under the colored light: a composite for each of the fill and the stroke. + let solid = stroked(&letter_c()); + assert_eq!(drawn(&solid, Some(colored)).0, 2); +} + +/// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they +/// overlap is cut along the line where their depths meet, into the piece in front of the wall and +/// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke +/// and a cut past the room left are left whole. +#[test] +fn a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet() { + let facet = |x: f64, normal: [f64; 3], offset: f64| Facet { + contours: vec![vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]], + depth: 0.0, + brightness: 1.0, + illumination: [1.0; 3], + normal, + rgb_gradient: None, + outline: None, + side: true, + away: false, + end: None, + plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }), + }; + let wall = facet(0.0, [0.0, 0.0, 1.0], 0.0); + // z = x - 8: behind the wall left of x = 8, in front of it right of it. + let r = std::f64::consts::FRAC_1_SQRT_2; + let mut through = facet(5.0, [-r, 0.0, r], -8.0 * r); + // Its curved outline: the top edge bows up to y = -2. + let mut curved = BezPath::new(); + curved.move_to((5.0, 0.0)); + curved.curve_to((8.0, -8.0 / 3.0), (12.0, -8.0 / 3.0), (15.0, 0.0)); + curved.line_to((15.0, 10.0)); + curved.line_to((5.0, 10.0)); + curved.close_path(); + through.outline = Some(PathData::from_bezpath(&curved)); + let extent = |f: &Facet| polygon_bounds(&f.contours[0]); + let cut_with = |paints: Paints, member: usize, room: usize| { + let (mut fill, mut stroke) = (vec![wall.clone()], vec![through.clone()]); + let mut solids: Vec<(Paints, usize, &mut Vec)> = vec![(Paints::Fill, 0, &mut fill), (paints, member, &mut stroke)]; + cut_stroke_faces(&mut solids, room); + stroke + }; + let pieces = cut_with(Paints::Other, 0, 10); + assert_eq!(pieces.len(), 2); + let (near, far) = (extent(&pieces[0]), extent(&pieces[1])); + assert!((near.x0 - 8.0).abs() < 1e-9 && near.x1 == 15.0 && far.x0 == 5.0 && (far.x1 - 8.0).abs() < 1e-9, "{near:?} {far:?}"); + assert!(pieces[0].depth > 0.0 && pieces[1].depth < 0.0); + // Each piece keeps its side of the curved outline, the bowed edge included. + let curve = |f: &Facet| f.outline.as_ref().and_then(PathData::bounds).unwrap(); + let (near, far) = (curve(&pieces[0]), curve(&pieces[1])); + assert!((near.x0 - 8.0).abs() < 1e-6 && (near.x1 - 15.0).abs() < 1e-6 && near.y0 < -1.5, "{near:?}"); + assert!((far.x0 - 5.0).abs() < 1e-6 && (far.x1 - 8.0).abs() < 1e-6 && far.y0 < -1.5, "{far:?}"); + let whole = std::slice::from_ref(&through); + assert_eq!(cut_with(Paints::Other, 1, 10), whole, "another shape's stroke"); + assert_eq!(cut_with(Paints::Stroke, 0, 10), whole, "a covering stroke"); + assert_eq!(cut_with(Paints::Other, 0, 0), whole, "no room"); + let (mut fill, mut stroke) = (vec![wall.clone()], vec![facet(5.0, [0.0, 0.0, 1.0], 1.0)]); + cut_stroke_faces(&mut [(Paints::Fill, 0, &mut fill), (Paints::Other, 0, &mut stroke)], 10); + assert_eq!(stroke.len(), 1, "in front of the wall wherever they overlap"); + // Of two of a shape's strokes whose faces pass through each other, the later stroke's face is + // cut against the earlier one's; a covering stroke's face is never cut, and the other stroke's + // face is cut against it. Two shapes' strokes are left whole. + for (case, (first, second, cut_first)) in + [(Paints::Other, Paints::Other, false), (Paints::Stroke, Paints::Other, false), (Paints::Other, Paints::Stroke, true)].into_iter().enumerate() + { + let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]); + cut_stroke_faces(&mut [(first, 0, &mut a), (second, 0, &mut b)], 10); + assert_eq!((a.len(), b.len()), if cut_first { (2, 1) } else { (1, 2) }, "case {case}"); + } + let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]); + cut_stroke_faces(&mut [(Paints::Other, 0, &mut a), (Paints::Other, 1, &mut b)], 10); + assert_eq!((a.len(), b.len()), (1, 1), "two shapes"); +} + +/// A small polygon far from the origin keeps its area and centroid. +#[test] +fn a_small_polygon_far_away_has_a_centroid() { + let far = 100_000_000.0; + let square = [Point::new(far, far), Point::new(far + 1.0, far), Point::new(far + 1.0, far + 1.0), Point::new(far, far + 1.0)]; + let c = centroid(&square).unwrap(); + assert!((c.x - far - 0.5).abs() < 1e-6 && (c.y - far - 0.5).abs() < 1e-6, "{c:?}"); + assert!(centroid(&[]).is_none() && centroid(&square[..2]).is_none()); +} + +/// Cutting adds no more faces than the object's face limit and the paint-copy allowance leave room +/// for, so an effect that draws with its faces uncut still draws. +#[test] +fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { + let items = stroked(&letter_c()).appearance.items.clone(); + let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); + let mut one = 0usize; + crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + let max = crate::revolve::MAX_PAINT_COPIES; + assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); + assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); + assert_eq!(cut_room(items.iter(), 10, max - 3 * one), 3); + assert_eq!(cut_room(items.iter(), 10, max + 1), 0); +} + +/// Where a shape's fill wall and a face of its stroke that leaves gaps overlap, the one nearer the +/// viewer there is drawn after the other, whichever their depths put first; a stroke face that +/// overlaps no fill wall keeps its place. +#[test] +fn local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + let at = |depth: f64, x: f64, z: f64, paints: Paints| Placed { + depth, + side: true, + away: false, + bounds: Rect::new(x, 0.0, x + 10.0, 10.0), + member: 0, + paints, + outline: square(x), + plane: Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }), + }; + // 0 a fill wall at z 0; 1 a stroke face over half of it, nearer, sorted before it; 2 a stroke + // face elsewhere; 3 a stroke face over it, farther, sorted after it. + let faces = + [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 1.0, Paints::Other), at(1.0, 20.0, 1.0, Paints::Other), at(6.0, 2.0, -1.0, Paints::Other)]; + assert_eq!(draw_order(&faces, true), [2, 3, 0, 1]); + assert_eq!(draw_order(&faces, false), [2, 3, 0, 1]); + // A covering stroke's faces keep the wall's own rule. + let covering: Vec = faces + .iter() + .map(|f| Placed { paints: if f.paints == Paints::Other { Paints::Stroke } else { f.paints }, outline: vec![], plane: None, ..*f }) + .collect(); + assert_eq!(draw_order(&covering, false), [2, 1, 0, 3]); + // Another shape's stroke, and a stroke face at the same depth where they overlap, move nothing. + let other_shape: Vec = + faces[..3].iter().map(|f| Placed { member: usize::from(f.paints == Paints::Other), outline: f.outline.clone(), ..*f }).collect(); + assert_eq!(draw_order(&other_shape, true), [2, 1, 0]); + let level = [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 0.0, Paints::Other)]; + assert_eq!(draw_order(&level, true), [1, 0]); + // A stroke face moved after its fill's wall does not pass a nearer face of another shape that + // it overlaps: 0 a stroke face, 1 another shape's face in front of both, 2 the fill's wall. + let in_front = Placed { member: 1, ..at(2.0, 5.0, 2.0, Paints::Fill) }; + let shapes = [at(1.0, 5.0, 1.0, Paints::Other), in_front, at(3.0, 0.0, 0.0, Paints::Fill)]; + assert_eq!(draw_order(&shapes, true), [2, 0, 1]); + // A shape with a covering stroke as well keeps its fill wall before the covering stroke's face, + // even where that face is the farther: 0 the fill's wall, 1 the covering stroke's face behind + // it, 2 the other stroke's face in front. + let both = [at(3.0, 0.0, 0.0, Paints::Fill), at(3.0, 0.0, -1.0, Paints::Stroke), at(2.0, 5.0, 1.0, Paints::Other)]; + assert_eq!(draw_order(&both, true), [0, 1, 2]); + // Faces of a shape's strokes go in the order of their depths where they overlap too, with no + // fill face among them: 1 is nearer than 0 there, though sorted before it. + for (case, (back, front)) in + [(Paints::Other, Paints::Other), (Paints::Stroke, Paints::Other), (Paints::Stroke, Paints::Stroke)].into_iter().enumerate() + { + let strokes = [at(5.0, 0.0, 0.0, back), at(2.0, 5.0, 1.0, front)]; + assert_eq!(draw_order(&strokes, true), [0, 1], "case {case}"); + } +} + +/// A cycle of overlapping stroke faces is broken at the face that comes first by depth, and a cap +/// drawn last stays after every wall: 0 at x 0 to 10, flat at z 0; 1 at x 4 to 14, z = x - 8; 2 at +/// x 8 to 18, flat at z 1. Where they overlap, 0 is nearer than 1, 1 nearer than 2 and 2 nearer +/// than 0. 3 is the cap. +#[test] +fn a_depth_cycle_keeps_the_cap_last() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + let at = |depth: f64, x: f64, normal: [f64; 3], offset: f64| Placed { + depth, + side: true, + away: false, + bounds: Rect::new(x, 0.0, x + 10.0, 10.0), + member: 0, + paints: Paints::Other, + outline: square(x), + plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }), + }; + let cap = Placed { + depth: f64::MAX, + side: false, + away: false, + bounds: Rect::new(0.0, 0.0, 18.0, 10.0), + member: 0, + paints: Paints::Other, + outline: vec![], + plane: None, + }; + let faces = [at(1.0, 0.0, [0.0, 0.0, 1.0], 0.0), at(2.0, 4.0, [-1.0, 0.0, 1.0], -8.0), at(3.0, 8.0, [0.0, 0.0, 1.0], 1.0), cap]; + assert_eq!(draw_order(&faces, true), [0, 2, 1, 3]); +} + /// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a /// face of the same shape's stroke drawn before it: the wall then goes just before that face. A /// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an @@ -266,6 +728,8 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { bounds: Rect::new(x, 0.0, x + 10.0, 10.0), member, paints, + outline: vec![], + plane: None, }; // 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke // wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a @@ -292,6 +756,9 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { }; assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke); assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape"); + let closed = profile(&stroked(&letter_c())).unwrap().0; + assert!(holds_edge(EXTRUDE, &closed) && holds_edge(CLASSIC_EXTRUDE, &closed)); + assert!(!holds_edge(INFLATE, &closed), "an inflated fill can rise in front of its stroke"); assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width"); let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false }; assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes"); diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index aa73535fc5..e0935a43e7 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -505,7 +505,10 @@ fn graphics_are_checked() { } assert!(check(ROTATE, json!([{"symbol": "Mark", "face": "back"}])).is_err()); assert!(check(INFLATE, json!([{"symbol": "Mark"}])).is_err()); - assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err()); + // Revolve takes graphics on its side, named the front; Revolve (Classic) takes none. + assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_ok()); + assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark", "face": "back"}]})).is_err()); + assert!(validate_three_d_params(crate::CLASSIC_REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err()); // A loaded file's invalid entries are left out when drawn. assert_eq!(mapped_graphics(&json!({"graphics": [{"symbol": "Mark"}, {"face": "back"}, 3]})).len(), 1); } @@ -620,6 +623,110 @@ fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); } +/// The paths of `art`'s faces in drawing order (a face lit by a colored light is a clip group: its +/// clipping path), leaving out the graphics (clip groups of solid-color paths). +fn face_paths(art: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Group { children, clip: true } => { + let graphic = children.get(1).is_some_and(|inner| match &inner.kind { + NodeKind::Group { children: paths, clip: false } => paths.iter().all(|p| { + p.path_data().is_some() + && p.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint.color().is_some())) + }), + _ => false, + }); + if !graphic { + out.extend(children.first().and_then(|c| c.path_data()).cloned()); + } + } + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)), + _ => out.extend(n.path_data().cloned()), + } + } + let mut out = vec![]; + walk(art, &mut out); + out +} + +/// A turned solid in a gradient with transparent stops under a colored light, with a dashed stroke +/// and a graphic, draws its fill's and its stroke's faces in the one order it draws them in without +/// the graphic, and the graphic keeps the cap's mask and goes after the fill's cap, not the stroke's. +#[test] +fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); + let solid = |graphics: Value| { + let mut n = object( + EXTRUDE, + json!({"rotationX": -20.0, "rotationY": -40.0, "depth": 60.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": graphics}), + ); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient.clone())); + } + // A gradient stroke: a solid color under the colored light would be one composite. + let blues = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.5))], + ); + let mut stroke = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(blues))), 6.0); + stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + n.appearance.items.push(AppearanceItem::Stroke(stroke)); + solid_art_in(Some(&d), &n).unwrap() + }; + let with = solid(json!([{"symbol": "Mark"}])); + let without = solid(json!([])); + assert!(face_paths(&without).len() > 20, "the faces are found"); + assert_eq!(face_paths(&with), face_paths(&without), "the faces in one order"); + // The graphic is drawn on the fill's cap and on the stroke's (the clip groups holding the + // symbol's red square); the fill's, whose surface carries the mask, carries it too. + let mut masked = vec![]; + with.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let [_, inner] = children.as_slice() + && let NodeKind::Group { children: paths, clip: false } = &inner.kind + && paths.iter().any(|p| { + p.appearance.items.iter().any(|i| match i { + AppearanceItem::Fill(f) => f.paint.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3), + AppearanceItem::Stroke(_) => false, + }) + }) + { + masked.push(c.extras().mask.is_some()); + } + }); + masked.sort_unstable(); + assert_eq!(masked, [false, true], "the fill's graphic carries the cap's mask"); + // The fill's graphic goes right after the fill's cap, so the dashes on the cap's edge, drawn + // after it, cover it. Drawn in order: the fill's graphic (the masked clip group), and each face, + // the fill's or the stroke's by its gradient's first stop (red or blue), with its bounds. + let mut drawn: Vec<(&str, Rect)> = vec![]; + with.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && c.extras().mask.is_some() + && let Some(bounds) = children.first().and_then(|clip| clip.path_data()).and_then(PathData::bounds) + { + drawn.push(("graphic", bounds)); + } + let first_stop = c.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_rgb()), + _ => None, + }, + AppearanceItem::Stroke(_) => None, + }); + if let (Some(rgb), Some(bounds)) = (first_stop, c.path_data().and_then(PathData::bounds)) { + drawn.push((if rgb[0] > rgb[2] { "fill" } else { "stroke" }, bounds)); + } + }); + let graphic = drawn.iter().position(|(kind, _)| *kind == "graphic").unwrap(); + let covering = |(kind, b): &(&str, Rect)| *kind == "stroke" && b.intersect(drawn[graphic].1).area() > 0.0; + assert!(drawn[graphic..].iter().any(covering), "a dash on the cap's edge is drawn over the fill's graphic"); +} + #[test] fn a_cap_with_a_hole_keeps_the_hole_out_of_the_graphic() { let d = document(); @@ -692,3 +799,72 @@ fn a_symbol_instance_in_a_graphic_draws_as_the_canvas_shows_it() { let area: f64 = pieces.iter().map(|p| p.path.to_bezpath().area().abs()).sum(); assert!((area - 30_000.0).abs() < 1e-3, "{area}"); } + +#[test] +fn curves_are_cut_one_at_a_time_and_the_work_stops_at_the_limit() { + let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 6.0)).to_bezpath(); + // Cut curve by curve, a path gives the points it gives cut whole. + for scale in [1.0, 100.0, 10_000.0] { + let placed = Affine::scale(scale) * ellipse.clone(); + let mut whole = vec![]; + vectorcraft_geom::kurbo::flatten(placed.iter(), SURFACE_TOLERANCE, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + whole.push(p); + } + }); + let mut points = 0; + let cut: Vec = straight_pieces(&placed, &mut points).unwrap().into_iter().flatten().collect(); + assert_eq!(points, cut.len()); + assert_eq!(cut.len(), whole.len(), "{scale}"); + assert!(cut.iter().zip(&whole).all(|(a, b)| a.distance(*b) < 1e-9), "{scale}"); + } + // Past the limit no further curve is cut: of 1,000 ellipses that each take more than 3,000 + // points, the count stops within one curve of the limit. + let mut many = BezPath::new(); + (0..1000).for_each(|_| many.extend(Affine::scale(10_000.0) * ellipse.clone())); + let mut points = 0; + assert!(straight_pieces(&many, &mut points).is_none()); + assert!(points > MAX_ELEMENTS && points < MAX_ELEMENTS + 2000, "{points}"); + // A curve that reaches farther than any surface, or to no finite point, is not cut at all. + for scale in [1e12, f64::INFINITY] { + let mut points = 0; + assert!(straight_pieces(&(Affine::scale(scale) * ellipse.clone()), &mut points).is_none()); + assert!(points <= 1, "{scale}: {points}"); + } +} + +#[test] +fn a_line_that_reaches_farther_than_any_surface_is_not_cut() { + let triangle = |far: f64| { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((far, 0.0)); + b.line_to((0.0, 1.0)); + b.close_path(); + b + }; + assert!(straight_pieces(&triangle(1e7), &mut 0).is_some()); + for far in [1e12, 1e300] { + assert!(straight_pieces(&triangle(far), &mut 0).is_none(), "{far}"); + assert!(straight_pieces(&(Affine::translate((far, 0.0)) * triangle(1.0)), &mut 0).is_none(), "{far}"); + } +} + +#[test] +fn only_an_opaque_part_on_revolve_takes_a_seam_stroke() { + let piece = |paint: Paint, opacity: f32, blend: BlendMode| Unrolled { + polygons: vec![], + boxes: vec![], + bounds: Rect::ZERO, + rule: FillRule::NonZero, + paint, + opacity, + blend, + place: Affine::IDENTITY, + }; + assert!(takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Normal))); + assert!(!takes_seam(&piece(Paint::solid(red()), 0.5, BlendMode::Normal))); + assert!(!takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Multiply))); + // A pattern's tile may hold transparent art, which the overlap would show. + assert!(!takes_seam(&piece(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, 1.0, BlendMode::Normal))); +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 56f9d40633..48665539d9 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -5,6 +5,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; mod imageoutline; +mod painted; mod rasterfollow; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/painted.rs b/crates/effects/src/tests/painted.rs new file mode 100644 index 0000000000..31dadc865e --- /dev/null +++ b/crates/effects/src/tests/painted.rs @@ -0,0 +1,270 @@ +//! Live-effect plug-ins that paint what they return, run on each fill a painted effect returned. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath, shapes}; +use vectorcraft_plugins::wat as tpl; + +use crate::geometry::Budget; +use crate::reshape; +use crate::reshape::{HELD_PEAK, PLUGIN_RUNS}; + +/// Installs live-effect plug-in `id`, which paints what it returns, replacing one with the same id: +/// its effect returns `output` whatever it is given. +fn install_painting(id: &str, output: &serde_json::Value) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); +} + +/// An open path of `curves` tall curves, each drawn with about 5,200 lines. +fn tall_curves(curves: u32) -> PathData { + let h = 3_000_000.0; + let anchors = (0..=curves) + .map(|i| { + let x = 40.0 + f64::from(i); + Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + PathData::single(SubPath::new(anchors, false)) +} + +/// Installs live-effect plug-in `id`, which reshapes the path it is given: its effect returns +/// `output` whatever it is given. +fn install_reshaping(id: &str, output: &serde_json::Value) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#); + let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); +} + +/// `curves` curves from `x`, each `h` tall at its handles. +fn curves_at(x: f64, curves: u32, h: f64) -> SubPath { + let anchors = (0..=curves) + .map(|i| { + let x = x + f64::from(i); + Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + SubPath::new(anchors, false) +} + +/// An open path, `dx` to the right of 40, drawn with about `lines` lines: tall curves and one +/// curve that makes up the rest. +fn drawn_with(lines: usize, dx: f64) -> PathData { + let h = 3_000_000.0; + let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines; + let tall = u32::try_from(lines / per).unwrap(); + let rest = lines % per; + let mut subpaths = vec![curves_at(40.0 + dx, tall, h)]; + if rest > 0 { + // A curve `h` tall at its handles is drawn with about √(9h) lines. + let rest = rest as f64; + subpaths.push(curves_at(40.0 + dx + f64::from(tall) + 2.0, 1, rest * rest / 9.0)); + } + PathData::new(subpaths) +} + +/// Stacks a painted effect that returns `first` on a path drawn with about `lines` lines with five +/// later effects on a square's fill, the `k`th returning that path `k` to the right (`later` +/// installs each). Each piece's share of the budget is counted once, so every effect fits, and the +/// art ends on the last effect's path, painted by `paints`. +fn later_effects_count_each_piece_once(name: &str, lines: usize, first: &serde_json::Value, later: fn(&str, &serde_json::Value), paints: &[Paint]) { + let near = drawn_with(lines, 0.0); + let drawn = Budget::of(&near).lines; + assert!(drawn.abs_diff(lines) <= 2, "the path is drawn with {drawn} lines"); + let first_id = format!("test.effects.{name}.first"); + let mut first = first.clone(); + first["objects"][0]["path"] = serde_json::to_value(&near).unwrap(); + install_painting(&first_id, &first); + let mut ids = vec![format!("plugin.{first_id}")]; + for k in 1..=5u8 { + let id = format!("test.effects.{name}.later{k}"); + later(&id, &json!({"objects": [{"path": drawn_with(lines, f64::from(k))}]})); + ids.push(format!("plugin.{id}")); + } + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for id in ids { + n.appearance.items[fill].effects_mut().push(Effect { id, params: json!({}), visible: true }); + } + let art = reshape(None, &n, None).unwrap(); + let mut paths = vec![]; + // The path is a compound path of two (the tall curves and the one that makes up the rest). + art.walk(&mut |c| { + let bounds = match &c.kind { + NodeKind::Compound { children, .. } => children.iter().filter_map(|k| k.path_data().and_then(PathData::bounds)).reduce(|a, b| a.union(b)), + _ => c.path_data().and_then(PathData::bounds), + }; + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if bounds.is_some() && !paints.is_empty() { + paths.push((bounds, paints)); + } + }); + assert_eq!(paths, [(drawn_with(lines, 5.0).bounds(), paints.to_vec())]); +} + +/// Later geometry effects on the two fills a painted effect returned run on each fill, and the +/// path they both stay on counts its piece's share of the budget once: each fill's result, drawn +/// with about 200 lines fewer than half the budget allows, fits at every stage. +#[test] +fn later_geometry_effects_on_two_fills_count_the_piece_once() { + let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))]; + let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect(); + let lines = Budget::EFFECTS.lines / 2 - 200; + later_effects_count_each_piece_once("twofills", lines, &json!({"objects": [{"fills": fills}]}), install_reshaping, &colors); +} + +/// Later painted effects on a painted effect's piece count the piece's share of the budget once: +/// each run's pieces take the place of the piece they were made of, so a result drawn with about +/// 300 lines fewer than the budget allows fits at every stage. +#[test] +fn later_painted_effects_on_a_painted_piece_count_it_once() { + let lines = Budget::EFFECTS.lines - 300; + later_effects_count_each_piece_once("painted", lines, &json!({"objects": [{}]}), install_painting, &[Paint::solid(Color::BLACK)]); +} + +/// A path's bounds and the paints of its fills and strokes. +type PaintedPath = (Option, Vec); + +/// The paths of the art a painted effect `name` that returns `square` with eight fills makes, each +/// path with its paints, when a later effect returns `first` on the first fill and `rest` on the +/// others, painting what it returns when `paints`; and the eight paints. Checks that the plug-in +/// results kept from one fill to the next never cost more than the effects' budget together. +fn eight_fills_then(name: &str, square: &PathData, paints: bool, results: (&PathData, &PathData)) -> (Vec, Vec) { + let (paths, colors, _) = fills_then(name, 8, square, paints, results); + (paths, colors) +} + +/// [`eight_fills_then`] with `n` fills, and how many times the later effect ran on a fill. +fn fills_then(name: &str, n: u8, square: &PathData, paints: bool, (first, rest): (&PathData, &PathData)) -> (Vec, Vec, usize) { + let colors: Vec = (0..n).map(|i| Paint::solid(Color::rgb(f32::from(i) / f32::from(n), 0.0, 0.0))).collect(); + let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect(); + let fills_id = format!("test.effects.{name}.fills"); + install_painting(&fills_id, &json!({"objects": [{"path": square, "fills": fills}]})); + let each_id = format!("test.effects.{name}.each"); + let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" }; + let manifest = format!(r#"{{"id":"{each_id}","name":"Test {each_id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#); + let (on_first, on_rest) = (json!({"objects": [{"path": first}]}).to_string(), json!({"objects": [{"path": rest}]}).to_string()); + let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_rest)).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for id in [fills_id, each_id] { + n.appearance.items[fill].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + } + HELD_PEAK.with(|p| p.set(Budget { anchors: 0, lines: 0 })); + PLUGIN_RUNS.with(|r| r.set(0)); + let art = reshape(None, &n, None).unwrap(); + let runs = PLUGIN_RUNS.with(std::cell::Cell::get); + let mut paths = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if !paints.is_empty() { + paths.push((p.bounds(), paints)); + } + } + }); + let peak = HELD_PEAK.with(std::cell::Cell::get); + assert!(Budget::EFFECTS.fits(peak), "the results kept cost {peak:?}"); + (paths, colors, runs) +} + +/// A later painted effect's results on eight fills, each costing more than half the effects' +/// budget and the first fill's on another path than the rest: only the first fill's result fits, +/// the other fills stay together on the square, and the results kept never cost more than the +/// budget together. +#[test] +fn painted_results_that_do_not_fit_are_not_kept() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let (large, other) = (tall_curves(900), PathData::single(curves_at(3_000.0, 900, 3_000_000.0))); + let (paths, colors) = eight_fills_then("held", &square, true, (&large, &other)); + assert_eq!(paths, [(large.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]); +} + +/// A later effect that reshapes each of eight painted fills to the same path, drawn with about a +/// fifth of the effects' budget: the fills share that one path, and the results that move onto it +/// are not kept as well, so what is kept never costs more than the budget. +#[test] +fn results_on_one_path_are_not_kept_for_each_fill() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let same = tall_curves(300); + let (paths, colors) = eight_fills_then("merged", &square, false, (&same, &same)); + assert_eq!(paths, [(same.bounds(), colors)]); +} + +/// A later effect that reshapes each of eight painted fills to the same path, drawn with more than +/// half the effects' budget: the path costs the art once, so every fill moves onto it, although a +/// second copy of it would not fit. +#[test] +fn results_on_one_path_share_it_near_the_budget() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let large = tall_curves(900); + let (paths, colors) = eight_fills_then("shared", &square, false, (&large, &large)); + assert_eq!(paths, [(large.bounds(), colors)]); +} + +/// An open path of tall curves `dx` to the right of 40, drawn with about `share` of the effects' +/// budget's lines. +fn drawn_share(share: f64, dx: f64) -> PathData { + let h = 3_000_000.0; + let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines as f64; + let curves = (Budget::EFFECTS.lines as f64 * share / per) as u32; + PathData::single(curves_at(40.0 + dx, curves, h)) +} + +/// A later painted effect runs once on each of three fills although the results are placed twice: +/// a result that fits both placings is kept between them, and one that did not fit in the first is +/// not run again when it does not fit the second either. The first fill's result (a tenth of the +/// budget) and the second's (half of it) fit, the third's (half of it) does not, and the square +/// (45%) left for the third fill then has no room; in the second placing the second fill's result +/// does not fit beside the square kept for the fills after it. +#[test] +fn a_later_plug_in_runs_once_on_each_fill_across_both_placings() { + let (square, first, rest) = (drawn_share(0.45, 0.0), drawn_share(0.1, 2_000.0), drawn_share(0.5, 4_000.0)); + let (paths, colors, runs) = fills_then("once", 3, &square, true, (&first, &rest)); + assert_eq!(paths, [(first.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]); + assert_eq!(runs, 3, "the later effect ran {runs} times on three fills"); +} + +/// A built-in geometry effect on two strokes that a painted effect returned, which doubles the lines +/// of a path drawn with 30% of the budget: the second stroke's result does not fit beside the +/// first's, but it is the same path, so it is built again with all the room the art leaves and +/// both strokes are drawn on that one path. +#[test] +fn a_built_in_result_on_the_path_before_it_is_taken_near_the_budget() { + let square = drawn_share(0.3, 0.0); + let transform = json!({"scaleV": 400}); + let tall = crate::distort::transform_within(&square, square.bounds().unwrap(), &transform, 0, usize::MAX); + let (s, t) = (Budget::of(&square).lines, Budget::of(&tall).lines); + let lines = Budget::EFFECTS.lines; + assert!(t > lines / 2 + lines / 20 && t < lines - lines / 20 && s + t < lines, "the paths are drawn with {s} and {t} lines"); + let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))]; + let strokes: Vec<_> = colors.iter().map(|p| json!({"paint": p, "width": 1})).collect(); + let id = "test.effects.builtin.strokes"; + install_painting(id, &json!({"objects": [{"path": square, "strokes": strokes}]})); + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let stroke = n.appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + n.appearance.items[stroke].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + n.appearance.items[stroke].effects_mut().push(Effect { id: "distort.transform".into(), params: transform, visible: true }); + let art = reshape(None, &n, None).unwrap(); + let mut paths = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if !paints.is_empty() { + paths.push((p.bounds(), paints)); + } + } + }); + // One path, scaled about a reference point of the piece's own, painted by both strokes. + assert_eq!(paths.len(), 1, "{paths:?}"); + let (bounds, paints) = &paths[0]; + assert_eq!(paints, &colors.to_vec()); + let height = |b: Option| b.map_or(0.0, |b| b.height()); + assert!((height(*bounds) - height(tall.bounds())).abs() < 1.0, "{bounds:?} is not {:?} tall", tall.bounds()); +} diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index e0c7bc5130..aead8b72ce 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -523,11 +523,12 @@ fn a_groups_transform_applies_after_its_members_own() { let mut on_fill = fill_square(33, vec![shadow(10.0, 0.0, 0.0)]); on_fill.appearance.effects = vec![turn()]; let art = crate::evaluate_container(None, &group(32, vec![on_fill], vec![uneven()])).unwrap(); - assert!(near(&[fill_shadow(&node_in(&art, 33))], &[want]), "fill: {:?}", fill_shadow(&node_in(&art, 33))); + // The member is drawn as its art: a group with its id holding the turned square. + let filled = node_in(&art, 33).children().and_then(|ch| ch.first().map(|c| (**c).clone())).unwrap(); + assert!(near(&[fill_shadow(&filled)], &[want]), "fill: {:?}", fill_shadow(&filled)); let nested = group(34, vec![group(35, vec![square(36, vec![shadow(10.0, 0.0, 0.0)])], vec![turn()])], vec![uneven()]); let art = crate::evaluate_container(None, &nested).unwrap(); - let inner = crate::evaluate_container(None, &node_in(&art, 35)).unwrap(); - assert!(near(&shadows_in(&inner), &[want]), "nested: {:?}", shadows_in(&inner)); + assert!(near(&shadows_in(&art), &[want]), "nested: {:?}", shadows_in(&art)); } /// An object's Transform effect transforms the raster effects of its fills and strokes with the @@ -645,7 +646,8 @@ fn a_groups_transform_moves_its_members_fills_shadows_beside_their_own_transform let art = crate::evaluate_container(None, &g).unwrap(); for (id, _, want) in &cases { let member = node_in(&art, *id); - let drawn = shadows_in(&crate::reshape(None, &member, None).unwrap()); + // The member is drawn as its art, its fill's own Transform effect applied. + let drawn = shadows_in(&member); assert!(!drawn.is_empty() && near(&drawn, &vec![*want; drawn.len()]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}"); } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 80ed19d145..166436b0c8 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -245,6 +245,12 @@ pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> { let Some(root) = ordinary_isolation(st) else { return has_selection(s) }; if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) } } +/// [`has_selected_member`], or a ruler guide is selected outside isolation mode: Object › Lock › +/// Selection and Object › Hide › Selection. +pub fn has_selected_member_or_guide(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if ordinary_isolation(st).is_none() && !st.selection.guides.is_empty() { Ok(()) } else { has_selected_member(s) } +} /// Whether `id` lies inside the isolated object `root` (the root itself does not). pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool { id != root && d.ancestry(id).is_some_and(|a| a.contains(&root)) @@ -506,8 +512,13 @@ fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result /// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error, /// never the selection. `None` when it gives neither (or gives them as `null`). pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result>> { + given_nodes(s, p, command, false) +} +/// The nodes `p` gives `command`, as [`given_objects`] reads them; ruler guide nodes are taken +/// too when `guides`, else refused. +pub(crate) fn given_nodes(s: &Session, p: &Value, command: &str, guides: bool) -> Result>> { if p.get("ids").is_some_and(|v| !v.is_null()) { - return checked_ids_param(s, p, "ids", command).map(Some); + return checked_nodes_param(s, p, "ids", command, guides).map(Some); } match p.get("id").filter(|v| !v.is_null()) { None => Ok(None), @@ -515,7 +526,7 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result { Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) } - Some(v) => Ok(Some(vec![checked_id(s, v, command)?])), + Some(v) => Ok(Some(vec![checked_node(s, v, command, guides)?])), } } /// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form: @@ -541,9 +552,18 @@ pub(crate) fn index(v: u64) -> usize { } /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { + checked_nodes_param(s, p, key, command, false) +} +/// The `key` list as [`checked_ids_param`] checks it; ruler guide nodes are taken too when +/// `guides`, else refused. +pub(crate) fn checked_nodes_param(s: &Session, p: &Value, key: &str, command: &str, guides: bool) -> Result> { let values = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; - values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect() + values + .iter() + .enumerate() + .map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_node(s, value, command, guides))) + .collect() } /// The objects a command acts on: `ids` when given (every one checked, and those inside another @@ -557,11 +577,16 @@ pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> } pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { + checked_node(s, value, command, false) +} +/// `value` as the id of a node of the active document; a ruler guide's is taken only when +/// `guides`. +pub(crate) fn checked_node(s: &Session, value: &Value, command: &str, guides: bool) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; match s.doc()?.doc.node(id) { None => Err(bad(command, format!("no such object id {value}"))), // The guide commands take ruler guides by index. - Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), + Some(n) if n.is_ruler_guide() && !guides => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), Some(_) => Ok(id), } } @@ -778,6 +803,8 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; pub(crate) use addons::{DecodedImages, refresh_color_masks}; +/// Document Info's category titles and the Objects category's count labels. +pub use docinfo::{CATEGORIES, OBJECT_LABELS}; pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index b20eb0d59b..1184687366 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -341,6 +341,33 @@ fn fix_all_is_one_undo_step() { assert_eq!(s.doc().unwrap().doc.artboards.len(), doc.artboards.len()); } +#[test] +fn a_long_straight_run_loses_its_inner_anchors_in_one_step() { + let mut s = session(); + let n: u32 = 5_000; + let mut pts: Vec<(f64, f64)> = (0..n).map(|i| (10.0 + f64::from(i) * 0.05, 150.0)).collect(); + pts.push((260.0, 190.0)); + let id = line(&mut s, &pts); + let other = rect(&mut s, 300.0, 20.0, 40.0, 40.0); + select(&mut s, &[other]); + let check = s.execute("addon.cleanup.check", &json!({"kinds": ["collinearAnchors"], "scope": "document"})).unwrap(); + assert_eq!(check["total"], n - 2, "{check}"); + assert_eq!(check["findings"][0]["ids"], json!([id.0])); + let shown = s.execute("addon.cleanup.show", &json!({"kind": "collinearAnchors", "scope": "document"})).unwrap(); + assert_eq!(shown["count"], n - 2); + assert_eq!(s.doc().unwrap().selection.objects, [id], "Show selects the path"); + select(&mut s, &[other]); + let (steps, before) = (undo_steps(&s), node(&s, id).unwrap()); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["collinearAnchors"], "scope": "document"})).unwrap(); + assert_eq!((r["total"].as_u64(), r["fixed"]["collinearAnchors"].as_u64()), (Some(u64::from(n) - 2), Some(u64::from(n) - 2))); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.doc().unwrap().selection.objects, [other], "Fix keeps the selection"); + let ends: Vec<(f64, f64)> = node(&s, id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect(); + assert_eq!(ends, [(10.0, 150.0), (10.0 + f64::from(n - 1) * 0.05, 150.0), (260.0, 190.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).unwrap(), before); +} + #[test] fn the_scope_is_the_selection_or_the_document() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index 654138a1dd..68681b50fa 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -132,8 +132,10 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result { let active = s.active; for i in 0..s.docs.len() { // Each document in turn is the one Save a Copy writes; switching tabs this way leaves every - // document's tool and interaction alone, and the active one comes back below. + // document's tool and interaction alone, and the active one comes back below. Its colors + // are written through its own profiles. s.active = Some(i); + s.sync_front_profiles(); let Ok(st) = s.doc() else { continue }; let title = st.doc.title.clone(); let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&title)); @@ -160,6 +162,7 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result { } } s.active = active; + s.sync_front_profiles(); Ok(json!({ "files": files, "failed": failed, "format": format.id })) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index fb86fa7852..79a203c421 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -19,7 +19,10 @@ //! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the //! image as its Contents row says; new ones go right below that row. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; @@ -45,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -81,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -90,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -144,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -416,9 +419,87 @@ pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) } } -/// [`appearance_targets`] of `cmd`, which needs at least one. -fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { +/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside +/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: +/// the Appearance panel lists those rows and edits them by index. +pub(crate) fn rowless_series_marks(d: &Document, ids: &[NodeId]) -> Vec { + super::graph::series_group_marks_where(d, ids, |group| group.appearance.items.is_empty()) +} + +/// Whether series group `group` holds nothing of its own, as the graph draws it: no fill or stroke row, no effect, the +/// default opacity, blend mode, isolation and knockout, and no graphic style link. +fn holds_no_look(group: &Node) -> bool { + let ap = &group.appearance; + ap.items.is_empty() + && ap.effects.is_empty() + && group.opacity == 1.0 + && group.blend == BlendMode::Normal + && !group.isolate + && group.knockout == Default::default() + && !group.knockout_shape + && group.extras().graphic_style.is_none() +} + +/// The objects whose appearance a command reads for `id`: a graph series group that holds nothing of its own +/// ([`holds_no_look`]) gives the paths directly inside it, backmost first. A series group with an effect, an opacity or +/// another attribute of its own is read itself, with that attribute. +fn look_holders(d: &Document, id: NodeId) -> Vec { + super::graph::series_group_marks_where(d, &[id], holds_no_look) +} + +/// The object whose appearance a command reads for `n`: a graph series group that holds nothing of its own stands for +/// the topmost path directly inside it ([`look_holders`]). +pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { + look_holders(d, n.id).last().and_then(|id| d.node(*id)).unwrap_or(n) +} + +/// Objects whose fill and stroke rows a command edits: the [`appearance_targets`], with a graph series group standing +/// for the paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. +pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { let ids = appearance_targets(s, p)?; + Ok(rowless_series_marks(&s.doc()?.doc, &ids)) +} + +/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, +/// which can hold effects and a graphic style link of its own. +pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { + let own = appearance_targets(s, p)?; + let mut ids = rowless_series_marks(&s.doc()?.doc, &own); + let groups = stood_for(&own, &ids); + ids.extend(groups); + Ok(ids) +} + +/// The series groups among `own` that `ids` ([`rowless_series_marks`] of `own`) replaced with their paths. +pub(crate) fn stood_for(own: &[NodeId], ids: &[NodeId]) -> Vec { + let kept: HashSet = ids.iter().copied().collect(); + own.iter().copied().filter(|id| !kept.contains(id)).collect() +} + +/// Clear Appearance on each series group a command gave a whole new appearance through its paths, so the group's own +/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it. +pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { + for id in groups { + if let Some(n) = d.node_mut(*id) { + clear(n); + (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + n.extras_mut().graphic_style = None; + } + } +} + +/// [`whole_targets`] of `cmd`, which needs at least one. +fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = whole_targets(s, p)?; + if ids.is_empty() { + return Err(bad(cmd, "select objects or give ids")); + } + Ok(ids) +} + +/// [`row_targets`] of `cmd`, which needs at least one. +fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = row_targets(s, p)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -616,7 +697,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } fn clear_appearance(s: &mut Session, p: &Value) -> Result { - let ids = require_targets(s, p, "appearance.clear")?; + let ids = require_whole_targets(s, p, "appearance.clear")?; // When every object is cleared already, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Clear Appearance", |d| { @@ -732,35 +813,64 @@ fn transfer(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`source` and `target` are the same object")); } let copy = switch_or(p, "copy", false, C)?; + // A graph series group stands for the paths directly inside it: as the source, the topmost of them is read and a + // move clears them all; as the target, each of them other than a source takes the appearance. + let sources = look_holders(&s.doc()?.doc, source); + let read = sources.last().copied().unwrap_or(source); + let from: Vec = sources.iter().copied().chain(stood_for(&[source], &sources)).collect(); + let marks = rowless_series_marks(&s.doc()?.doc, &[target]); + let stands = !marks.contains(&target); + let within: HashSet = from.iter().copied().collect(); + let mut targets: Vec = marks.iter().copied().filter(|id| !within.contains(id)).collect(); + if targets.is_empty() { + if stands { + return Err(bad(C, "`source` is the only path in series group `target`")); + } + targets.push(target); + } + // A source that is one of the target group's paths keeps its look: the group stands for it too. + let part: HashSet = if stands { marks.into_iter().collect() } else { HashSet::new() }; + let from: Vec = stood_for(&from, &targets); + let cleared: Vec = if copy { vec![] } else { from.iter().copied().filter(|id| !part.contains(id)).collect() }; + // The group its paths stand for loses its own opacity, blend mode and effects, which would stay on top of them. + let group: Vec = if stands { vec![target] } else { vec![] }; // When both objects come out as they were, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| { - let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?; - let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?; - let mut ap = src.appearance.clone(); - if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) { - ap.rebase_gradients(f, t); + d.node(source).ok_or(EngineError::NoNode(source))?; + let src = d.node(read).cloned().ok_or(EngineError::NoNode(read))?; + for id in &targets { + let to = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + let mut ap = src.appearance.clone(); + if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) { + ap.rebase_gradients(f, t); + } + // Only groups, layers, type and images have a Contents (Characters) row to keep a slot for. + if to.contents_label().is_none() { + ap.contents_index = None; + } + to.appearance = ap; + (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = + (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); + super::object::snap_if_aligned(to); } - // Only groups, layers, type and images have a Contents (Characters) row to keep a slot for. - if to.contents_label().is_none() { - ap.contents_index = None; + clear_stood_for(d, &group); + for id in &cleared { + if let Some(n) = d.node_mut(*id) { + clear(n); + (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + super::object::snap_if_aligned(n); + } } - to.appearance = ap; - (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); - super::object::snap_if_aligned(to); - if !copy && let Some(n) = d.node_mut(source) { - clear(n); - (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); - super::object::snap_if_aligned(n); - } - let changed = if copy { vec![target] } else { vec![source, target] }; + let changed: Vec = cleared.iter().chain(&targets).copied().collect(); let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); - // The target last: on marks of one series, its paint is the one the series keeps. + // The targets last: on marks of one series, their paint is the one the series keeps. let mut rows = top_paint_changed(&before, d, &changed); - rows.extend(painted_rows(d, both_kinds(&[target]))); + rows.extend(painted_rows(d, both_kinds(&targets))); let painted = super::graph::capture_series_top_paints(d, &rows); let object = super::graph::capture_series_object_opacity(d, &changed); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object)) + let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect(); + Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -772,7 +882,7 @@ fn is_group(n: &Node) -> bool { } fn reduce_basic(s: &mut Session, p: &Value) -> Result { - let ids = require_targets(s, p, "appearance.reduceToBasic")?; + let ids = require_whole_targets(s, p, "appearance.reduceToBasic")?; // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); // The Appearance panel's active row (a row of the first selected object's stack, and in each @@ -828,7 +938,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["weight", "opacity"], "appearance.setItem")?; check_switches(p, &["visible"], "appearance.setItem")?; let paint = paint_from(s, p)?; - let ids = appearance_targets(s, p)?; + let ids = row_targets(s, p)?; if ids.is_empty() { return Err(bad("appearance.setItem", "select objects or give ids")); } @@ -1039,7 +1149,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { /// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects. fn show_all_hidden(s: &mut Session, p: &Value) -> Result { - let roots = require_targets(s, p, "appearance.showAllHidden")?; + let roots = require_whole_targets(s, p, "appearance.showAllHidden")?; let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { let shown: Vec = roots @@ -1069,11 +1179,16 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.moveItem"; let to = index_param(p, "to", C)?; - let ids = require_targets(s, p, C)?; let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { + // A graph series group's own Contents row, not its paths, which have none. + let ids = appearance_targets(s, p)?; + if ids.is_empty() { + return Err(bad(C, "select objects or give ids")); + } return move_contents(s, &ids, to); } + let ids = require_targets(s, p, C)?; // Where an aligned row would go in each of several different stacks isn't one place. if aligned && appearances_differ(&s.doc()?.doc, &ids) { return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); @@ -1365,17 +1480,19 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); let source_has = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind); let source_shows = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + // A whole pick replaces the stack: it removes the rows of a kind the source has no row of. + let whole = fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL; let (paints, opacities) = if append { let rows = (fill.color && source_shows(true), stroke.color && source_shows(false)); (rows, rows) } else { ( (fill.color, stroke.color || (stroke.weight && source_has(false))), - (fill.transparency && source_has(true), stroke.transparency && source_has(false)), + (fill.transparency && (whole || source_has(true)), stroke.transparency && (whole || source_has(false))), ) }; // A pick of some attributes edits the topmost row of its kind, which may be hidden; `append` and a whole pick write rows. - let rows_written = append || (fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL); + let rows_written = append || whole; let top_shows = |id: NodeId, kind: bool| { let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(|it| it.visible()) @@ -1389,6 +1506,9 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { .collect() }; let mut opacity_rows = top_opacity_changed(&before, d, &targets); + // A written row that had paint before the pick counts too when the pick left it without paint, as in + // `capture_series_writes`. + opacity_rows.extend(painted_rows(&before, written(opacities))); opacity_rows.extend(painted_rows(d, written(opacities))); let mut rows = top_paint_changed(&before, d, &targets); rows.extend(painted_rows(d, written(paints))); diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 9c6c28b900..11519b84be 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -527,8 +527,9 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result, + /// The encoded image the tracer decodes: the blob under the image's key, shared with the + /// document. Two blobs are equal when they are the same allocation or hold the same bytes. + bytes: Arc>, } /// What `imageTrace.make` and `imageTrace.makeAndExpand` take from the session before they trace. @@ -549,7 +553,6 @@ struct Prepared { params_json: Value, preset_label: String, library: Vec<([u8; 3], Paint)>, - bytes: Arc>, } /// What command `c` with params `p` traces, and how, from the session: everything but the trace. @@ -597,8 +600,8 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; let traced_with = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; - let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone() }; - Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library, bytes }) + let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone(), bytes }; + Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library }) } /// What a trace made of an image: the size in pixels it was decoded at, and the paths. @@ -616,11 +619,11 @@ impl Traced { } } -/// Decode the image `bytes` and trace it with `params`, telling `steps` how far it has got -/// ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`. -fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, bytes: &[u8], steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { +/// Decode the image bytes of `inputs` and trace them with `params`, telling `steps` how far it has +/// got ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`. +fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { // An image over the tracer's size is traced scaled down (the paths are scaled back). - let raster = tr::Raster::decode_to_fit(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let raster = tr::Raster::decode_to_fit(&inputs.bytes).map_err(|e| EngineError::Other(e.to_string()))?; if !steps(0, 0) { return Err(EngineError::Cancelled); } @@ -682,7 +685,8 @@ impl TraceRun { (self.prepared.inputs.image.width, self.prepared.inputs.image.height) } - /// Does it still apply: would the command trace the same object, image and settings now? + /// Does it still apply: would the command trace the same object, image, pixels and settings + /// now? pub fn current(&self, s: &Session) -> bool { prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs) } @@ -691,13 +695,13 @@ impl TraceRun { /// failed or stopped ([`EngineError::Cancelled`] once `steps` answers `false`). It touches no /// document, so it can run on any thread. pub fn run(&self, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { - trace_now(&self.prepared.inputs, &self.prepared.trace, &self.prepared.bytes, steps) + trace_now(&self.prepared.inputs, &self.prepared.trace, steps) } } pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; - let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?; + let Prepared { inputs, trace, view, params_json, preset_label, library } = prepare(s, c, p)?; let group_by_color = expand && by_color_param(c, p)?; // A saved preset's settings join the journal entry, so a replay traces as this run did after // the preset is changed, renamed or deleted. @@ -709,7 +713,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result traced, - _ => trace_now(&inputs, &trace, &bytes, &|_, _| true)?, + _ => trace_now(&inputs, &trace, &|_, _| true)?, }; let TraceInputs { target, image: img, .. } = inputs; let mut warnings = keep_warnings(&trace, &res); diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index d28bd0e6c9..446634d21c 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -143,17 +143,13 @@ pub(crate) fn subpath_selected_segments( let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty()); if has_explicit_segs { if let Some(segments) = seg_refs { - for &(s, seg) in segments { - if s == si && seg < seg_count { - selected_segs.insert(seg); - } - } + selected_segs.extend(super::path::subpath_refs(segments, si).filter(|&seg| seg < seg_count)); } return selected_segs; } if let Some(anchors) = anchor_refs { - let sub_anchors: BTreeSet = anchors.iter().filter_map(|&(s, a)| (s == si && a < n).then_some(a)).collect(); + let sub_anchors: BTreeSet = super::path::subpath_refs(anchors, si).filter(|&a| a < n).collect(); if !sub_anchors.is_empty() { for i in 0..seg_count { let next = (i + 1) % n; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 16990fc2ea..6c75eae128 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -5,9 +5,11 @@ //! Colour settings and the proof view are process-wide (`vectorcraft_color::cms::active`, //! `vectorcraft_render::proof::view`). A document's assigned profiles are stored in //! `Document::color_profiles` (files before format v3: `Document.unknown["colorProfiles"]`) and -//! take the place of the working spaces in the active settings when assigned, and when the working -//! settings change while the document is in front ([`use_front_profiles`]); the working spaces stay -//! as Edit → Color Settings set them. +//! take the place of the working spaces in the active settings while the document is in front +//! ([`use_front_profiles`]): when they are assigned, when the working settings change, and when the +//! document comes to the front or its assignment comes back (an open, a switch, a close, a revert, +//! an undo or a rollback, [`Session::sync_front_profiles`]); the working spaces stay as Edit → Color +//! Settings set them. use std::collections::BTreeMap; use std::sync::Arc; @@ -181,7 +183,7 @@ fn touch_all(s: &mut Session) { /// Make the active settings the working settings with the profiles the front document is assigned /// in place of the working spaces (none, or no document open: the working spaces). The open /// documents redraw when the active settings change. -fn use_front_profiles(s: &mut Session) { +pub(crate) fn use_front_profiles(s: &mut Session) { let (rgb, cmyk) = s.active().map(|d| doc_profiles(&d.doc)).unwrap_or_default(); if cms::use_document_profiles(rgb.as_deref(), cmyk.as_deref()) { touch_all(s); diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 833c7d22bf..a43325becd 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -52,7 +52,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ]; /// The object counts of `document.info` and their labels. -const OBJECT_LABELS: [(&str, &str); 19] = [ +pub const OBJECT_LABELS: [(&str, &str); 19] = [ ("paths", "Paths"), ("compoundPaths", "Compound Paths"), ("groups", "Groups"), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index ae3fc521f0..ae48ba02e6 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -210,7 +210,7 @@ pub fn specs() -> Vec { "Guides", [], None, - "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides", + "{} → [{index, id (its node, which `layer.setProps` hides or locks on its own), vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), hidden, locked (its own visibility and lock), shown, editable (it and its layer shown and unlocked)}…] the ruler guides", has_doc, guide_list ), @@ -219,7 +219,7 @@ pub fn specs() -> Vec { "Select Guides", [], None, - "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", + "{indexes: [index…] (at most 10,000), toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", guides_unlocked, guide_select ), @@ -228,7 +228,7 @@ pub fn specs() -> Vec { "Remove Guide", [], None, - "{index?} delete ruler guide `index`, else the selected ones (as Delete / edit.clear does) → {count}", + "{index?, indexes?: [index…] (at most 10,000)} delete ruler guide `index` (or guides `indexes`, as dragging their rows onto the Layers panel's trash does), else the selected ones (as Delete / edit.clear does), in one undo step; give `index` or `indexes`, not both, and an index given twice counts once; a hidden or locked guide, or one on a hidden or locked layer, is an error → {count}", guides_unlocked, guide_remove ), @@ -246,7 +246,7 @@ pub fn specs() -> Vec { "Move Guide to Layer", [], None, - "{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}", + "{index?, indexes?: [index…] (at most 10,000), layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does; give `index` or `indexes`, not both, and an index given twice counts once → {count}", guides_unlocked, guide_set_layer ), @@ -805,12 +805,17 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { let row = |(i, g): (usize, &Guide)| { let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id)); let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected }); + if let Some(id) = g.id { + r["id"] = json!(id.0); + } if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } if let Some(l) = g.layer { r["layer"] = json!(l.0); } + r["hidden"] = json!(g.hidden); + r["locked"] = json!(g.locked); r["shown"] = json!(st.doc.guide_shown(g)); r["editable"] = json!(st.doc.guide_editable(g)); r @@ -824,27 +829,44 @@ fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result(v: &'a Value, cmd: &str) -> Result<&'a Vec> { + let list = v.as_array().ok_or_else(|| bad(cmd, "indexes must be an array of guide indexes"))?; + if list.len() > MAX_GUIDE_INDEXES { + return Err(bad(cmd, format!("indexes may hold at most {MAX_GUIDE_INDEXES} entries"))); + } + Ok(list) +} + /// The id of ruler guide `index` of the active document (`index` validated). fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd) } -/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer? +/// Is ruler guide `id` one that can be moved or deleted: not hidden or locked itself, nor on a +/// hidden or locked layer? fn check_editable_guide(s: &Session, id: NodeId) -> Result { let d = &s.doc()?.doc; - if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) { + let guide = d.ruler_guide(id); + if guide.as_ref().is_some_and(|g| g.hidden || g.locked) { + return Err(EngineError::Other("the guide is hidden or locked".into())); + } + if !guide.is_some_and(|g| d.guide_editable(&g)) { return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); } Ok(id) } -/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. +/// [`guide_index`] of a guide that can be moved or deleted ([`check_editable_guide`]). fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { check_editable_guide(s, guide_index(s, v, cmd)?) } fn guide_select(s: &mut Session, p: &Value) -> Result { - let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; + let list = guide_index_list(p.get("indexes").ok_or_else(|| bad("guide.select", "missing indexes"))?, "guide.select")?; let ids = guide_ids(&s.doc()?.doc); let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::>>()?; if switch_or(p, "toggle", false, "guide.select")? { @@ -861,18 +883,30 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) })) } -/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. +/// Delete ruler guide `index` (or guides `indexes`), else the selected guides, in one undo step +/// → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { - let gone: Vec = match p.get("index") { - Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], - None => s.doc()?.selection.guides.clone(), + const C: &str = "guide.remove"; + let mut gone: Vec = match (p.get("index"), p.get("indexes")) { + (Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")), + (Some(v), None) => vec![editable_guide_index(s, Some(v), C)?], + (None, Some(list)) => { + let list = guide_index_list(list, C)?; + let ids = guide_ids(&s.doc()?.doc); + // A set, so an index given twice deletes its guide once and the ids held never outnumber the guides. + let set = list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()?; + set.into_iter().collect() + } + (None, None) => s.doc()?.selection.guides.clone(), }; + gone.sort_unstable(); + gone.dedup(); if gone.is_empty() { - return Err(bad("guide.remove", "no guide selected")); + return Err(bad(C, "no guide selected")); } let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" }; s.edit(label, |d, sel| { - d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.binary_search(&id).is_ok())); Ok(()) })?; Ok(json!({ "count": gone.len() })) @@ -888,14 +922,19 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result { if super::layer::locked_within(d, layer) { return Err(EngineError::Other("the layer or group is locked".into())); } - let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { - (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], + let mut moving: Vec = match (p.get("index"), p.get("indexes")) { + (Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")), + (Some(v), None) => vec![editable_guide_index(s, Some(v), C)?], (None, Some(list)) => { + let list = guide_index_list(list, C)?; let ids = guide_ids(&s.doc()?.doc); - list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? + list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? } (None, None) => s.doc()?.selection.guides.clone(), }; + // An index given twice moves (and counts) its guide once; the others keep their order. + let mut seen = HashSet::new(); + moving.retain(|id| seen.insert(*id)); if moving.is_empty() { return Err(bad(C, "no guide selected (or give an index)")); } diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index 10254f3cd3..5b80fcc80f 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -116,8 +116,11 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no such anchor".into())); } let mut emptied = Beside::replacing(d, &[id]); - let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?; + let removed = remove_anchors_of(d, sel, &mut emptied, id, vec![(si, ai)], curvature)?; emptied.finish(d)?; + if removed { + sel.remove(id); + } Ok(removed) })?; Ok(json!({ "removedObject": removed })) @@ -136,14 +139,14 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); } let removed = s.edit("Remove Anchor Points", |d, sel| { - let mut removed = 0; + let mut gone = Vec::new(); let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); for (id, anchors) in jobs { let shape: Vec<(usize, bool)> = d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); let next = following_anchors(&shape, &anchors); if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? { - removed += 1; + gone.push(id); } else if !next.is_empty() { // The next anchor along is selected, so the next Remove Anchor Points goes on from // there without picking it first (#2241). @@ -151,7 +154,8 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { } } emptied.finish(d)?; - Ok(removed) + sel.remove_all(gone.iter().copied()); + Ok(gone.len()) })?; Ok(json!({ "removedObjects": removed })) } @@ -160,19 +164,32 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { /// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its /// last removed one, round the end of a closed subpath, or the one before it at an open subpath's /// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. +/// `removed` is in ascending order without repeats, as a selection holds it, and each subpath reads +/// only its own pairs. fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { let mut next = BTreeSet::new(); let mut kept = 0; + let mut rest = removed; for (si, &(n, closed)) in shape.iter().enumerate() { - let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count()); + rest = after; + let gone = &mine[..mine.partition_point(|&(_, a)| a < n)]; if n - gone.len() < 2 { continue; } - if let Some(&last) = gone.last() { - let wrap = if closed { 0..last } else { 0..0 }; - let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); - if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { - next.insert((kept, a - gone.range(..a).count())); + if let Some(&(_, last)) = gone.last() { + // Anchors past `last` are all left. Below it, the first one left (round a closed + // subpath's end) is past the removed run from 0, and the last one left is below the + // removed run down from `last`. + let a = if last + 1 < n { + Some(last + 1) + } else if closed { + Some(gone.iter().zip(0..).take_while(|&(&(_, g), k)| g == k).count()) + } else { + last.checked_sub(gone.iter().rev().zip((0..=last).rev()).take_while(|&(&(_, g), k)| g == k).count()) + }; + if let Some(a) = a { + next.insert((kept, a - gone.partition_point(|&(_, g)| g < a))); } } kept += 1; @@ -183,34 +200,37 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr /// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round /// each one, or with `curvature` re-curving it as the Curvature tool curves its points /// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed, -/// and a path left with no subpath is removed through `emptied`. True when the path went. +/// and a path left with no subpath is removed through `emptied`; the caller then takes it out of +/// the selection. True when the path went. fn remove_anchors_of( d: &mut Document, sel: &mut Selection, emptied: &mut Beside, id: NodeId, - anchors: impl IntoIterator, + mut anchors: Vec<(usize, usize)>, curvature: bool, ) -> Result { - let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); // The last first, so the indexes still to come stay valid. anchors.sort_unstable_by(|a, b| b.cmp(a)); anchors.dedup(); let path = path_mut(d, id)?; - for (si, ai) in anchors { - if let Some(sp) = path.subpaths.get_mut(si) { - if curvature { + let mut rest = &anchors[..]; + while let Some(&(si, _)) = rest.first() { + let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count()); + rest = after; + let Some(sp) = path.subpaths.get_mut(si) else { continue }; + if curvature { + for &(_, ai) in mine { vectorcraft_tools::draw2::curvature_remove(sp, ai); - } else { - po::remove_anchor(sp, ai); } + } else { + po::remove_anchors(sp, &mine.iter().map(|&(_, ai)| ai).collect::>()); } } path.subpaths.retain(|sp| sp.anchors.len() >= 2); let gone = path.is_empty(); if gone { emptied.put(d, id, vec![])?; - sel.remove(id); } else { sel.anchors.remove(&id); sel.segments.remove(&id); @@ -277,3 +297,47 @@ pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result { })?; ok() } + +#[cfg(test)] +mod tests { + use super::*; + + /// [`following_anchors`] by its definition: for each subpath, a search of the anchors after + /// its last removed one, round a closed end, then before it. + fn following_by_search(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { + let mut next = BTreeSet::new(); + let mut kept = 0; + for (si, &(n, closed)) in shape.iter().enumerate() { + let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + if n - gone.len() < 2 { + continue; + } + if let Some(&last) = gone.last() { + let wrap = if closed { 0..last } else { 0..0 }; + let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); + if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { + next.insert((kept, a - gone.range(..a).count())); + } + } + kept += 1; + } + next + } + + #[test] + fn following_anchors_matches_a_search_along_each_subpath() { + let mut seed = 0x2545_f491_4f6c_dd1d_u64; + let mut rnd = |m: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % m) as usize + }; + for case in 0..2000 { + let shape: Vec<(usize, bool)> = (0..rnd(5) + 1).map(|_| (rnd(9), rnd(2) == 0)).collect(); + let removed: BTreeSet<(usize, usize)> = (0..rnd(14)).map(|_| (rnd(shape.len() as u64 + 1), rnd(11))).collect(); + let removed: Vec<_> = removed.into_iter().collect(); + assert_eq!(following_anchors(&shape, &removed), following_by_search(&shape, &removed), "case {case}: {shape:?} removing {removed:?}"); + } + } +} diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 8946aa4351..400e13baab 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { "Pencil", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", has_doc, freehand ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index f13b0a0ba4..31e184e195 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -151,7 +151,11 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } let pen = PenRecord::of_samples(&samples); let mut pts: Vec = samples.into_iter().map(|s| s.pt).collect(); - let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, 100.0); + let tol = number_or(p, "fidelity", 1.5, C)?; + let (lo, hi) = (vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, vectorcraft_tools::draw2::MAX_FIT_TOLERANCE); + if !(lo..=hi).contains(&tol) { + return Err(bad(C, format!("`fidelity` must be from {lo} to {hi} pt"))); + } // The tolerance the stroke is fitted at; with `fit: false` it is not fitted. let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); let closed = switch_or(p, "closed", false, C)?; @@ -200,7 +204,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } None => None, }; - let close_tol = (tol * 4.0).max(6.0); + // An extending stroke that ends within four Fidelities of the path's other end closes it: + // at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. + let close_tol = (tol * 4.0).clamp(6.0, 400.0); let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); if closing && let Some(l) = pts.last_mut() { *l = first; diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index df383c838b..0ac4fc8d6b 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -8,10 +8,22 @@ use super::{ // ---------- Smooth ---------- -/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. -fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { +/// The spread (points) of the Gaussian the Smooth tool smooths a path along its length with before +/// refitting it, as a share of the tool's reach: a third, so each point is averaged with the stretch +/// of path within the reach on either side of it. VectorCraft's own value. +const SMOOTH_SPREAD: f64 = 1.0 / 3.0; + +/// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of +/// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the +/// smoothing moved it, keeping a corner only where the path turns back on itself. +fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions { + SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true } +} + +/// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. +fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() }); + let fitted = po::simplify_with(&piece, &refit_options(tol, sigma)); let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; let (f, l) = (anchors[0], anchors[anchors.len() - 1]); let last = out.len() - 1; @@ -25,7 +37,7 @@ fn refit_run(anchors: &[Anchor], tol: f64) -> Vec { out } -fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option { +fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option { let n = sp.anchors.len(); let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); if n < 3 || !marked.iter().any(|m| *m) { @@ -33,11 +45,8 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio } if sp.closed { let Some(k) = marked.iter().position(|m| !m) else { - // Everything is in range: smooth the whole closed path. - let f = po::simplify_with( - &PathData::single(sp.clone()), - &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() }, - ); + // Everything is in range: smooth the whole closed path, across its start too. + let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma)); return f.subpaths.into_iter().next(); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. @@ -48,14 +57,14 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol); + let mut res = smooth_open(&open.anchors, &m, near, tol, sigma); let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } return Some(SubPath::new(res, true)); } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false)) + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false)) } /// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it @@ -84,10 +93,11 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez (hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0))) } -/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments -/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under -/// it, and the rest of a long segment next to it stays as it was (#1512). -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec { +/// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted +/// within `tol`, together with the stretch of the segments leading into the run that is in the +/// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next +/// to it stays as it was (#1512). +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -127,7 +137,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool head = Some(Anchor { h_out: far.p1, ..*outer }); *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); } - let new = refit_run(&piece, tol); + let new = refit_run(&piece, tol, sigma); out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out @@ -138,6 +148,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { let pts = points_param(p, "points", C)?; let radius = number_or(p, "radius", 12.0, C)?.max(0.0); let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); + let sigma = radius * SMOOTH_SPREAD; let st = s.doc()?; let near = |q: Point| dist_to_polyline(&pts, q) <= radius; let mut plan = vec![]; @@ -148,7 +159,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { let subs: Vec = pd .subpaths .iter() - .map(|sp| match smooth_subpath(sp, &near, tol) { + .map(|sp| match smooth_subpath(sp, &near, tol, sigma) { Some(n) if n != *sp && n.anchors.len() >= 2 => { changed = true; n diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f3befb3462..990ac1a1c6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -678,12 +678,22 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result { /// under an interaction that would then preview on, or cancel to, the document from before (#1719). /// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the /// document. A preview that Real-time Drawing off left unapplied is applied first, as the tool's own -/// commit applies it; when it fails, the interaction is canceled. +/// commit applies it; when it fails, the interaction is canceled. The applied preview's warning +/// goes to the running command's result or the tool's status messages, as the tool's own commit +/// reports it ([`Session::note_settled_warning`]). pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> { let typing = typing_in_progress(s); match s.unapplied_interaction_preview().filter(|_| !s.previewing) { - Some((cmd, p)) if s.preview(&cmd, &p).is_err() => s.cancel_interaction()?, - _ => s.commit_interaction()?, + Some((cmd, p)) => match s.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) { + s.note_settled_warning(w); + } + s.commit_interaction()? + } + Err(_) => s.cancel_interaction()?, + }, + None => s.commit_interaction()?, } if typing { s.set_tool_option("endTyping", &Value::Bool(true)); @@ -1086,7 +1096,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { subpaths.extend(super::path::without_segments(sp, &cuts)); } else { // Picked anchors leave the path with their connections, as Clear removes them. - let gone: BTreeSet = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect(); + let gone: BTreeSet = anchor_refs.into_iter().flat_map(|refs| super::path::subpath_refs(refs, si)).collect(); subpaths.extend(super::path::without_anchors(sp, &gone)); } } diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 3365a1b764..3f95ae0518 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -1,5 +1,5 @@ //! Window › 3D and Materials › Materials › Graphics: art kept as a document symbol (Add as Single -//! Graphic) and mapped onto the planar faces of 3D objects (Extrude & Bevel's caps, a plane). The +//! Graphic) and mapped onto 3D objects (Extrude & Bevel's caps, a plane, Revolve's side). The //! graphics an object shows are the `graphics` parameter of its 3D effect, which `effect.setParams` //! also edits. @@ -28,7 +28,7 @@ pub(super) fn specs() -> Vec { "Map Graphic", [], None, - "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane), scale?: % (1..10000; default 100, or less so the graphic fits inside the object), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it; every object's 3D effect must be Extrude & Bevel (on a solid cap) or Rotate (the Plane type); one undo step → {ids, graphic}", + "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind; on Revolve the front is its swept side, which is the only face), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane; on Revolve from the middle of the side unrolled flat, x around the axis and y along the profile), scale?: % (1..10000; default 100, or less so the graphic fits inside the object, on Revolve inside the half of the side seen from the front), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it (on Revolve wrapped around the side, and drawn only where its outside faces the viewer); every object's 3D effect must be Extrude & Bevel (on a solid cap), Rotate (the Plane type) or Revolve; one undo step → {ids, graphic}", has_doc, map_graphic ), @@ -111,7 +111,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); }; if !effects::takes_graphics(&effect.id) { - return Err(bad(C, format!("object {}: Graphics map onto planar faces: use the Extrude or Plane object type", id.0))); + return Err(bad(C, format!("object {}: {}", id.0, effects::GRAPHICS_NOT_TAKEN))); } // A hollow Extrude & Bevel keeps the graphics it has, to show when Cap is turned on again, // but has no cap to show a new one on. diff --git a/crates/engine/src/cmd/effectcmd/graphics_tests.rs b/crates/engine/src/cmd/effectcmd/graphics_tests.rs index 125ba82514..a9d3a754ce 100644 --- a/crates/engine/src/cmd/effectcmd/graphics_tests.rs +++ b/crates/engine/src/cmd/effectcmd/graphics_tests.rs @@ -68,12 +68,37 @@ fn a_large_graphic_starts_inside_its_object() { assert!((scale - 100.0 * 100.0 / 401.0).abs() < 1e-9, "{scale}"); } +#[test] +fn map_graphic_puts_a_graphic_on_the_side_of_a_revolve() { + let (mut s, id) = setup("threeD.revolve"); + let plain = vectorcraft_render::effects::revolve_art_in(Some(&s.doc().unwrap().doc), s.doc().unwrap().doc.node(id).unwrap()); + let history = undo_len(&s); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap(); + assert_eq!(graphics(&s, id), json!([{"symbol": "Mark", "face": "front", "x": 0.0, "y": 0.0, "scale": 100.0, "rotation": 0.0}])); + assert_eq!(undo_len(&s), history + 1); + let d = &s.doc().unwrap().doc; + assert_ne!(vectorcraft_render::effects::revolve_art_in(Some(d), d.node(id).unwrap()), plain, "the graphic is drawn"); + // A large graphic starts inside the half of the side seen from the front: 100π around the + // axis (the square's far edge is 100 from it) and 300 along the profile (its edge on the axis + // sweeps nothing). + let big = s.execute("path.create", &json!({"d":"M0 0L400 0L400 200L0 200Z"})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Big", "ids": [big]})).unwrap(); + s.execute("threeD.mapGraphic", &json!({"symbol": "Big", "ids": [id.0]})).unwrap(); + let scale = graphics(&s, id)[1]["scale"].as_f64().unwrap(); + assert!((scale - 100.0 * 100.0 * std::f64::consts::PI / 401.0).abs() < 1e-6, "{scale}"); + // The side is its only face. + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "face": "back", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("side"), "{e}"); + assert_eq!(undo_len(&s), history); +} + #[test] fn map_graphic_refuses_what_cannot_take_it_and_changes_nothing() { let (mut s, id) = setup("threeD.inflate"); let history = undo_len(&s); let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap_err().to_string(); - assert!(e.contains("Extrude or Plane"), "{e}"); + assert!(e.contains("Plane, Extrude and Revolve"), "{e}"); let (mut s, id) = setup("threeD.rotate"); for (p, says) in [ (json!({"symbol": "Gone"}), "no symbol named"), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 0f55386830..352e520433 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -18,8 +18,8 @@ use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::live::GradientMesh; use vectorcraft_doc::pattern::{PatternDef, place_art}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; -use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling, TextObject}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, SubPath, Vec2, shapes}; use super::dimension::EndedLink; use super::edit::selected_roots; @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Expand Options", [], None, - "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", + "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", has_selection, |s, _| { let roots = selected_roots(s)?; @@ -197,7 +197,7 @@ impl Expandable { // Once one is found, the next path is not measured. e.gradient = e.gradient || match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), + NodeKind::Text(t) => text_gradient_expands(d, t), _ => shape && gradient_expands(d, c), }; e.fill |= e.gradient; @@ -247,6 +247,34 @@ fn gradient_expands(d: &Document, l: &Node) -> bool { any } +/// Whether type `t` has a gradient fill that Fill expands once Object has outlined it: one of the +/// paths `type.createOutlines` makes of its glyphs, ruby glyphs and underline and strikethrough +/// bars, each painted as its run, has one ([`gradient_expands`] on that path's own box). Inline +/// graphics and color glyphs become other art. Leaves the document as it is and stops at the +/// first such path. +fn text_gradient_expands(d: &Document, t: &TextObject) -> bool { + if !t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))) { + return false; + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let expands = |run: usize, outline: &BezPath| { + let style = t.runs.get(run).map(|r| &r.style); + if !matches!(style.map_or_else(|| t.first_style().fill, |s| s.fill.clone()), Paint::Gradient(_)) { + return false; + } + let path = PathData::from_bezpath(outline).transformed(t.xf); + if path.is_empty() { + return false; + } + let appearance = style.map_or_else(|| t.first_style().appearance(), |s| s.appearance()); + gradient_expands(d, &Node::path(NodeId(u64::MAX), path, appearance)) + }; + let inline = |gi: usize| lay.inlines.iter().find(|i| i.glyph == gi).is_some_and(|i| t.runs.get(i.run).is_some_and(|r| r.inline.is_some())); + lay.glyphs.iter().enumerate().any(|(gi, g)| !inline(gi) && g.image.is_none() && g.colour.is_none() && expands(g.run, &g.outline)) + || lay.ruby.iter().any(|g| expands(g.run, &g.outline)) + || vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar)) +} + /// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]). fn piece_gradient_expands(d: &Document, l: &Node) -> bool { if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) { diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 18bc78b5d9..0974934ded 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -41,6 +41,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { roll_back(s, start, &stash); s.notify_text_tool("batch.rollBack"); s.active = active; + s.sync_front_profiles(); (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved; s.pending_paint = None; return Err(e); @@ -78,8 +79,11 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { /// `artboard`, as the Artboards panel shows it in the document the step runs in: past that /// document's last artboard, the last one; a Paste in Place, in Front or in Back step that names /// `artboards` keeps them, and one that names neither, while the Artboard tool is chosen with -/// several artboards selected, pastes on those. A step that resizes the active artboard -/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`. +/// several artboards selected, pastes on those. A Fit to Artwork Bounds step that names neither +/// `index` nor `indices`, while the Artboard tool is chosen with several artboards selected when the +/// step runs, gets those as its `indices`, which its journal entry records. Otherwise a step that +/// resizes the active artboard ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` (nor +/// `indices`, for Fit to Artwork Bounds) gets that artboard as its `index`. /// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them. fn run_steps( s: &mut Session, @@ -110,9 +114,22 @@ fn run_steps( // Artboard tool is chosen with several artboards selected, pastes on those. let names_artboards = id.starts_with("edit.pasteIn") && (params.get("artboards").is_some() || (params.get("artboard").is_none() && !crate::cmd::edit::artboard_tool_picks(s).is_empty())); + // A Fit to Artwork Bounds step that names neither `index` nor `indices`, while the Artboard + // tool is chosen with several artboards selected, fits those, as the command does on its own. + let fit_picks = if id == "artboard.fitToArt" && params.get("index").is_none() && params.get("indices").is_none() { + crate::cmd::edit::artboard_tool_picks(s) + } else { + Vec::new() + }; + if !fit_picks.is_empty() + && let Some(o) = params.as_object_mut() + { + o.insert("indices".into(), json!(fit_picks)); + } if let Some(a) = artboard && let Some(key) = key && params.get(key).is_none() + && !(id == "artboard.fitToArt" && params.get("indices").is_some()) && !names_artboards && let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1)) && let Some(o) = params.as_object_mut() @@ -129,8 +146,9 @@ fn run_steps( } grouped.push(step_groups(s, start, &held)); // The step's journal entry records what it took from the preferences or the clock (a - // save's date, a new document's), as a command of its own would. - if noted != params + // save's date, a new document's), as a command of its own would, and the artboards a Fit to + // Artwork Bounds step took from the Artboard tool. + if (noted != params || !fit_picks.is_empty()) && let Some(c) = c.as_object_mut() { c.insert("params".into(), noted); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 26272d873d..9e95948def 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Batch", [], None, - "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", + "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer; an artboard.fitToArt step that names neither index nor indices, while the Artboard tool is chosen with several artboards selected when the step runs, gets those as indices instead, and the journal records them)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 31539ff51d..0fbffcea82 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -629,6 +629,13 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { { return Ok(Encoded::one_with_notes(bytes, notes)); } + // So does a `.ai` file of the art's bounds (a legacy version 7 `.ai` file has none to leave out). + if use_artboards == Some(false) + && f.id == "ai" + && let Some(enc) = super::save::encode_ai_art_page(C, f, doc, p)? + { + return Ok(enc); + } if use_artboards == Some(false) && let Some((art, q)) = art_page(C, doc, p)? { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index e9f6ecf3d4..bb9dc3711a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -868,9 +868,12 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. -/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is +/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`vectorcraft_doc::cmyk::exif_orientation`]) turns or flips it is /// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG -/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). +/// or TIFF keeps its inks, at the 8 or 16 bits the file has, and its CMYK ICC profile as a CMYK +/// TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). An image the decoder reads and that is stored as +/// PNG keeps its gray or RGB samples at 8 or 16 bits, and the gray or RGB ICC profile they are in; +/// other samples become 8-bit RGBA. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -909,7 +912,7 @@ pub fn raster_image(bytes: &[u8]) -> Result { let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is // seen, so it places the way it was taken (#4284); the others keep their own bytes. - let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); + let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| vectorcraft_doc::cmyk::exif_orientation(bytes)).flatten(); let turned = orientation.is_some(); // The resolution describes the stored pixels; a quarter turn swaps its axes. let quarter = orientation.is_some_and(|o| o.to_exif() >= 5); @@ -933,47 +936,60 @@ pub fn raster_image(bytes: &[u8]) -> Result { let cms = vectorcraft_color::cms::active(); vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; + // The ICC profile of the samples the image decoder reads; the other readers give sRGB pixels. + let mut icc = None; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let decoder = reader.into_decoder().map_err(err)?; + let mut decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } + // A profile the decoder can't read leaves the image without one. The image decoder + // reads a TIFF's tags within the size of its pixels, so a small TIFF's profile is + // read by the TIFF decoder itself. + icc = match f.id { + "tiff" => vectorcraft_doc::cmyk::tiff_icc_profile(bytes), + _ => decoder.icc_profile().ok().flatten(), + }; image::DynamicImage::from_decoder(decoder).map_err(err)? } }, }; img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms)); - let mut img = img.into_rgba8(); if f.id == "tga" { + let mut img = img.into_rgba8(); super::tgaread::fix_alpha(bytes, &mut img); + return as_png(img, None, shown_ppi); } - as_png(img, None, shown_ppi) + use image::DynamicImage as D; + let img = match img { + D::ImageLuma8(_) | D::ImageLumaA8(_) | D::ImageRgb8(_) | D::ImageRgba8(_) => img, + D::ImageLuma16(_) | D::ImageLumaA16(_) | D::ImageRgb16(_) | D::ImageRgba16(_) => img, + other => D::ImageRgba8(other.into_rgba8()), + }; + let icc = icc.filter(|icc| png_takes_icc(&img, icc)); + as_png(img, icc, shown_ppi) } -/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when -/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with -/// extra samples) has its Orientation tag read from its first image -/// ([`vectorcraft_doc::cmyk::tiff_orientation`]). -pub(crate) fn exif_orientation(bytes: &[u8]) -> Option { - use image::ImageDecoder as _; - use image::metadata::Orientation; - let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); - let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); - decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) +/// Can a PNG of `img`'s samples carry ICC profile `icc`? Its color space must be the samples': +/// `GRAY` for gray samples, `RGB ` for RGB ones. +fn png_takes_icc(img: &image::DynamicImage, icc: &[u8]) -> bool { + let want = if img.color().has_color() { *b"RGB " } else { *b"GRAY" }; + vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want) } /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its -/// colors are in. -pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { +/// colors are in. `img` has samples a PNG holds: 8 or 16 bits of gray or RGB, with or without alpha. +pub(super) fn as_png(img: impl Into, icc: Option>, ppi: Option<(f64, f64)>) -> Result { use image::ImageEncoder as _; - let size = img.dimensions(); + let img: image::DynamicImage = img.into(); + let size = (img.width(), img.height()); let mut png = Vec::new(); let mut encoder = image::codecs::png::PngEncoder::new(&mut png); if let Some(icc) = icc { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index acc22c7876..ec7d43472d 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -98,8 +98,8 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ FormatOption { name: "longPagesInPoints", ty: "boolean", - default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", + default: "true", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit); false writes it with a UserUnit from PDF 1.6 on", }, FormatOption { name: "createLayers", @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index e1cbf68bf2..6a2a0986a3 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -685,6 +685,31 @@ struct AiEditingData { editing_data: Option, } +/// Why a `.ai` file of the art's bounds (`useArtboards: false`) carries no editing data. +pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str = + "Preserve editing was left out: an .ai file of the art's bounds would replace the document's artboards when reopened"; + +/// The error for a `.ai` file of the art's bounds without PDF content. +pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false"; + +/// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of +/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor +/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`]. +/// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy +/// version 7 `.ai` file. +pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + if super::encode::use_artboards(cmd, p)? != Some(false) || super::legacy_ai::wanted(cmd, f, p)? { + return Ok(None); + } + if !bool_or(p, "pdfCompatible", true) { + return Err(bad(cmd, ART_PAGE_NEEDS_PDF)); + } + let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?; + enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string()); + Ok(Some(enc)) +} + /// The shared Save and Export encoder for `.ai` files (and `.ait` templates, the same file): /// PDF-compatible by default, without PDF content (`pdfCompatible: false`), or legacy version 7 /// `.ai` files (`aiVersion: "7"`). @@ -692,13 +717,23 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res if super::legacy_ai::wanted(cmd, f, p)? { return super::legacy_ai::encode(cmd, f, doc, p); } + encode_ai_pages(cmd, f, doc, p, true) +} + +/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and +/// the editing copy for other apps when `editing` is true, and neither otherwise. +fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result { let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { - o.insert("preserveEditing".into(), json!(true)); + o.insert("preserveEditing".into(), json!(editing)); // Keep upstream AI PDF layers, including hidden layers, unless a standard decides. if o.get("standard").is_none_or(Value::is_null) { o.entry("createLayers").or_insert(json!(true)); } + // A page longer than 14,400 pt keeps its `UserUnit` (PDF export writes it in points). + if o.get("longPagesInPoints").is_none_or(Value::is_null) { + o.insert("longPagesInPoints".into(), json!(false)); + } if let Some(c) = o.get("compress").and_then(Value::as_bool) { let compression = o.entry("compression").or_insert_with(|| json!({})); if let Some(m) = compression.as_object_mut() { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 18bb4853b1..71c1bd06e5 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -1171,6 +1171,216 @@ fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { } } +/// A made-up ICC profile of `len` bytes (at least a header's 128) whose header names the color +/// space `space`; the bytes after the header differ from place to place. +fn icc_profile(space: &[u8; 4], len: usize) -> Vec { + let mut icc: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + icc[..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(space); + icc[36..40].copy_from_slice(b"acsp"); + icc +} + +/// `tiff` (little-endian) with an ICCProfile tag (34675, of type UNDEFINED) holding `icc`: its first +/// directory copied to the end of the file with the tag added in order, as [`tiff_oriented`] adds +/// its tag. +fn tiff_with_icc(tiff: &[u8], icc: &[u8]) -> Vec { + let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap(); + let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]])); + let mut entries: Vec> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect(); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + let data = u32::try_from(out.len()).unwrap(); + out.extend(icc); + if out.len() % 2 == 1 { + out.push(0); + } + let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 34675).unwrap_or(n); + let count = u32::try_from(icc.len()).unwrap(); + entries.insert(i, [&34675u16.to_le_bytes()[..], &7u16.to_le_bytes(), &count.to_le_bytes(), &data.to_le_bytes()].concat()); + let ifd = u32::try_from(out.len()).unwrap(); + out[4..8].copy_from_slice(&ifd.to_le_bytes()); + out.extend(u16::try_from(n + 1).unwrap().to_le_bytes()); + out.extend(entries.concat()); + out.extend([0; 4]); + out +} + +/// The ICC profile and pixels of a stored image, read by the image decoder for its format. +fn stored_pixels(what: &str, img: &RasterImage) -> (Option>, image::ExtendedColorType, image::DynamicImage) { + use image::ImageDecoder as _; + let bytes = Cursor::new(img.blob.bytes.as_slice()); + let mut d: Box = match img.blob.mime.as_str() { + "image/png" => Box::new(image::codecs::png::PngDecoder::new(bytes).unwrap()), + "image/tiff" => Box::new(image::codecs::tiff::TiffDecoder::new(bytes).unwrap()), + other => panic!("{what}: stored as {other}"), + }; + let icc = d.icc_profile().unwrap(); + let color = d.original_color_type(); + (icc, color, image::DynamicImage::from_decoder(d).unwrap()) +} + +/// An image turned by its orientation and stored as PNG keeps the gray or RGB ICC profile its file +/// carries, and its 16-bit samples at 16 bits, from a PNG, a JPEG, a WebP and a TIFF; a TIFF the +/// image decoder reads, stored as PNG without a turn, keeps them too. A profile of another color +/// space than the samples' is left out. +#[test] +fn images_stored_as_png_keep_their_icc_profile_and_16_bit_samples() { + use image::{DynamicImage, ImageEncoder as _}; + let (rgb_icc, gray_icc, cmyk_icc) = (icc_profile(b"RGB ", 600), icc_profile(b"GRAY", 300), icc_profile(b"CMYK", 400)); + // 3 × 2, every sample's low byte different. + let rgb16 = image::ImageBuffer::from_fn(3, 2, |x, y| { + let v = u16::try_from(x * 0x1111 + y * 0x2345).unwrap(); + image::Rgb([0x1000 + v, 0x8001 + v, 0xF0F3 - v]) + }); + let rgb16 = DynamicImage::ImageRgb16(rgb16); + let gray16 = DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(3, 2, |x, y| image::Luma([u16::try_from(x * 0x3001 + y * 0x0107).unwrap()]))); + let rgb8 = DynamicImage::ImageRgb8(image::RgbImage::from_fn(3, 2, |x, y| image::Rgb([u8::try_from(x * 70 + y * 9).unwrap(), 200, 13]))); + let png = |img: &DynamicImage, icc: &[u8], orientation: u16| { + let mut out = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut out); + enc.set_icc_profile(icc.to_vec()).unwrap(); + enc.set_exif_metadata(exif_block(orientation, (150, 150))).unwrap(); + img.write_with_encoder(enc).unwrap(); + out + }; + let webp = { + let mut out = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + enc.set_exif_metadata(exif_block(6, (150, 150))).unwrap(); + rgb8.write_with_encoder(enc).unwrap(); + out + }; + let tiff = |orientation: Option| { + let mut out = Vec::new(); + let mut enc = image::codecs::tiff::TiffEncoder::new(Cursor::new(&mut out)); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + rgb16.write_with_encoder(enc).unwrap(); + match orientation { + Some(o) => tiff_oriented(&out, o, false, None), + None => out, + } + }; + // (what, file, the ICC profile to keep, the pixels as they show, 16 bits) + type Case<'a> = (&'a str, Vec, Option<&'a [u8]>, DynamicImage, bool); + let cases: Vec = vec![ + ("16-bit RGB PNG, orientation 6", png(&rgb16, &rgb_icc, 6), Some(&rgb_icc), rgb16.rotate90(), true), + ("16-bit gray PNG, orientation 8", png(&gray16, &gray_icc, 8), Some(&gray_icc), gray16.rotate270(), true), + ("8-bit RGB PNG, orientation 3", png(&rgb8, &rgb_icc, 3), Some(&rgb_icc), rgb8.rotate180(), false), + ("RGB PNG with a CMYK profile, orientation 6", png(&rgb8, &cmyk_icc, 6), None, rgb8.rotate90(), false), + ("WebP, orientation 6", webp, Some(&rgb_icc), rgb8.rotate90(), false), + ("16-bit RGB TIFF, not turned", tiff(None), Some(&rgb_icc), rgb16.clone(), true), + ("16-bit RGB TIFF, orientation 6", tiff(Some(6)), Some(&rgb_icc), rgb16.rotate90(), true), + ]; + for (what, file, icc, want, wide) in cases { + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (want.width(), want.height(), "image/png"), "{what}"); + let (kept, color, px) = stored_pixels(what, &img); + assert_eq!(kept.as_deref(), icc, "{what}: the ICC profile"); + assert_eq!(color.bits_per_pixel() / u16::from(color.channel_count()), if wide { 16 } else { 8 }, "{what}: the sample depth"); + if wide { + assert_eq!(px.to_rgb16(), want.to_rgb16(), "{what}: the samples"); + } else { + assert_eq!(px.to_rgb8(), want.to_rgb8(), "{what}: the samples"); + } + } + // A JPEG's samples change in encoding; its profile is kept. + let mut jpg = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpg, 95); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + rgb8.write_with_encoder(enc).unwrap(); + let exif = [b"Exif\0\0".as_slice(), &exif_block(6, (150, 150))].concat(); + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let img = raster_image(&turned).unwrap(); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (2, 3, "image/png"), "JPEG"); + assert_eq!(stored_pixels("JPEG", &img).0, Some(rgb_icc.clone()), "JPEG: the ICC profile"); + // Not turned, a PNG keeps its own bytes. + let plain = png(&rgb16, &rgb_icc, 1); + assert_eq!(raster_image(&plain).unwrap().blob.bytes.as_slice(), plain.as_slice(), "a PNG as taken keeps its bytes"); +} + +/// A CMYK TIFF stored again, because it stores one plane per ink or because its orientation turns +/// it, keeps its ICC profile as tag 34675 and its 8 or 16 bits a sample, exactly; one that stores +/// its inks together and isn't turned keeps its own bytes. +#[test] +fn cmyk_tiffs_written_again_keep_their_icc_profile_and_depth() { + use image::DynamicImage; + let icc = icc_profile(b"CMYK", 900); + // 3 × 2, no black, every sample's low byte different. + let ink = |c: usize, p: usize| -> u16 { u16::try_from(0x0101 * (c * 5 + p + 1) + 0x10 * p + c).unwrap() }; + for bits in [8u16, 16] { + let sample = |c: usize, p: usize| match (c, bits) { + (3, _) => 0, + (_, 16) => ink(c, p), + _ => ink(c, p) & 0xFF, + }; + let channels: Vec> = (0..4).map(|c| (0..6).map(|p| sample(c, p)).collect()).collect(); + // The image decoder shows a CMYK pixel without black as 1 - C, 1 - M, 1 - Y, exactly. + let full = if bits == 16 { 0xFFFF } else { 0xFF }; + let shown: Vec = (0..6).flat_map(|p| (0..3).map(|c| full - channels[c][p]).collect::>()).collect(); + let shown = if bits == 16 { + DynamicImage::ImageRgb16(image::ImageBuffer::from_raw(3, 2, shown).unwrap()) + } else { + DynamicImage::ImageRgb8(image::RgbImage::from_raw(3, 2, shown.iter().map(|v| u8::try_from(*v).unwrap()).collect()).unwrap()) + }; + for planar in [false, true] { + let file = tiff_with_icc(&hand_tiff(5, bits, &[], planar, &channels), &icc); + for (orientation, want) in [(None, shown.clone()), (Some(6), shown.rotate90()), (Some(3), shown.rotate180())] { + let what = format!("{bits} bits, planar {planar}, orientation {orientation:?}"); + let file = orientation.map_or_else(|| file.clone(), |o| tiff_oriented(&file, o, false, Some(150))); + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + if orientation.is_none() && !planar { + assert_eq!(img.blob.bytes.as_slice(), file.as_slice(), "{what}: kept as it is"); + } + let (kept, color, px) = stored_pixels(&what, &img); + assert_eq!(kept.as_deref(), Some(icc.as_slice()), "{what}: the ICC profile"); + let depth = if bits == 16 { image::ExtendedColorType::Cmyk16 } else { image::ExtendedColorType::Cmyk8 }; + assert_eq!(color, depth, "{what}: the sample depth"); + assert_eq!(px, want, "{what}: the samples"); + } + } + } +} + +/// A CMYK JPEG turned by its orientation and stored as a CMYK TIFF keeps its whole ICC profile, +/// put together from the two APP2 segments a profile larger than one segment is split into. +#[test] +fn a_turned_cmyk_jpeg_keeps_its_icc_profile_from_every_segment() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + let icc = icc_profile(b"CMYK", 70_000); + let inks: Vec = (0..16u32 * 8).flat_map(|i| [u8::try_from(i).unwrap(), 0, 0, 40]).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 16, 8, 100, None, &o).unwrap(); + let exif = [b"Exif\0\0".as_slice(), &exif_block(6, (300, 300))].concat(); + let mut file = jpg[..2].to_vec(); + file.extend([0xff, 0xe1]); + file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + file.extend(&exif); + let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); + assert_eq!(chunks.len(), 2, "the profile takes two segments"); + for (i, chunk) in chunks.iter().enumerate() { + file.extend([0xff, 0xe2]); + file.extend(u16::try_from(chunk.len() + 16).unwrap().to_be_bytes()); + file.extend(b"ICC_PROFILE\0"); + file.extend([u8::try_from(i + 1).unwrap(), 2]); + file.extend(*chunk); + } + file.extend(&jpg[2..]); + let img = raster_image(&file).unwrap(); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (8, 16, "image/tiff")); + let (kept, color, _) = stored_pixels("CMYK JPEG", &img); + assert_eq!(color, image::ExtendedColorType::Cmyk8); + assert!(kept.as_deref() == Some(icc.as_slice()), "the whole ICC profile: {:?} bytes", kept.map(|k| k.len())); +} + /// With `importer` named, a file up to the importer's limit is read whole and a larger one is an /// error, whatever its content. #[test] diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 2970238e38..0b560e5e04 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -402,28 +402,75 @@ fn ai_export_validates_options_even_when_editing_data_is_omitted() { } } -#[test] -fn ai_export_without_artboards_keeps_art_outside_the_original_pages() { +/// Two artboards, a visible 30 × 20 pt rectangle outside them at (180, 100) and a hidden one. +fn art_outside_the_artboards() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 2})).unwrap(); s.execute("paint.setStroke", &json!({"none": true})).unwrap(); s.execute("shape.rectangle", &json!({"x": 180, "y": 100, "width": 30, "height": 20})).unwrap(); s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 50, "height": 40})).unwrap(); s.execute("object.hide", &json!({})).unwrap(); + s +} + +/// Whether the `.ai` file `bytes` carries the editing copy of its art for other apps. +fn carries_private_data(bytes: &[u8]) -> bool { + let opts = LoadOptions { native: false, ..Default::default() }; + load::load_with("a.ai", bytes, &opts).unwrap().editing_data +} + +#[test] +fn an_ai_of_the_arts_bounds_carries_no_editing_data() { + let mut s = art_outside_the_artboards(); let before = comparable(&s.doc().unwrap().doc); - let r = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "range": "2"})).unwrap(); - let bytes = b64(&r); - let pages = vectorcraft_pdf::import(&bytes).unwrap(); - assert_eq!(pages.artboards.len(), 1); - assert_eq!((pages.artboards[0].rect.width(), pages.artboards[0].rect.height()), (30.0, 20.0)); - let editing = vectorcraft_pdf::editing(&bytes).unwrap(); - let exported = vectorcraft_format::load(&editing.data).unwrap(); - assert_eq!(exported.artboards.len(), 1); - assert_eq!(exported.artboards[0].rect, vectorcraft_geom::Rect::new(180.0, 100.0, 210.0, 120.0)); - assert_eq!(comparable(&s.doc().unwrap().doc), before); + let left_out = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w == super::save::AI_EDITING_NEEDS_THE_ARTBOARDS); + let path = tmp("art-bounds.ai"); + for extra in [json!({}), json!({"range": "2"}), json!({"editingData": false}), json!({"path": path})] { + let mut params = json!({"format": "ai", "useArtboards": false}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let r = s.execute("document.export", ¶ms).unwrap(); + let bytes = if params.get("path").is_some() { std::fs::read(&path).unwrap() } else { b64(&r) }; + // One page, the visible art's bounds, whatever range names. + let pages = vectorcraft_pdf::import(&bytes).unwrap(); + assert_eq!(pages.artboards.len(), 1, "{params}"); + assert_eq!((pages.artboards[0].rect.width(), pages.artboards[0].rect.height()), (30.0, 20.0), "{params}"); + assert!(vectorcraft_pdf::editing(&bytes).is_none(), "{params}"); + assert!(!carries_private_data(&bytes), "{params}"); + assert!(left_out(&r), "{params}: {r}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before); + } + // The artboards keep both editing copies, without the warning. + for params in [json!({"format": "ai"}), json!({"format": "ai", "useArtboards": true})] { + let r = s.execute("document.export", ¶ms).unwrap(); + let bytes = b64(&r); + let editing = vectorcraft_pdf::editing(&bytes).expect("editing data"); + assert_eq!(vectorcraft_format::load(&editing.data).unwrap().artboards.len(), 2, "{params}"); + assert!(carries_private_data(&bytes), "{params}"); + assert!(!left_out(&r), "{params}: {r}"); + } assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err()); } +#[test] +fn an_ai_of_the_arts_bounds_without_pdf_is_refused() { + let mut s = art_outside_the_artboards(); + let before = comparable(&s.doc().unwrap().doc); + for extra in [json!({}), json!({"editingData": false}), json!({"editingData": true})] { + for command in ["document.export", "document.serialize"] { + let mut params = json!({"format": "ai", "useArtboards": false, "pdfCompatible": false}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains(super::save::ART_PAGE_NEEDS_PDF), "{command} {params}: {e}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before); + } + } + // A document with no visible art is still refused for that. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false})).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); +} + /// Color Halftone's Max Radius is in pixels of the raster effects resolution since document /// version 3 (#3260). A PDF saved with Preserve Editing carries the native document, so one from /// before that opens with the radius converted, drawing the same dots; one saved since opens as diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index 4ad89c773b..0adb06132a 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -135,6 +135,9 @@ fn touch(a: &Rect, b: &Rect) -> bool { struct Sets { parent: Vec, seeded: Vec, + /// How many open boxes the sweep's boxes met. + #[cfg(test)] + meets: usize, } impl Sets { @@ -180,17 +183,25 @@ impl Sets { /// tops that knows the lowest bottom below each of its branches, so each box meets only the open /// boxes it overlaps, not every open one (many objects stacked in a column, sharing their /// horizontal extent, would otherwise compare every pair). An open box inside a later box of -/// its group, ending no further right, is closed early: whatever overlaps it overlaps that box. -/// Fails only when cancelled ([`vectorcraft_doc::cancel`]). +/// its group, ending no further right, is closed early, and a box inside an open box of its group, +/// ending no further right, isn't opened: whatever overlaps it overlaps that box. Fails only when +/// cancelled ([`vectorcraft_doc::cancel`]). pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { Ok(sweep(bounds, None)?.groups()) } /// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is -/// see-through, and an open box is closed early only inside a later see-through box. +/// see-through, and an open box is closed early, or a box isn't opened, only inside a see-through +/// box. The open +/// see-through boxes and the open boxes without transparency are then in trees of their own, and +/// a box without transparency meets only the see-through ones. fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { - let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() }; - let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]); + let mut sets = Sets { + parent: (0..bounds.len()).collect(), + seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect(), + #[cfg(test)] + meets: 0, + }; let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect(); let n = boxes.len(); // Each box's place in the tree: by top (then index). @@ -201,9 +212,11 @@ fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result], see_through: Option<&[bool]>) -> Result &[0], + Some(_) if shadows => &[0, 1], + Some(_) => &[1], + }; let below = tops.partition_point(|t| *t <= b.y1); met.clear(); - stack.clear(); - stack.push((1, 0, size)); - while let Some((node, lo, hi)) = stack.pop() { - work += 1; - if work.is_multiple_of(4096) { - vectorcraft_doc::cancel::check()?; + for &t in looks { + stack.clear(); + stack.push((1, 0, size)); + while let Some((node, lo, hi)) = stack.pop() { + work += 1; + if work.is_multiple_of(4096) { + vectorcraft_doc::cancel::check()?; + } + if lo >= below || low[t][node] < b.y0 { + continue; + } + if hi - lo == 1 { + met.push(by_top[lo]); + continue; + } + let mid = (lo + hi) / 2; + stack.push((2 * node + 1, mid, hi)); + stack.push((2 * node, lo, mid)); } - if lo >= below || low[node] < b.y0 { - continue; - } - if hi - lo == 1 { - met.push(by_top[lo]); - continue; - } - let mid = (lo + hi) / 2; - stack.push((2 * node + 1, mid, hi)); - stack.push((2 * node, lo, mid)); } + #[cfg(test)] + { + sets.meets += met.len(); + } + // An open box it met that holds it, ending no further right, and that joins whatever it + // overlaps: whatever later overlaps this box overlaps that one and joins it, so this box + // isn't opened. + let holder = met.iter().copied().find(|&o| { + let ob = boxes[o].1; + see_through.is_none_or(|s| s[boxes[o].0]) && b.x1 <= ob.x1 && ob.y0 <= b.y0 && b.y1 <= ob.y1 + }); // Whatever overlaps a box inside this one overlaps this one, and joins it when this one is // see-through. - let shadows = see_through.is_none_or(|s| s[boxes[k].0]); for &o in &met { - if joins(boxes[k].0, boxes[o].0) { - sets.join(boxes[k].0, boxes[o].0); - } + sets.join(boxes[k].0, boxes[o].0); let ob = boxes[o].1; - if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { - set(&mut low, leaf[o], f64::NEG_INFINITY); + if shadows && Some(o) != holder && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { + set(&mut low[tree(o)], leaf[o], f64::NEG_INFINITY); } } - set(&mut low, leaf[k], b.y1); + if holder.is_none() { + set(&mut low[tree(k)], leaf[k], b.y1); + } } Ok(sets) } @@ -392,6 +425,11 @@ mod tests { /// What [`overlapping`] gives, comparing every pair. fn every_pair(bounds: &[Option]) -> Vec> { + every_pair_joining(bounds, |_, _| true) + } + + /// The groups of `bounds` comparing every pair, joining overlapping pairs that `joins`. + fn every_pair_joining(bounds: &[Option], joins: impl Fn(usize, usize) -> bool) -> Vec> { let n = bounds.len(); let mut group: Vec = (0..n).collect(); let hit = |a: &Rect, b: &Rect| a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1; @@ -401,6 +439,7 @@ mod tests { for j in 0..n { if let (Some(a), Some(b)) = (bounds[i], bounds[j]) && hit(&a, &b) + && joins(i, j) && group[j] < group[i] { group[i] = group[j]; @@ -464,6 +503,69 @@ mod tests { assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]); } + /// `n` boxes sharing their left and top edges, each inside the one before. + fn shrinking_nest(n: usize) -> Vec> { + (0..n).map(|i| Some(Rect::new(0.0, 0.0, 2.0 * (n - i) as f64, 2.0 * (n - i) as f64))).collect() + } + + /// Each box of a nest that shrinks to the right and bottom meets only the outermost box, which + /// stays open and holds it, not every box around it. + #[test] + fn a_shrinking_nest_meets_only_its_outermost_box() { + let n = 512; + let bounds = shrinking_nest(n); + let mut sets = sweep(&bounds, None).unwrap(); + assert_eq!(sets.meets, n - 1); + assert_eq!(sets.groups(), every_pair(&bounds)); + assert_eq!(sets.groups(), vec![(0..n).collect::>()]); + // Equal boxes: the later one isn't opened and the earlier one stays open for the third. + let same = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(9.0, 9.0, 20.0, 20.0))]; + assert_eq!(overlapping(&same).unwrap(), vec![vec![0, 1, 2]]); + // With transparency, an opaque box isn't opened inside a see-through box, and boxes inside + // an opaque box are opened: an opaque box that overlaps only the inner one still joins it + // when that one is see-through. + let see = [true, false]; + let mut sets = sweep(&bounds[..2], Some(&see)).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1]]); + let held = [Some(Rect::new(0.0, 0.0, 20.0, 20.0)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 8.0, 8.0))]; + let mut sets = sweep(&held, Some(&[false, true, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[true, false, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[false, false, true])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[false, false, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0], vec![1], vec![2]]); + } + + /// Boxes nested at shared corners and placed at random, some see-through, group with and + /// without transparency as comparing every pair groups them. + #[test] + fn nested_and_scattered_boxes_group_as_comparing_every_pair() { + let mut seed = 0x51_7cc1_b727_220au64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..40 { + let n = 6 + round * 3; + let bounds: Vec> = (0..n) + .map(|_| { + // Corners on a coarse grid so that edges are often shared and boxes often nest. + let (x, y) = ((next(6.0).floor()) * 10.0, (next(6.0).floor()) * 10.0); + let (w, h) = ((next(5.0).floor() + 1.0) * 10.0, (next(5.0).floor() + 1.0) * 10.0); + Some(Rect::new(x, y, x + w, y + h)) + }) + .collect(); + assert_eq!(overlapping(&bounds).unwrap(), every_pair(&bounds), "round {round}"); + let see: Vec = (0..n).map(|_| next(1.0) < 0.3).collect(); + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + assert_eq!(sets.groups(), every_pair_joining(&bounds, |i, j| see[i] || see[j]), "round {round}, with transparency"); + } + } + /// A filled rectangle, unstroked. fn filled(id: u64, r: Rect) -> Node { let look = vectorcraft_doc::Appearance::basic( @@ -583,6 +685,62 @@ mod tests { assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n); } + /// A small see-through box, then `n` opaque boxes nested around it, each inside the one + /// before and sharing its left and top edges. + fn nest_around_a_see_through_box(n: usize) -> (Vec>, Vec) { + let mut bounds = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0))]; + bounds.extend((0..n).map(|i| Some(Rect::new(0.0, 0.0, 2.0 * (n - i) as f64 + 1.0, 2.0 * (n - i) as f64 + 1.0)))); + let mut see = vec![false; n + 1]; + see[0] = true; + (bounds, see) + } + + /// Opaque boxes nested in each other around a see-through box each meet only the see-through + /// box, not the open opaque boxes they can't join, and all join it. + #[test] + fn nested_opaque_boxes_meet_only_the_see_through_box() { + let n = 512; + let (bounds, see) = nest_around_a_see_through_box(n); + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + assert_eq!(sets.meets, n); + assert_eq!(sets.groups(), vec![(0..=n).collect::>()]); + // The unfiltered sweep that runs before it meets each box once too. + let mut sets = sweep(&bounds, None).unwrap(); + assert_eq!(sets.meets, n); + assert_eq!(sets.groups(), vec![(0..=n).collect::>()]); + let (painted, see) = nest_around_a_see_through_box(40); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + for fringe in [0.0, 0.7] { + let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap(); + check(&reach, &painted, &see, fringe, &groups, &format!("nest, fringe {fringe}")); + assert_eq!(groups, vec![(0..=40).collect::>()]); + } + } + + /// An opaque box inside a later see-through box is closed: later boxes meet the see-through + /// box instead, and join the opaque one through it. + #[test] + fn a_see_through_box_closes_an_opaque_box_inside_it() { + // An opaque box, a see-through box around it, a see-through box over both and an opaque + // box over the first. + let bounds = [ + Some(Rect::new(0.0, 0.0, 10.0, 10.0)), + Some(Rect::new(0.0, 0.0, 20.0, 20.0)), + Some(Rect::new(5.0, 5.0, 30.0, 30.0)), + Some(Rect::new(8.0, 8.0, 12.0, 12.0)), + ]; + let see = [false, true, true, false]; + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + // The second box meets the first; the third meets the second; the fourth meets the second + // and the third. + assert_eq!(sets.meets, 4); + assert_eq!(sets.groups(), vec![vec![0, 1, 2, 3]]); + // Opaque boxes alone meet nothing. + let mut sets = sweep(&bounds, Some(&[false; 4])).unwrap(); + assert_eq!(sets.meets, 0); + assert_eq!(sets.groups().len(), 4); + } + /// An opaque box between a member and its group's top that paints over the member joins, and /// so on along a chain; one under the member stays, but for one within the fringe. #[test] diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 2c42494160..c45bbf5a67 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -46,7 +46,8 @@ use generate::generate; use series::default_series_color; pub(crate) use series::{ capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, - capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint, + capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_object_opacity_differs, + shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 229a4d4b42..99a7ec084b 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -79,6 +79,11 @@ struct SeriesRef { index: usize, } +/// How many series groups `spec`'s graph has: a group whose index is past it (the index comes from the file) is not one. +fn series_count(spec: &GraphSpec) -> usize { + spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES) +} + /// Every node in a graph's series groups (the groups too), found in one pass over the document. /// Axes, the legend and groups whose index is past the graph's series (the index comes from the /// file) are left out. @@ -86,7 +91,7 @@ fn series_members(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { let Some(spec) = n.extras().graph.as_deref() else { return }; - let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + let count = series_count(spec); for group in n.children().into_iter().flatten() { let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; let r = SeriesRef { graph: n.id, group: group.id, index }; @@ -124,6 +129,38 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { out } +/// `ids` with each graph series group replaced by the paths directly inside it, as [`paint_targets`] reads a series +/// group: the graph draws its series groups again with no rows of their own. A series group with no such path, other +/// ids and the art of a graph design pass through, and the order of `ids` is kept. +pub(crate) fn series_group_marks(doc: &Document, ids: &[NodeId]) -> Vec { + series_group_marks_where(doc, ids, |_| true) +} + +/// [`series_group_marks`] for the series groups `stands` accepts; the others pass through. +pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: impl Fn(&Node) -> bool) -> Vec { + // A series group is a child of a graph with one of the graph's series indexes ([`series_members`]). Each id is + // looked up through its parent, with no pass over the document: the panels call this while they draw. + let is_series_group = |n: &Node| { + let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false }; + let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref()); + graph.is_some_and(|spec| index < series_count(spec)) + }; + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for id in ids { + match doc.node(*id) { + Some(group) if is_series_group(group) && stands(group) => { + let marks: Vec = + group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id).collect(); + if marks.is_empty() { out.push(*id) } else { out.extend(marks) } + } + _ => out.push(*id), + } + } + out.retain(|id| seen.insert(*id)); + out +} + /// Copy the paint just applied to generated series art into that series' fill or stroke. Called /// inside the same document edit, so the visible paint and future regeneration undo together. /// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index aef1918a6d..6a111fb700 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1034,6 +1034,85 @@ fn an_opaque_pick_replaces_the_opacity_the_series_stores() { assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); } +/// A column graph whose series 0 stores the 40% Multiply fill set on its first mark, and a mark of series 1 whose top fill +/// row has no paint and is opaque and Normal: the session, the graph, series 0's marks and that source. +fn marks_and_unpainted_source() -> (Session, NodeId, Vec, NodeId) { + let (mut s, id, _, marks) = column_marks(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "none": true})).unwrap(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply))); + (s, id, marks, source) +} + +/// An Eyedropper pick from a fill row without paint onto a mark whose fill had paint replaces the fill opacity and blend +/// mode another mark of the series stored: the series is drawn opaque and Normal again. +#[test] +fn an_eyedropper_pick_that_unpaints_a_series_fill_clears_its_stored_transparency() { + let (mut s, id, marks, source) = marks_and_unpainted_source(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill().unwrap().clone(); + assert_eq!((mark.paint.is_none(), mark.opacity, mark.blend), (false, 1.0, BlendMode::Normal), "the target's fill row"); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + let picked = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill().unwrap().clone(); + assert_eq!((picked.paint.is_none(), picked.opacity, picked.blend), (true, 1.0, BlendMode::Normal)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, None)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let fills: Vec<_> = series(&n, 0).children().unwrap().iter().filter_map(|m| m.appearance.fill().map(|f| (f.opacity, f.blend))).collect(); + assert!(fills.iter().all(|f| *f == (1.0, BlendMode::Normal)), "{fills:?}"); +} + +/// An Eyedropper pick from a fill row without paint onto a mark whose fill had no paint before the pick either leaves the +/// fill opacity and blend mode another mark of the series stored. +#[test] +fn an_eyedropper_pick_of_an_already_unpainted_row_leaves_series_transparency() { + let (mut s, id, marks, source) = marks_and_unpainted_source(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "none": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply)), "a color set to None writes no opacity"); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.4), Some(BlendMode::Multiply))); +} + +/// A whole Eyedropper pick from a source with no fill row, or no stroke row, removes that row from the mark. Where the +/// mark's row had paint, the pick replaces the opacity and blend mode another mark of the series stored for that kind +/// of row. Where the mark's row had no paint, the stored values stay. +#[test] +fn a_whole_eyedropper_pick_that_removes_a_painted_row_clears_its_stored_transparency() { + let stored = |s: &Session, id: NodeId, fill: bool| { + let kept = spec(s, id).series_paints[0].clone(); + if fill { (kept.fill_opacity, kept.fill_blend) } else { (kept.stroke_opacity, kept.stroke_blend) } + }; + for (fill, painted) in [(true, true), (false, true), (false, false)] { + let (mut s, id, _, marks) = column_marks(); + let row = top_row(&s, marks[0], fill); + if painted { + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": row, "color": "#ff0000"})).unwrap(); + } + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "opacity": 40, "blend": "multiply"})).unwrap(); + assert_eq!(stored(&s, id, fill), (Some(0.4), Some(BlendMode::Multiply)), "fill {fill}, painted {painted}"); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, fill)})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + assert!(!mark.appearance.items.iter().any(|it| it.is_fill() == fill), "the pick removed the row: fill {fill}"); + let want = if painted { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!(stored(&s, id, fill), want, "fill {fill}, painted {painted}"); + if painted { + let after = spec(&s, id).series_paints; + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, after); + } + } +} + /// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's /// empty one, and the series still shows when the graph is drawn again. #[test] @@ -1251,6 +1330,45 @@ fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() { } } +/// An Eyedropper pick from a stroke row without paint onto a marker whose stroke had paint replaces the stroke opacity and +/// blend mode the series' Filled Lines band left, in the pick's undo step: the band's outline is drawn opaque and Normal +/// again. A pick onto a marker whose stroke had no paint before the pick leaves them as they are. +#[test] +fn an_eyedropper_pick_that_unpaints_a_series_stroke_clears_its_stored_transparency() { + for stroked in [true, false] { + let (mut s, id, band) = band_graph(); + let parts = series_parts(&s, id, 0); + let (marker, source) = (parts[1].id, parts[2].id); + let st = parts[2].appearance.stroke().unwrap(); + assert_eq!((st.paint.is_none(), st.opacity, st.blend), (true, 1.0, BlendMode::Normal), "the source's stroke row"); + if stroked { + let row = top_row(&s, marker, false); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap(); + } + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap(); + let was = spec(&s, id).series_paints; + assert_eq!((was[0].stroke_opacity, was[0].stroke_blend), (Some(0.4), Some(BlendMode::Multiply))); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marker.0]})).unwrap(); + let picked = s.doc().unwrap().doc.node(marker).unwrap().appearance.stroke().unwrap().clone(); + assert_eq!((picked.paint.is_none(), picked.opacity, picked.blend), (true, 1.0, BlendMode::Normal), "stroked {stroked}"); + let kept = spec(&s, id).series_paints[0].clone(); + let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}"); + if stroked { + let after = spec(&s, id).series_paints; + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + assert!(!s.doc().unwrap().doc.node(marker).unwrap().appearance.stroke_paint().is_none(), "the undo took back the pick"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, after); + } + redrawn_band(&mut s, id); + let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone(); + assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}"); + } +} + /// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the /// band its color: an empty stored stroke is not a color for the band. #[test] @@ -1686,6 +1804,364 @@ fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_st assert_eq!(spec(&s, id).series_paints, was); } +/// Series 0's group, and whether it is as the graph draws it: the group has no rows of its own and each path directly +/// inside it has rows. +fn series_0_group(s: &Session, id: NodeId) -> (NodeId, bool) { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let g = series(&n, 0); + let drawn = g.appearance.items.is_empty() && g.children().unwrap().iter().all(|m| !m.appearance.items.is_empty()); + (g.id, drawn) +} + +/// `appearance.setItem` given a series group's id edits the paths directly inside it, so the graph stores the color and +/// opacity and every mark has them after the graph is drawn again. The group keeps no rows of its own. +#[test] +fn an_appearance_item_set_on_a_series_group_edits_its_marks() { + let (mut s, id, _, marks) = column_marks(); + let (group, drawn) = series_0_group(&s, id); + assert!(drawn); + let was = spec(&s, id).series_paints; + let row = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [group.0], "index": row, "color": "#0000ff", "opacity": 40})).unwrap(); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); + let blue = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(blue.clone()), Some(0.4))); + let (stroke, weight) = { + let m = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + (m.appearance.stroke_paint(), m.appearance.stroke_width()) + }; + series_0_is(&mut s, id, (&blue, 0.4, &stroke, weight), &was); +} + +/// The other Appearance commands given a series group's id, picked with Group Selection or given in `ids`, edit the +/// paths directly inside it, and the graph stores what they change: Add New Fill, Clear Appearance, Move Appearance onto +/// the group, and an opacity set on a row with `transparency.set`. +#[test] +fn appearance_commands_given_a_series_group_edit_its_marks() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + assert!(series_0_group(&s, id).1, "Add New Fill on the selected group"); + let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count(); + assert_eq!(rows, 2, "each mark has a new fill row"); + s.execute("edit.undo", &json!({})).unwrap(); + + let row = top_row(&s, marks[0], true); + s.execute("transparency.set", &json!({"ids": [group.0], "item": row, "opacity": 30})).unwrap(); + assert!(series_0_group(&s, id).1); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.3)); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); + s.execute("edit.undo", &json!({})).unwrap(); + + let (rect, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("object.setProps", &json!({"ids": [group.0], "opacity": 50})).unwrap(); + s.execute("appearance.transfer", &json!({"source": rect.0, "target": group.0, "copy": true})).unwrap(); + assert!(series_0_group(&s, id).1); + assert_eq!(s.doc().unwrap().doc.node(group).unwrap().opacity, 1.0, "the appearance replaces the group's own opacity"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// Moving the Contents row of a series group as the graph draws it, with no rows of its own, moves the group's own +/// row: the paths inside it have none. +#[test] +fn moving_a_series_groups_contents_row_moves_its_own_row() { + let (mut s, id, _, _) = column_marks(); + let (group, drawn) = series_0_group(&s, id); + assert!(drawn); + let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": "contents", "to": 0})).unwrap(); + assert_eq!(out["contents"], json!(0)); +} + +/// Add New Stroke, Duplicate Item, Reorder Appearance Item and Remove Item given a series group's id edit the rows of +/// the paths directly inside it, and the group keeps no rows of its own. +#[test] +fn row_commands_given_a_series_group_edit_its_marks_rows() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + let count = |s: &Session, fill: bool| -> Vec { + let d = &s.doc().unwrap().doc; + marks.iter().map(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill() == fill).count()).collect() + }; + let (fills, strokes) = (count(&s, true), count(&s, false)); + s.execute("appearance.addStroke", &json!({"ids": [group.0]})).unwrap(); + assert_eq!(count(&s, false), strokes.iter().map(|n| n + 1).collect::>(), "each mark has a new stroke row"); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.duplicateItem", &json!({"ids": [group.0], "index": fill})).unwrap(); + assert_eq!(count(&s, true), fills.iter().map(|n| n + 1).collect::>(), "each mark has a copy of its fill row"); + let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.len(); + let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": 0, "to": rows - 1})).unwrap(); + assert_eq!(out["index"], json!(rows - 1)); + s.execute("appearance.removeItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true)})).unwrap(); + assert_eq!(count(&s, true), fills); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); +} + +/// Reduce to Basic Appearance given a series group's id reduces the paths directly inside it and drops an effect the +/// group holds. +#[test] +fn reducing_a_series_group_reduces_its_marks_and_drops_its_own_effect() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("appearance.addFill", &json!({"ids": [group.0]})).unwrap(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + s.execute("appearance.reduceToBasic", &json!({"ids": [group.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(group).unwrap().appearance.effects.is_empty(), "the group's effect is dropped"); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count() == 1)); + assert!(series_0_group(&s, id).1); +} + +/// A graphic style added on top (Alt-click) given a series group's id adds its rows to the paths directly inside the +/// group and unlinks a group that held a style link. +#[test] +fn a_graphic_style_added_to_a_series_group_adds_rows_to_its_marks_and_unlinks_the_group() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + blue_style(&mut s, "Blue", false); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id; + s.edit("Link", |d, _| { + d.node_mut(group).unwrap().extras_mut().graphic_style = Some(style); + Ok(()) + }) + .unwrap(); + let fills = |s: &Session, m: NodeId| s.doc().unwrap().doc.node(m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count(); + let was = fills(&s, marks[0]); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0], "add": true})).unwrap(); + assert!(marks.iter().all(|m| fills(&s, *m) == was + 1), "each mark has the style's fill on top"); + assert!(series_0_group(&s, id).1, "the group takes no rows of the style"); + assert_eq!(s.doc().unwrap().doc.node(group).unwrap().extras().graphic_style, None, "the group is unlinked"); +} + +/// Move Appearance from one of a series group's paths onto the group gives the other paths its look and leaves that +/// path as it was. When the path is the only one directly in the group, the command is refused and the group takes no +/// rows. +#[test] +fn moving_an_appearance_from_a_mark_onto_its_own_series_group_keeps_the_mark() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap(); + let green = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": group.0})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "every mark, the source too, is green"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(green)); + + s.edit("One path", |d, _| { + d.children_mut(Some(group)).unwrap().truncate(1); + Ok(()) + }) + .unwrap(); + let only = s.doc().unwrap().doc.node(group).unwrap().children().unwrap()[0].id; + let err = s.execute("appearance.transfer", &json!({"source": only.0, "target": group.0})).unwrap_err(); + assert!(err.to_string().contains("only path"), "{err}"); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); +} + +/// A series group that holds an opacity and an effect of its own is read itself as a source, as any group is: a new +/// graphic style and Move Appearance take the group's opacity and effect, and the group, not a path, is linked. +#[test] +fn a_series_group_with_a_look_of_its_own_is_read_itself_as_a_source() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("transparency.set", &json!({"ids": [group.0], "opacity": 50, "item": null})).unwrap(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap(); + let d = &s.doc().unwrap().doc; + let style = d.graphic_style("Series").unwrap(); + assert_eq!((style.opacity, style.appearance.effects.len()), (0.5, 1)); + assert_eq!(d.node(group).unwrap().extras().graphic_style, Some(style.id)); + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none())); + + let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0, "copy": true})).unwrap(); + let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); + assert_eq!((r.opacity, r.appearance.effects.len()), (0.5, 1)); +} + +/// Clear Appearance and Show All Hidden Attributes given a series group's id also reach an effect the group holds, and +/// Break Link to Graphic Style unlinks the paths a style applied to the group linked. +#[test] +fn whole_appearance_commands_given_a_series_group_edit_the_group_and_its_marks() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let effects = |s: &Session| s.doc().unwrap().doc.node(group).unwrap().appearance.effects.clone(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + s.execute("effect.setParams", &json!({"ids": [group.0], "index": 0, "visible": false, "item": null})).unwrap(); + assert!(!effects(&s)[0].visible); + let shown = s.execute("appearance.showAllHidden", &json!({"ids": [group.0]})).unwrap(); + assert_eq!(shown["ids"], json!([group.0])); + assert!(effects(&s)[0].visible); + s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap(); + assert!(effects(&s).is_empty(), "the group's effect is cleared with its marks' rows"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); + s.execute("edit.undo", &json!({})).unwrap(); + + blue_style(&mut s, "Blue", false); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap(); + assert_eq!(s.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some(("Blue".into(), true))); + s.execute("graphicStyle.breakLink", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none()), "each mark is unlinked"); + assert!(s.selection_graphic_style().is_none()); +} + +/// Redefine Graphic Style on a selected series group whose paths a style links takes the look of the topmost of them. +#[test] +fn redefining_a_graphic_style_from_a_selected_series_group_reads_its_topmost_mark() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + blue_style(&mut s, "Blue", false); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap(); + let top = *marks.last().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [top.0], "index": top_row(&s, top, true), "color": "#00ff00"})).unwrap(); + let green = s.doc().unwrap().doc.node(top).unwrap().appearance.fill_paint(); + s.execute("graphicStyle.redefine", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.graphic_style("Blue").unwrap().appearance.fill_paint(), green); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "the marks still linked follow"); +} + +/// A series group as the graph draws it, read as a source, gives the look of the topmost path directly inside it: New +/// Graphic Style keeps that path's fill and links it, Move Appearance copies it, and a move clears every path of the +/// group. +#[test] +fn a_series_group_read_as_a_source_gives_its_topmost_marks_look() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let top = *marks.last().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true), "color": "#0000ff", "opacity": 30})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap(); + let look = s.doc().unwrap().doc.node(top).unwrap().appearance.clone(); + assert_ne!(look.fill_paint(), s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint()); + s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.graphic_style("Series").unwrap().appearance.fill_paint(), look.fill_paint()); + assert_eq!(d.node(top).unwrap().extras().graphic_style, Some(d.graphic_style("Series").unwrap().id)); + + let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.node(rect).unwrap().appearance.fill_paint(), look.fill_paint()); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == Paint::None), "the move clears each mark"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); +} + +/// A series group that has a row of its own, as a document saved earlier can hold, is edited itself: the Appearance +/// panel lists that row and edits it by its index. +#[test] +fn a_series_group_with_a_row_of_its_own_is_edited_itself() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.edit("Row", |d, _| { + let g = d.node_mut(group).unwrap(); + g.appearance = vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0).into(); + Ok(()) + }) + .unwrap(); + let was = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone(); + s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, group, true), "color": "#0000ff"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_ne!(d.node(group).unwrap().appearance.fill_paint(), Paint::None); + assert_eq!(d.node(marks[0]).unwrap().appearance, was, "the marks keep their rows"); +} + +/// A graphic style applied to a series group's id styles and links the paths directly inside it, and the graph stores +/// the style's fill and stroke. +#[test] +fn a_graphic_style_applied_to_a_series_group_styles_its_marks() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + let own = json!({"ids": [group.0], "opacity": 50, "blend": "multiply", "isolate": true, "knockout": "on", "item": null}); + s.execute("transparency.set", &own).unwrap(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + let g = s.doc().unwrap().doc.node(group).unwrap().clone(); + assert!(g.isolate && g.knockout != Default::default() && g.blend != vectorcraft_color::BlendMode::Normal); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0]})).unwrap(); + assert!(series_0_group(&s, id).1, "the group takes no rows of the style"); + let g = s.doc().unwrap().doc.node(group).unwrap().clone(); + assert_eq!((g.opacity, g.isolate), (1.0, false), "the style replaces the group's own opacity and isolation"); + assert_eq!((g.blend, g.knockout), (vectorcraft_color::BlendMode::Normal, Default::default()), "and its blend mode and knockout"); + assert!(g.appearance.effects.is_empty(), "and its effects"); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id; + let d = &s.doc().unwrap().doc; + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style)), "each mark is linked to the style"); + assert_eq!(d.node(group).unwrap().extras().graphic_style, None); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// Redefine Graphic Style on a series group linked to the style (as a file can hold it) restyles the paths directly +/// inside the group and links them, and the graph stores the new look; the group goes back to no rows of its own. +#[test] +fn redefining_a_graphic_style_restyles_the_marks_of_a_linked_series_group() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let (rect, _) = blue_style(&mut s, "Blue", false); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone(); + s.edit("Link", |d, _| { + let g = d.node_mut(group).unwrap(); + g.appearance = style.appearance.clone().into(); + g.extras_mut().graphic_style = Some(style.id); + Ok(()) + }) + .unwrap(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.node(group).unwrap().extras().graphic_style, None); + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id))); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(green.clone()), None, Some(Paint::None))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + series_0_is(&mut s, id, (&green, 1.0, &Paint::None, 0.0), &was); +} + +/// Redefine Graphic Style on a linked series group leaves a path of the group that is linked to the style but no +/// longer has its look as it is, unlinked, and restyles the group's other paths. +#[test] +fn redefining_a_graphic_style_keeps_an_edited_mark_of_a_linked_series_group() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + let (rect, _) = blue_style(&mut s, "Blue", false); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone(); + s.edit("Link", |d, _| { + let g = d.node_mut(group).unwrap(); + g.appearance = style.appearance.clone().into(); + g.extras_mut().graphic_style = Some(style.id); + d.node_mut(marks[0]).unwrap().extras_mut().graphic_style = Some(style.id); + Ok(()) + }) + .unwrap(); + let edited = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + let d = &s.doc().unwrap().doc; + let first = d.node(marks[0]).unwrap(); + assert_eq!((&first.appearance, first.extras().graphic_style), (&edited, None), "the edited mark keeps its look, unlinked"); + assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green)); + assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id))); +} + /// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. #[test] fn default_fill_and_stroke_on_a_series_mark_is_kept() { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f816ec7688..f589ea62da 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -14,8 +14,8 @@ use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor use vectorcraft_geom::{Affine, Rect}; use super::{ - CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, - has_selection, ids_param, index, object_param, ok, str_param, switch_or, + CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, checked_node, checked_nodes_param, cmd, + color_value, f64_or, has_doc, has_selection, ids_param, index, object_param, ok, str_param, switch_or, }; use crate::EngineError; @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Layer Options…", ["Window", "Layers"], None, - "{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name (a hex color with an alpha part, #rrggbbaa or #rgba, is an error)} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step", + "{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name (a hex color with an alpha part, #rrggbbaa or #rgba, is an error)} Layer Options; rows that are not layers take name, visible and locked, and `ids` may name ruler guides (their `guide.list` id), which hide or lock on their own. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step", has_doc, set_props ), @@ -210,11 +210,16 @@ pub fn specs() -> Vec { /// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current /// layer (capped at [`MAX_ROWS`]). pub(crate) fn rows_param(s: &Session, p: &Value, command: &str) -> Result> { + rows_or_guides_param(s, p, command, false) +} + +/// The rows [`rows_param`] reads; given ruler guide ids are taken too when `guides`, else refused. +fn rows_or_guides_param(s: &Session, p: &Value, command: &str, guides: bool) -> Result> { // Presence is explicit intent: validate every target before fallback, truncation or edits. let mut ids = if p.get("ids").is_some() { - checked_ids_param(s, p, "ids", command)? + checked_nodes_param(s, p, "ids", command, guides)? } else if let Some(id) = p.get("id") { - vec![checked_id(s, id, command)?] + vec![checked_node(s, id, command, guides)?] } else { let st = s.doc()?; let rows = st.highlighted_rows(); @@ -637,7 +642,8 @@ fn set_props(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.setProps"; let xml_id = s.prefs.identify_objects_by == "xmlId"; check_options(C, p, xml_id)?; - let ids = rows_param(s, p, C)?; + // A ruler guide's row takes visible and locked: its own eye and lock. + let ids = rows_or_guides_param(s, p, C, true)?; if ids.is_empty() { return Err(bad(C, "no rows")); } diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index f2ce9d4d12..99295e5c79 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::findfiles::{self, Limits, Visitor}; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, checked_ids_param, cmd}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, cmd, ids_param}; /// The most linked files one search looks for. const MAX_LINKS: usize = 1_000; @@ -143,8 +143,14 @@ fn find(s: &mut Session, p: &Value) -> Result { let ids = match p.get("ids") { None | Some(Value::Null) => None, Some(_) => { - let ids = checked_ids_param(s, p, "ids", C)?; - let linkable = |id: &NodeId| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); + // Images and placed documents in opacity-mask art and in the art of symbols, graph + // designs and patterns are named by their ids too (`links.list`). + let ids = ids_param(p, "ids", C)?.unwrap_or_default(); + let kind = |id: &NodeId| st.doc.stored_node(*id).map(|(n, _)| &n.kind); + if let Some(value) = ids.iter().find(|id| kind(id).is_none()).map(|id| id.0) { + return Err(bad(C, format!("no such object id {value}"))); + } + let linkable = |id: &NodeId| matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); if let Some(id) = ids.iter().find(|id| !linkable(id)) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e3e72861b9..d9c2ccf3ad 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -30,6 +30,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, + StoredPlace, StoredRoot, }; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; @@ -43,7 +44,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs and patterns)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -52,7 +53,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns and opacity masks have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", has_doc, update ), @@ -70,7 +71,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs and patterns) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", has_doc, relink ), @@ -79,7 +80,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs and patterns (one row per stored object, never one per symbol instance or pattern tile) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern, name? (of the symbol, graph design or pattern), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -88,7 +89,7 @@ pub fn specs() -> Vec { "Go To Link", [], None, - "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", + "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design or pattern has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", has_doc, go_to ), @@ -156,7 +157,8 @@ impl Status { struct Group { link: LinkInfo, key: String, - /// The objects in the layers (images in symbols and patterns share the content, not an id). + /// The stored objects showing it: in the layers, in opacity-mask art and in the art of symbols, + /// graph designs and patterns ([`Document::visit_stored`]), each once. ids: Vec, /// Placed documents: what they show. document: Option, @@ -177,8 +179,9 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { } } -/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]): an -/// image placed from a DCS file has its plate files of their own too, beside its file. +/// Every linked file of `d` with the ids of the objects showing it, wherever they are stored (for +/// [`crate::link_watch`]): an image placed from a DCS file has its plate files of their own too, +/// beside its file. pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { let mut out = vec![]; for g in groups(d, None).into_iter().filter(|g| !g.ids.is_empty()) { @@ -190,8 +193,10 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { out } -/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when -/// given), then, without `ids`, the ones only symbols and patterns show. +/// The linked images and placed documents of `d` by file and content, wherever they are stored +/// (only `ids` when given), in [`Document::visit_stored`] order. An object whose id an object +/// visited before it has (art being edited on a layer and its definition) counts once, by its +/// first place; without `ids` its content is still grouped. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); @@ -210,24 +215,48 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { g.ids.push(id); } }; - d.walk(|n| { - if let Some((l, key, document)) = linked(n) - && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) - { + let mut seen = std::collections::HashSet::new(); + d.visit_stored(|n, _| { + let Some((l, key, document)) = linked(n) else { return }; + // Id 0 names no object. + let first = n.id.0 != 0 && seen.insert(n.id); + if first && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) { add(l, key, document, Some(n.id)); + } else if ids.is_none() { + add(l, key, document, None); } }); - if ids.is_none() { - d.visit_images(|_, im| { - if let Some(l) = &im.link { - add(l, &im.key, None, None); - } - }); - d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None)); - } out } +/// Stored object `id` of `d` ([`Document::stored_node`]). +fn stored(d: &Document, id: NodeId) -> Option<&Node> { + d.stored_node(id).map(|(n, _)| &**n) +} + +/// Of `ids`, the objects of `d` outside the layer tree (where [`Document::node`] finds none). +fn nested_ids(d: &Document, ids: &[NodeId]) -> std::collections::HashSet { + ids.iter().copied().filter(|id| d.node(*id).is_none()).collect() +} + +/// `links.list`'s `owner` of an object stored at `place` of `d`, when it is not in the layer tree +/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`. +fn owner_json(d: &Document, place: StoredPlace) -> Option { + if place.in_layers() { + return None; + } + let mut owner = match place.root { + StoredRoot::Layer(_) => json!({ "kind": "layers" }), + StoredRoot::Symbol(i) => json!({ "kind": "symbol", "name": d.symbols.get(i).map(|s| s.name.as_str()) }), + StoredRoot::GraphDesign(i) => json!({ "kind": "graphDesign", "name": d.graph_designs.get(i).map(|g| g.name.as_str()) }), + StoredRoot::Pattern { pattern, .. } => json!({ "kind": "pattern", "name": d.patterns.get(pattern).map(|p| p.name.as_str()) }), + }; + if let Some(m) = place.mask_of { + owner["maskOf"] = json!(m.0); + } + Some(owner) +} + /// A linked file where it was found. struct Found { path: String, @@ -493,8 +522,8 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a } /// The linked files of the open document `st` that `links.findFiles` looks for, each with the -/// objects in the layers that show it: the files of objects `ids` whatever their status, else the -/// files that can't be found. +/// stored objects that show it: the files of objects `ids` whatever their status, else the files +/// that can't be found. pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec)> { groups(&st.doc, ids) .into_iter() @@ -708,7 +737,7 @@ fn clip_to( type Remake<'a> = dyn FnMut(&Node, Option<(&PathData, FillRule)>) -> (Node, ClipTo) + 'a; /// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and -/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. +/// opacity masks, where [`Document::node`] does not find them) that `pick` holds for. struct Nested<'a> { pick: &'a dyn Fn(&Node) -> bool, make: &'a mut Remake<'a>, @@ -854,10 +883,10 @@ impl Nested<'_> { } } -/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its -/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also` -/// (a file's path and the content shown), so do the placed documents outside the layer tree that -/// show that content. +/// Placed documents `ids` (in the layers or outside them) show `src`, read from the file `link` +/// points at, each placed as its Placement Options say (`force`: as that Preserve choice says, +/// whatever they say); with `also` (a file's path and the content shown), so do the placed +/// documents outside the layer tree that show that content. fn install_document( d: &mut Document, ids: &[NodeId], @@ -872,8 +901,10 @@ fn install_document( let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o) }; - if let Some(also) = also { - let pick = |n: &Node| shows(n, also, true); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = + |n: &Node| matches!(n.kind, NodeKind::PlacedDocument(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, true))); let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { let mut n = n.clone(); let mut clip = ClipTo::Keep; @@ -1037,26 +1068,39 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ install(d, &[id], read, Some(Preserve::Bounds), None) } -/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement -/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's -/// path and the content shown), so do the images outside the layer tree that show that content. +/// Image objects `ids` (in the layers or outside them) show the file read for `link` (`img`), each +/// placed as its Placement Options say (`force`: as that Preserve choice says, whatever they say); +/// with `also` (a file's path and the content shown), so do the images outside the layer tree that +/// show that content. fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option, also: Option<(&str, &str)>) -> Result<()> { let new_pt = super::place::pt_per_px(img.ppi); let mut clips = vec![]; - if let Some(also) = also { - // Every image picked shows content `also.1`, so one resolution serves them all. - let old_pt = file_pt(d, also.1); - let pick = |n: &Node| shows(n, also, false); - // The clipping path the old content's DCS file drew it through. - let old_clip = d.images.get(also.1).and_then(ImageBlob::separated_clip); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = |n: &Node| matches!(n.kind, NodeKind::Image(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, false))); + // The resolution of each content the images picked show, and the clipping path its DCS file + // drew it through, looked up once each. + let mut old: BTreeMap)> = BTreeMap::new(); + d.visit_stored(|n, _| { + if let NodeKind::Image(im) = &n.kind + && pick(n) + && !old.contains_key(&im.key) + { + old.insert(im.key.clone(), (file_pt(d, &im.key), d.images.get(&im.key).and_then(ImageBlob::separated_clip))); + } + }); let mut make = |n: &Node, held: Option<(&PathData, FillRule)>| { let mut n = n.clone(); let mut clip = ClipTo::Keep; if let NodeKind::Image(im) = &mut n.kind { let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); + let (old_pt, old_clip) = match old.get(&im.key) { + Some((pt, clip)) => (*pt, clip.as_ref()), + None => (super::place::pt_per_px(None), None), + }; let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); - clip = clip_to(im.xf, old_clip.as_ref(), c, &img, xf, held); + clip = clip_to(im.xf, old_clip, c, &img, xf, held); (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); } (n, clip) @@ -1267,7 +1311,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps } } // The objects in symbols, graph designs, patterns and opacity masks that show the same file and - // content, which have no ids here, whatever else their links hold. + // content, whatever else their links hold, those whose id an earlier object has included. if !moved.is_empty() { let mut by_path: BTreeMap> = BTreeMap::new(); for (path, key, document, new) in moved { @@ -1302,13 +1346,25 @@ fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> { } } -/// Linked image objects (or placed documents) `ids` link to `link`. +/// Linked image objects (or placed documents) `ids`, in the layers or outside them, link to `link`. fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { - for id in ids { + let nested = nested_ids(d, ids); + for id in ids.iter().filter(|id| !nested.contains(id)) { if let Some(l) = d.node_mut(*id).and_then(link_mut) { *l = link.clone(); } } + if !nested.is_empty() { + let pick = |n: &Node| nested.contains(&n.id) && linked(n).is_some(); + let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { + let mut out = n.clone(); + if let Some(l) = link_mut(&mut out) { + *l = link.clone(); + } + (out, ClipTo::Keep) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open @@ -1331,14 +1387,17 @@ pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { if targets.is_empty() { return; } - // Each document is updated as the active one, without switching tools. + // Each document is updated as the active one, through its own profiles, without switching + // tools. let back = s.active; for i in targets { s.active = Some(i); + s.sync_front_profiles(); // A file that can't be read leaves what its objects show: links.check reports it. let _ = update_where(s, None, Some(&path)); } s.active = back; + s.sync_front_profiles(); } // ---------- relative paths ---------- @@ -1537,7 +1596,7 @@ fn update(s: &mut Session, p: &Value) -> Result { /// Update Links in the active document of `s` for objects `ids` (default: the linked files that /// changed or show their preview), or with `path`, for the placed documents of the file at `path` /// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that -/// show the same content are read again too, and have no ids to report. +/// show the same content are read again too. fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; let (mut reads, mut missing) = (vec![], vec![]); @@ -1603,7 +1662,7 @@ enum Scope { Each, /// The objects given or selected. Selected, - /// Every object in the layers linked to the same file as one given or selected. + /// Every stored object linked to the same file as one given or selected. All, /// The objects given or selected, then every other missing link whose file name is in the new /// file's folder. @@ -1687,7 +1746,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { }; // An image or a placed document, with its link. let image = |id: &NodeId| { - st.doc.node(*id).and_then(|n| match &n.kind { + stored(&st.doc, *id).and_then(|n| match &n.kind { NodeKind::Image(im) => Some(im.link.as_ref()), NodeKind::PlacedDocument(p) => Some(Some(&p.link)), _ => None, @@ -1712,22 +1771,23 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } - // All Instances: the other objects in the layers linked to the same files. + // All Instances: the other stored objects linked to the same files, each once. if scope == Scope::All { let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); - let given: BTreeSet = ids.iter().copied().collect(); + let mut given: BTreeSet = ids.iter().copied().collect(); let mut more = vec![]; - st.doc.walk(|n| { + st.doc.visit_stored(|n, _| { if let Some((l, _, _)) = linked(n) && paths.contains(l.path.as_str()) - && !given.contains(&n.id) + && n.id.0 != 0 + && given.insert(n.id) { more.push(n.id); } }); ids.extend(more); } - if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { + if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&st.doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. @@ -1763,7 +1823,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut images = vec![]; let mut documents: Vec<((u32, bool, PageBox), Vec)> = vec![]; for id in ids { - match st.doc.node(id).map(|n| &n.kind) { + match stored(&st.doc, id).map(|n| &n.kind) { Some(NodeKind::PlacedDocument(pl)) => { let k = (pl.page(), pl.bounding, pl.page_box); match documents.iter_mut().find(|(e, _)| *e == k) { @@ -1913,9 +1973,9 @@ fn copy_into_folder(_: &str, _: &[u8], _: &Path) -> Result<(String, bool)> { // ---------- the Links panel ---------- -/// Image object `id` of `d`. +/// Image object `id` of `d`, wherever it is stored. fn image_of(d: &Document, id: NodeId) -> Option<&ImageObject> { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im), _ => None, } @@ -1933,7 +1993,7 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { .ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match image_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image", id.0))), } } @@ -1947,9 +2007,9 @@ fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> { } } -/// The Placement Options of image or placed document `id` of `d`. +/// The Placement Options of image or placed document `id` of `d`, wherever it is stored. fn placement_of(d: &Document, id: NodeId) -> Option { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im.placement), NodeKind::PlacedDocument(p) => Some(p.placement), _ => None, @@ -1962,12 +2022,12 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match placement_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))), } } -/// Per linked image in the layers of `st`: its file's status, where it was found when away from +/// Per linked image or placed document of `st`, wherever it is stored: its file's status, where it was found when away from /// its path, and whether the image shows its preview (each file probed once). pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { let mut out = BTreeMap::new(); @@ -2068,17 +2128,20 @@ fn list(s: &mut Session, p: &Value) -> Result { } let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() }; let statuses = statuses(st); - let mut objects = vec![]; - st.doc.walk(|n| { - if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) { - objects.push(n); + // The layer tree's objects, top first as the Layers panel lists them, then the others in the + // order they are stored (opacity-mask art, symbols, graph designs, patterns), each once. + let (mut objects, mut nested) = (vec![], vec![]); + let mut seen = std::collections::HashSet::new(); + st.doc.visit_stored(|n, place| { + if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) && n.id.0 != 0 && seen.insert(n.id) { + if place.in_layers() { objects.push((n, place)) } else { nested.push((n, place)) } } }); - // Top first, as the Layers panel lists them. objects.reverse(); + objects.extend(nested); let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut rows = vec![]; - for n in objects { + for (n, place) in objects { let (status, found, preview) = match statuses.get(&n.id) { Some((s, f, p)) => (s.as_str(), f.as_deref(), *p), None => ("embedded", None, false), @@ -2121,6 +2184,9 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } + if let Some(owner) = owner_json(&st.doc, place) { + row["owner"] = owner; + } if interactions.contains(&n.id) { row["dcsTransparency"] = json!(true); } @@ -2141,15 +2207,21 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let id = linked_param(st, p, "links.goTo")?; + // An object in mask art or a definition has no place of its own on the canvas: the selection + // is cleared, and the reply names what holds it. + if let Some(owner) = st.doc.stored_node(id).and_then(|(_, place)| owner_json(&st.doc, place)) { + s.select(|_, sel| sel.set([]))?; + return Ok(json!({ "id": id.0, "onCanvas": false, "owner": owner })); + } let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default(); s.select(|_, sel| sel.set([id]))?; - Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] })) + Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1], "onCanvas": true })) } fn embed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.embed"; let st = s.doc()?; - let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind); + let kind = |id: &NodeId| stored(&st.doc, *id).map(|n| &n.kind); let linked = |id: &NodeId| match kind(id) { Some(NodeKind::Image(im)) => im.link.is_some(), Some(NodeKind::PlacedDocument(_)) => true, @@ -2207,7 +2279,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { install(d, &ids, read, Some(Preserve::Bounds), None)?; } for id in &done { - if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { im.link = None; } } @@ -2235,7 +2307,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.unembed"; let st = s.doc()?; let id = image_param(st, p, C)?; - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; if im.link.is_some() { return Err(bad(C, format!("image {} is linked already", id.0))); @@ -2321,7 +2393,7 @@ fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { /// Link Info of placed document `id`. fn placed_info(st: &crate::DocState, id: NodeId) -> Result { - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) }; let size = n.geometric_bounds().unwrap_or_default(); let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0)); @@ -2401,7 +2473,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { }; s.edit("Placement Options", |d, _| { for id in &ids { - if let Some(o) = d.node_mut(*id).and_then(placement_mut) { + if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { set(o); } } diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 8a92d759f9..692ce9e563 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -172,7 +172,7 @@ pub fn specs() -> Vec { "Make with Top Object", ["Object", "Envelope Distort"], Some("Cmd+Alt+C"), - "{ids?} the topmost selected path becomes the envelope of the others: its outline splits into four sides at its corners, and each piece of a side takes a share of the content in proportion to its control polygon's length (type maps from its glyph outline bounds) → {id}", + "{ids?} the topmost selected path becomes the envelope of the others: its outline splits into four sides at its corners, and each piece of a side takes a share of the content in proportion to its control polygon's length, and a side whose pieces are all one curve split at anchors follows that curve's parameter (type maps from its glyph outline bounds) → {id}", has_multi, env_make_top ), diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index ff5b950d03..e460712376 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -169,28 +169,47 @@ pub(super) fn lock(s: &mut Session, _: &Value) -> Result { ok() } -/// The selected objects ([`selected_roots`]) that Lock › Selection and Hide › Selection change: in -/// isolation mode, only those inside the isolated object, where a path of the isolated compound -/// path is an object of its own. +/// The selected objects ([`selected_roots`]) and ruler guides that Lock › Selection and Hide › +/// Selection change: in isolation mode, only the objects inside the isolated object, where a path +/// of the isolated compound path is an object of its own. A guide counts while it can be edited +/// (it and its layer shown and unlocked, and Lock Guides off). fn selected_lock_hide_roots(s: &Session) -> Result> { - match crate::cmd::ordinary_isolation(s.doc()?) { + let st = s.doc()?; + match crate::cmd::ordinary_isolation(st) { Some(root) => crate::cmd::edit::selected_roots_inside(s, root, false), - None => selected_roots(s), + None => { + let mut ids = selected_roots(s)?; + if !s.guides_locked() { + let d = &st.doc; + ids.extend(st.selection.guides.iter().copied().filter(|id| d.ruler_guide(*id).is_some_and(|g| d.guide_editable(&g)))); + } + Ok(ids) + } } } +/// The objects and ruler guides in `d` that `f` picks (layers never). fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { let mut v = vec![]; d.walk(|n| { - if !n.is_layer() && !n.is_ruler_guide() && f(n) { + if !n.is_layer() && f(n) { v.push(n.id) } }); v } +/// Select what Unlock All or Show All changed (`ids`): the objects, and the ruler guides among +/// them as guides (a guide that still can't be edited is then dropped from the selection). +fn select_changed(d: &Document, sel: &mut vectorcraft_doc::Selection, ids: &[NodeId]) { + let guides: std::collections::HashSet = d.ruler_guides().into_iter().filter_map(|g| g.id).collect(); + let (on_guides, objects): (Vec, Vec) = ids.iter().copied().partition(|id| guides.contains(id)); + sel.set(objects); + sel.guides = on_guides; +} + /// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole -/// document, inside the isolated object in isolation mode, or with `scope: "group"` inside the +/// document (its ruler guides included), inside the isolated object in isolation mode, or with `scope: "group"` inside the /// groups that hold the selected objects (in isolation mode, only the objects of those groups /// that are inside the isolated object). 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 @@ -253,7 +272,7 @@ pub(super) fn unlock_all(s: &mut Session, p: &Value) -> Result { n.locked = false; } } - sel.set(ids.iter().copied()); + select_changed(d, sel, &ids); Ok(()) })?; Ok(json!({ "count": ids.len() })) @@ -292,7 +311,7 @@ pub(super) fn show_all(s: &mut Session, p: &Value) -> Result { n.visible = true; } } - sel.set(ids.iter().copied()); + select_changed(d, sel, &ids); Ok(()) })?; Ok(json!({ "count": ids.len() })) diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 5c66ec8442..21d6530e53 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -7,7 +7,7 @@ use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; use super::{CommandSpec, cmd, has_doc, has_multi, has_selection, has_selection_or_guides}; -use crate::cmd::has_selected_member; +use crate::cmd::has_selected_member_or_guide; pub fn specs() -> Vec { vec![ @@ -151,8 +151,8 @@ pub fn specs() -> Vec { "Selection", ["Object", "Lock"], Some("Cmd+2"), - "{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}", - has_selected_member, + "{ids?: [id…] (selected first; default: the selection, selected ruler guides included; in isolation mode, only the objects inside the isolated object)}", + has_selected_member_or_guide, lock ), cmd!( @@ -160,7 +160,7 @@ pub fn specs() -> Vec { "Unlock All", ["Object"], Some("Cmd+Alt+2"), - "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; 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}", + "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; 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; document scope outside isolation mode also unlocks ruler guides locked on their own → {count}", has_doc, unlock_all ), @@ -169,8 +169,8 @@ pub fn specs() -> Vec { "Selection", ["Object", "Hide"], Some("Cmd+3"), - "{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}", - has_selected_member, + "{ids?: [id…] (selected first; default: the selection, selected ruler guides included; in isolation mode, only the objects inside the isolated object)}", + has_selected_member_or_guide, hide ), cmd!( @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "Show All", ["Object"], Some("Cmd+Alt+3"), - "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; 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}", + "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; 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; document scope outside isolation mode also shows ruler guides hidden on their own → {count}", has_doc, show_all ), diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 296cef4dc2..7dc8aec1c9 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -12,7 +12,8 @@ //! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move //! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the //! path, the objects between it and that place each moving one place toward where it was; hidden -//! and locked objects keep their places. +//! and locked objects keep their places. `object.objectsOnPath.detach` takes one object out of its +//! group, just above it, and the others move up the path. //! //! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back //! on its path: editing the path moves them, an object moved on its own goes back to its place, @@ -21,7 +22,8 @@ //! //! Behavior: (the tool and //! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution, -//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand). +//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Detach and +//! the objects that move up the path after it; Expand). use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -75,6 +77,15 @@ pub fn specs() -> Vec { has_doc, move_member ), + cmd!( + "object.objectsOnPath.detach", + "Detach Object from Path", + [], + None, + "{member?: id of an object of an Objects on Path group (default: the object chosen with the selected group's Select/Move widget); neither the object nor the group may be hidden or locked} take one object off the path, out of its group and just above the group in the Layers panel, where it stays as drawn and is selected alone. The group's other objects move up the path in their order: with Auto Space they spread evenly again, and a Space value and the start are kept. The group and its path stay, even with one object or none left → {group, member}", + has_doc, + detach + ), cmd!( "object.objectsOnPath.expand", "Expand", @@ -383,6 +394,47 @@ fn move_member(s: &mut Session, p: &Value) -> Result { Ok(json!({ "group": g.0, "member": member.0, "index": index })) } +// ---------- Detach ---------- + +/// The object chosen with the Select/Move widget of the one selected Objects on Path group. +fn chosen_member(s: &Session) -> Option { + let st = s.active()?; + match st.selection.objects[..] { + [_] => st.selection.on_path_member, + _ => None, + } +} + +fn detach(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.objectsOnPath.detach"; + const CHOOSE: &str = "choose the object to detach with its Select/Move widget"; + let (g, member) = if p.get("member").is_none_or(Value::is_null) { + let chosen = chosen_member(s).ok_or_else(|| bad(C, CHOOSE))?; + let found = member_param(s, &json!({ "member": chosen.0 }), "member", C)?; + if s.active().is_none_or(|st| st.selection.objects[..] != [found.0]) { + return Err(bad(C, CHOOSE)); + } + found + } else { + member_param(s, p, "member", C)? + }; + // The object goes just above the group, in the group's parent, and keeps where it is drawn. + // When the group is itself an object on another group's path, the object goes above that + // group instead (and so on outward), so it does not join that path. The refresh after the edit + // removes it from the group's record and moves the others up the path. + s.edit("Detach Object from Path", |d, sel| { + let mut above = g; + while let Some(p) = d.parent_of(above).filter(|p| d.node(*p).is_some_and(is_objects_on_path)) { + above = p; + } + let (parent, index, _) = d.position(above).ok_or(EngineError::NoNode(above))?; + d.move_node(member, parent, index + 1)?; + sel.set([member]); + Ok(()) + })?; + Ok(json!({ "group": g.0, "member": member.0 })) +} + // ---------- Expand ---------- fn expand(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index eaf1597423..3464df3833 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind}; -use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; +use super::appearance::{ItemTarget, edit_items, edit_items_then, item_target, row_targets}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; use super::{ Affine, CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, leaf_targets, ok, @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, transparency ), @@ -1062,7 +1062,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result { return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. - let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? }; + let item_ids = if editing_mask { ids } else { row_targets(s, p)? }; if explicit { let d = &s.doc()?.doc; if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index b4639f0de5..2dee458930 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -481,6 +481,12 @@ fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) { } } +#[cfg(test)] +thread_local! { + /// Anchors that [`merge_coincident`] moved to a lower index of their vector on this thread. + static ANCHOR_MOVES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which /// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many /// anchors went. @@ -501,23 +507,68 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: join_point(sp, 0, smooth); } } - // Back to front, so the indices `picked` knows stay true for the anchors still to visit. - for i in (1..sp.anchors.len()).rev() { + // Back to front, so the indices `picked` knows stay true for the anchors still to visit. A + // merged anchor is only marked here; the marked anchors are removed in one pass at the end. + let closed = sp.closed; + let anchors = &mut sp.anchors; + let len = anchors.len(); + let mut gone = vec![false; len]; + // The anchor after `i` that is still on the path, and the last one still on it. + let mut next = None; + let mut last = len.saturating_sub(1); + for i in (1..len).rev() { if picked(i - 1) && picked(i) - && let Some([a, b]) = sp.anchors.get_mut(i - 1..=i) + && let Some([a, b]) = anchors.get_mut(i - 1..=i) && same(a, b) { a.h_out = b.h_out; - sp.anchors.remove(i); - if let Some(smooth) = join_kind { - join_point(sp, i - 1, smooth); + if let Some(slot) = gone.get_mut(i) { + *slot = true; } + if next.is_none() { + last = i - 1; + } + if let Some(smooth) = join_kind { + // The neighbors the anchor has on the path as it now stands, as `join_point` sees them. + let prev = if i > 1 { Some(i - 2) } else { closed.then_some(last) }; + let after = next.or(closed.then_some(0)); + join_point_between(anchors, i - 1, prev, after, smooth); + } + } else { + next = Some(i); } } + #[cfg(test)] + if let Some(first) = gone.iter().position(|&g| g) { + // `retain` moves each anchor it keeps after the first one it drops. + let dropped = gone.iter().filter(|&&g| g).count(); + ANCHOR_MOVES.set(ANCHOR_MOVES.get() + (len - first - dropped)); + } + if gone.contains(&true) { + let mut k = 0; + anchors.retain(|_| { + let keep = !gone.get(k).copied().unwrap_or(false); + k += 1; + keep + }); + } before - sp.anchors.len() } +/// [`join_point`] on anchor `ai` of `anchors` with its neighbors given by index. +fn join_point_between(anchors: &mut [Anchor], ai: usize, prev: Option, next: Option, smooth: bool) { + let at = |j: Option| j.and_then(|j| anchors.get(j)).map(|a| a.p); + let (prev, next) = (at(prev), at(next)); + if let Some(a) = anchors.get_mut(ai) { + if smooth { + a.smooth_between(prev, next); + } else { + a.retract(); + } + } +} + /// Close subpath `si` of path `id`, merging coincident endpoints if present. fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option) -> Result<()> { let path = path_mut(d, id)?; @@ -751,8 +802,19 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result { let mut merged = vec![]; for (id, picked) in &st.selection.anchors { let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue }; - let n: usize = - path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)), join_kind)).sum(); + // One flag per anchor; references past the end of a subpath or to a missing one pick nothing. + let mut masks: Vec> = path.subpaths.iter().map(|sp| vec![false; sp.anchors.len()]).collect(); + for &(si, ai) in picked { + if let Some(flag) = masks.get_mut(si).and_then(|m| m.get_mut(ai)) { + *flag = true; + } + } + let n: usize = path + .subpaths + .iter_mut() + .zip(&masks) + .map(|(sp, mask)| merge_coincident(sp, |ai| mask.get(ai).copied().unwrap_or(false), join_kind)) + .sum(); if n > 0 { merged.push((*id, path)); } @@ -1276,7 +1338,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .into_iter() .enumerate() .flat_map(|(si, sp)| { - let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect(); + let cuts = subpath_refs(refs, si).collect(); without_segments(sp, &cuts) }) .filter(|s| s.anchors.len() >= 2) @@ -1288,7 +1350,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .into_iter() .enumerate() .flat_map(|(si, sp)| { - let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect(); + let gone = subpath_refs(set, si).collect(); without_anchors(sp, &gone) }) .filter(|s| s.anchors.len() >= 2) @@ -1314,6 +1376,12 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { ok() } +/// The anchor or segment indexes in `refs` that belong to subpath `si`, in index order. +/// `refs` is ordered by subpath first, so this reads only that subpath's entries. +pub(super) fn subpath_refs(refs: &BTreeSet<(usize, usize)>, si: usize) -> impl Iterator + '_ { + refs.range((si, 0)..=(si, usize::MAX)).map(|&(_, i)| i) +} + /// Retain path `id` from `d` without copying its geometry before deletion takes its subpaths. /// The shared snapshot supplies the original path classification and, only if needed, gradient /// bounds after the working node's geometry has moved. Returns `NoNode` for a missing/non-path ID. @@ -1722,7 +1790,7 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result { let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue }; let (mut subs, mut starts) = (vec![], vec![]); for (si, sp) in pd.subpaths.iter().enumerate() { - let at: BTreeSet = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect(); + let at: BTreeSet = subpath_refs(set, si).collect(); let pieces = sp.cut_at(&at); // The pieces that start at a cut: every piece of an opened closed subpath, all but the // first of an open one. @@ -1763,3 +1831,140 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result { })?; Ok(super::draw2::ids_json(&ids)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `merge_coincident` removing each merged anchor from the vector straight away, kept as the + /// reference the one-pass version must match. + fn merge_coincident_by_removal(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: Option) -> usize { + let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6; + let before = sp.anchors.len(); + if sp.closed + && before > 2 + && picked(0) + && picked(before - 1) + && let [first, .., last] = sp.anchors.as_mut_slice() + && same(first, last) + { + first.h_in = last.h_in; + sp.anchors.pop(); + if let Some(smooth) = join_kind { + join_point(sp, 0, smooth); + } + } + for i in (1..sp.anchors.len()).rev() { + if picked(i - 1) + && picked(i) + && let Some([a, b]) = sp.anchors.get_mut(i - 1..=i) + && same(a, b) + { + a.h_out = b.h_out; + sp.anchors.remove(i); + if let Some(smooth) = join_kind { + join_point(sp, i - 1, smooth); + } + } + } + before - sp.anchors.len() + } + + /// Every field of every anchor, bit for bit. + fn bits(sp: &SubPath) -> (Vec<[u64; 6]>, Vec, bool) { + let b = |p: Point| [p.x.to_bits(), p.y.to_bits()]; + let fields = sp.anchors.iter().map(|a| [b(a.p), b(a.h_in), b(a.h_out)].concat().try_into().unwrap()).collect(); + (fields, sp.anchors.iter().map(|a| a.kind).collect(), sp.closed) + } + + /// A small linear congruential generator, so the cases are the same on every run. + struct Lcg(u64); + impl Lcg { + fn below(&mut self, n: u64) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (self.0 >> 33) % n + } + } + + fn same_as_removal(sp: &SubPath, picked: &dyn Fn(usize) -> bool, join_kind: Option) { + let (mut ours, mut theirs) = (sp.clone(), sp.clone()); + let n = merge_coincident(&mut ours, picked, join_kind); + let m = merge_coincident_by_removal(&mut theirs, picked, join_kind); + assert_eq!(n, m, "{sp:?} {join_kind:?}"); + assert_eq!(bits(&ours), bits(&theirs), "{sp:?} {join_kind:?}"); + } + + /// Short paths with points on, near and just past the merge distance, selections with stale + /// indices, every anchor kind and every join kind give the same anchors, bit for bit, as + /// removing each merged anchor straight away. + #[test] + fn merging_coincident_anchors_matches_removing_them_one_by_one() { + let spots = [(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (5e-7, 0.0), (0.0, 1.5e-6), (10.0, 0.0), (5.0, 5.0), (10.0, 0.0)]; + let mut rng = Lcg(7); + for _ in 0..30_000 { + let n = rng.below(9) as usize; + let anchors = (0..n) + .map(|_| { + let (x, y) = spots[rng.below(spots.len() as u64) as usize]; + let p = Point::new(x, y); + let handle = |rng: &mut Lcg| match rng.below(3) { + 0 => p, + _ => p + Vec2::new(rng.below(7) as f64 - 3.0, rng.below(7) as f64 - 3.0), + }; + let (h_in, h_out) = (handle(&mut rng), handle(&mut rng)); + let kind = if rng.below(2) == 0 { AnchorKind::Corner } else { AnchorKind::Smooth }; + Anchor { p, h_in, h_out, kind } + }) + .collect(); + let sp = SubPath::new(anchors, rng.below(2) == 0); + let picks: Vec = (0..n + 2).map(|_| rng.below(4) != 0).collect(); + let join_kind = [None, Some(false), Some(true)][rng.below(3) as usize]; + same_as_removal(&sp, &|i| picks.get(i).copied().unwrap_or(false), join_kind); + } + } + + /// A smooth join of three anchors on one another takes the outgoing handle the middle anchor + /// was given when it joined the right one: an already smooth left anchor keeps that handle. + #[test] + fn a_smooth_join_of_three_anchors_keeps_the_handle_the_middle_one_got() { + let o = Point::ZERO; + let left = Anchor { p: o, h_in: Point::new(-2.0, 0.0), h_out: o, kind: AnchorKind::Smooth }; + let right = Anchor { p: o, h_in: o, h_out: Point::new(0.0, 5.0), kind: AnchorKind::Corner }; + let mut sp = SubPath::new(vec![left, Anchor::corner(o), right, Anchor::corner(Point::new(10.0, 0.0))], false); + assert_eq!(merge_coincident(&mut sp, |i| i < 3, Some(true)), 2); + let a = sp.anchors[0]; + assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(-2.0, 0.0), Point::new(10.0 / 3.0, 0.0), AnchorKind::Smooth)); + assert_eq!(sp.anchors.len(), 2); + } + + /// One long run of selected anchors on one another, open or closed, becomes one anchor. + #[test] + fn a_long_run_of_anchors_on_one_another_becomes_one() { + for closed in [false, true] { + for join_kind in [None, Some(false), Some(true)] { + let anchors = (0..50_000).map(|i| Anchor::smooth(Point::ZERO, Point::new(1.0, i as f64))).collect(); + let sp = SubPath::new(anchors, closed); + same_as_removal(&sp, &|_| true, join_kind); + let mut ours = sp.clone(); + assert_eq!(merge_coincident(&mut ours, |_| true, join_kind), 49_999); + assert_eq!((ours.anchors.len(), ours.closed), (1, closed)); + } + } + } + + /// A long selected run at the start of a path followed by as many anchors that stay: each + /// anchor after the run moves once, not once per merged anchor. + #[test] + fn a_long_run_before_many_other_anchors_merges_in_one_pass() { + let run = 300_000; + let anchors = (0..run).map(|_| Anchor::corner(Point::ZERO)).chain((1..=run).map(|i| Anchor::corner(Point::new(i as f64, 1.0)))).collect(); + let mut sp = SubPath::new(anchors, false); + ANCHOR_MOVES.set(0); + assert_eq!(merge_coincident(&mut sp, |i| i < run, Some(false)), run - 1); + assert_eq!(ANCHOR_MOVES.get(), run); + assert_eq!(sp.anchors.len(), run + 1); + assert_eq!(sp.anchors[0].p, Point::ZERO); + assert_eq!(sp.anchors[1].p, Point::new(1.0, 1.0)); + assert_eq!(sp.anchors[run].p, Point::new(run as f64, 1.0)); + } +} diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index c617c4de8e..ac57d1852f 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -1228,7 +1228,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { Some(l) => po::simplify_level_tolerance(l), None => given_tolerance.unwrap_or(1.0), }; - let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, smooth: 0.0 }; + let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, ..Default::default() }; // A path with segments direct-selected simplifies only their runs, and keeps those runs (and the // other anchors it had selected) direct-selected. let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 36444878ef..20f19ef803 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -58,6 +58,9 @@ use crate::{EngineError, MAX_COORD}; const PLACE: &str = "file.place"; const QUEUE: &str = "file.place.queue"; +/// The error of a file read apart from the session while another document is active, or after the +/// document it was read for closed ([`Session::place_read`], [`Session::place_queue_read`]). +const NOT_ACTIVE: &str = "the document it was prepared for is no longer active"; /// The most files one `file.place.queue` loads. const MAX_QUEUE: usize = 100; /// The longest thumbnail side `thumbnail` may ask for (px). @@ -503,11 +506,13 @@ fn fit(natural: Rect, r: Rect) -> Affine { Affine::translate(r.center().to_vec2()) * Affine::scale(k) * Affine::translate(-natural.center().to_vec2()) } -/// What `file.place` takes from the session before it reads the file: the params, the object -/// Replace swaps and the artboard under `at`, which a DXF drawing is laid out on -/// ([`Session::prepare_place`]). [`PlaceJob::read`] reads and decodes the file without the session, -/// on any thread. +/// What `file.place` takes from the session before it reads the file: the document it places into, +/// the params, the object Replace swaps and the artboard under `at`, which a DXF drawing is laid +/// out on ([`Session::prepare_place`]). [`PlaceJob::read`] reads and decodes the file without the +/// session, on any thread. pub struct PlaceJob { + /// The document it was prepared in (`DocState::uid`). + doc: u64, params: Value, old: Option, /// The object Replace swaps as it was when Place started. @@ -560,10 +565,14 @@ impl Session { /// Run `file.place` with the params of `read` as a command (checked and journaled as any /// other) that places the file `read` holds instead of reading it again → `file.place`'s - /// reply. The object Replace swaps must still be in the document as it was when Place started; - /// when the selection changed meanwhile, `select.set` selects that object again first, so a - /// replay of the journal replaces the same object. + /// reply. The document Place started in must be the active one, and the object Replace swaps + /// must still be in it as it was when Place started; when the selection changed meanwhile, + /// `select.set` selects that object again first, so a replay of the journal replaces the same + /// object. pub fn place_read(&mut self, read: PlaceRead) -> Result { + if self.doc()?.uid != read.job.doc { + return Err(bad(PLACE, NOT_ACTIVE)); + } let params = read.job.params.clone(); if let Some(old) = read.job.old { let st = self.doc()?; @@ -615,7 +624,7 @@ fn prepare(s: &Session, p: &Value) -> Result { (true, _) => return Err(bad(PLACE, "replace: select the one object to replace")), }; let replaced = old.and_then(|id| st.doc.node(id)).cloned(); - Ok(PlaceJob { params: p.clone(), old, replaced, target: board_at(&st.doc, at) }) + Ok(PlaceJob { doc: st.uid, params: p.clone(), old, replaced, target: board_at(&st.doc, at) }) } /// Place the file `read` holds in the active document. @@ -755,6 +764,8 @@ fn queued_files(p: &Value) -> Result> { /// ([`Session::prepare_place_queue`]). [`QueueJob::read`] reads them without the session, on any /// thread. pub struct QueueJob { + /// The document it was prepared in (`DocState::uid`). + doc: u64, params: Value, files: Vec, } @@ -762,6 +773,8 @@ pub struct QueueJob { /// The files [`QueueJob::read`] read, which [`Session::place_queue_read`] loads the place cursor /// with. pub struct QueueRead { + /// The document the files were read for (`DocState::uid`). + doc: u64, params: Value, entries: Vec, files: Vec, @@ -776,13 +789,13 @@ impl QueueJob { /// Read every file for its size and thumbnail, as `file.place.queue` does, without the session. pub fn read(self) -> Result { - read_queue(self.params, self.files) + read_queue(self.doc, self.params, self.files) } } -/// Read each of `queued`, the files of `file.place.queue` with params `p`, for its size and -/// thumbnail. -fn read_queue(p: Value, queued: Vec) -> Result { +/// Read each of `queued`, the files of `file.place.queue` with params `p` for the document `doc`, +/// for its size and thumbnail. +fn read_queue(doc: u64, p: Value, queued: Vec) -> Result { let thumbnail = p.get("thumbnail").and_then(Value::as_f64); let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); for mut q in queued { @@ -823,7 +836,7 @@ fn read_queue(p: Value, queued: Vec) -> Result { } } } - Ok(QueueRead { params: p, entries, files, skipped }) + Ok(QueueRead { doc, params: p, entries, files, skipped }) } impl Session { @@ -834,12 +847,17 @@ impl Session { has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?; check_switches(params, &["link", "template"], QUEUE)?; check_numbers(params, &["thumbnail"], QUEUE)?; - Ok(QueueJob { params: params.clone(), files: queued_files(params)? }) + let files = queued_files(params)?; + Ok(QueueJob { doc: self.doc()?.uid, params: params.clone(), files }) } /// Run `file.place.queue` with the params of `read` as a command that loads the place cursor - /// with the files `read` holds instead of reading them again → `file.place.queue`'s reply. + /// with the files `read` holds instead of reading them again → `file.place.queue`'s reply. The + /// document the files were read for must be the active one. pub fn place_queue_read(&mut self, read: QueueRead) -> Result { + if self.doc()?.uid != read.doc { + return Err(bad(QUEUE, NOT_ACTIVE)); + } let params = read.params.clone(); self.queue_hand_off = Some(read); let r = self.execute(QUEUE, ¶ms); @@ -944,7 +962,8 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), }; - let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + // An image in opacity-mask art or a definition's art is found by its id too (`links.list`). + let n = st.doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) }; // Effective resolution: 72 pt per inch over the points one pixel spans along each axis. let [a, b, c, d, ..] = im.xf.as_coeffs(); diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 15a39f06e5..9cd28b7e25 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use vectorcraft_brush::{Brush, BrushKind}; use vectorcraft_color::Paint; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, PatternDef, Symbol}; use super::unique_name; @@ -118,11 +118,12 @@ impl Used { type Renames = BTreeMap, bool)>; /// Names in `dst` for the `used` definitions of `src` (`def`): the same name when `dst` has none -/// (copied) or an equal one (shared), else a free variant (`taken`, copied). -fn names( +/// (copied) or one that is the `same` (shared), else a free variant (`taken`, copied). +fn names( used: &[String], def: impl Fn(&str) -> Option, existing: impl Fn(&str) -> Option, + same: impl Fn(&T, &T) -> bool, taken: impl Fn(&str) -> bool, ) -> Renames { let mut out = Renames::new(); @@ -130,7 +131,7 @@ fn names( let Some(d) = def(name) else { continue }; let entry = match existing(name) { None => (Some(name.clone()), true), - Some(e) if e == d => (Some(name.clone()), false), + Some(e) if same(&e, &d) => (Some(name.clone()), false), Some(_) => { (Some(unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true) } @@ -140,6 +141,65 @@ fn names( out } +/// `n` as `dst` would copy it in ([`Document::reid`]), with the ids a document of its own gives it: +/// art copied in once, which differs from the file's only in its ids, compares equal to the file's. +fn canon(dst: &Document, n: &Node) -> Node { + let mut own = Document::new(0.0, 0.0); + // Pixels the art carries take a key `dst` doesn't use for other pixels, as they do in `dst`. + let mut keys = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| { + if let NodeKind::Image(im) = &c.kind + && im.pixels.is_some() + { + keys.push(im.key.clone()); + } + }); + for (k, blob) in &dst.images { + if keys.iter().any(|key| k == key || k.strip_prefix(key.as_str()).is_some_and(|rest| rest.starts_with('-'))) { + own.images.insert(k.clone(), blob.clone()); + } + } + own.reid(n) +} + +/// Do symbols `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_symbol(dst: &Document, a: &Symbol, b: &Symbol) -> bool { + let own = |s: &Symbol| Symbol { art: Arc::new(canon(dst, &s.art)), ..s.clone() }; + own(a) == own(b) +} + +/// Do patterns `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_pattern(dst: &Document, a: &PatternDef, b: &PatternDef) -> bool { + let bare = |p: &PatternDef| PatternDef { art: vec![], ..p.clone() }; + bare(a) == bare(b) && a.art.len() == b.art.len() && a.art.iter().zip(&b.art).all(|(x, y)| canon(dst, x) == canon(dst, y)) +} + +/// Does art `a` use a resource that `m` (images, symbols, patterns, brushes, swatches) gives +/// another name, or a swatch whose link `m` drops? +fn uses_moved(a: &Node, m: [&Renames; 5]) -> bool { + let moved = |map: &Renames, k: &str| map.get(k).is_some_and(|(new, _)| new.as_deref() != Some(k)); + let (mut a, mut any) = (a.clone(), false); + refs(&mut a, &mut |r| { + any |= match r { + Ref::Image(k) => moved(m[0], k), + Ref::Symbol(k) => moved(m[1], k), + Ref::Pattern(k) => moved(m[2], k), + Ref::Brush(k) => moved(m[3], k), + Ref::Swatch(link) => link.as_deref().is_some_and(|k| moved(m[4], k)), + } + }); + any +} + +/// Copy each of `shared` in `map` under a free name, as [`names`] does for a definition `dst` +/// holds differently. +fn unshare(map: &mut Renames, shared: &[String], used: &[String], taken: impl Fn(&str) -> bool) { + for name in shared { + let free = unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || map.values().any(|(n, _)| n.as_deref() == Some(c))); + map.insert(name.clone(), (Some(free), true)); + } +} + /// The renamed resources whose definitions are copied: (name in `src`, name in `dst`). fn copies(m: &Renames) -> Vec<(&str, &str)> { m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect() @@ -194,14 +254,17 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { } } - let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |c| dst.images.contains_key(c)); - let symbols = names( + let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |a, b| a == b, |c| dst.images.contains_key(c)); + // Symbol and pattern art copied in takes ids of `dst`, so their art is compared by what it + // draws: placing the same file again shares what the first placement brought. + let mut symbols = names( &used.symbols, |n| src.symbols.iter().find(|s| s.name == n), |n| dst.symbols.iter().find(|s| s.name == n), + |a, b| same_symbol(dst, a, b), |c| dst.symbols.iter().any(|s| s.name == c), ); - let patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |c| dst.pattern(c).is_some()); + let mut patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |a, b| same_pattern(dst, a, b), |c| dst.pattern(c).is_some()); // A swatch the document defines differently isn't renamed (that would add look-alike swatches): // the colours linked to it keep their look, unlinked. let swatches: Renames = used @@ -236,12 +299,33 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { any }; let mut dst_brushes = vectorcraft_brush::library(dst); - let brushes = names( + let mut brushes = names( &used.brushes, |n| src_brushes.iter().find(|b| b.name == n).map(|b| (b, moves_art(b))), |n| dst_brushes.iter().find(|b| b.name == n).map(|b| (b, false)), + |a, b| a == b, |c| dst_brushes.iter().any(|b| b.name == c), ); + // Likewise a symbol, pattern or brush whose art uses a resource that moves isn't the + // document's of that name: it is copied under a free name, which can move what uses it in turn. + loop { + let maps = [&images, &symbols, &patterns, &brushes, &swatches]; + let shared = |map: &Renames| map.iter().filter(|(_, (_, copy))| !*copy).map(|(k, _)| k.clone()).collect::>(); + let sym: Vec = + shared(&symbols).into_iter().filter(|n| src.symbols.iter().any(|s| s.name == *n && uses_moved(&s.art, maps))).collect(); + let pat: Vec = + shared(&patterns).into_iter().filter(|n| src.pattern(n).is_some_and(|p| p.art.iter().any(|a| uses_moved(a, maps)))).collect(); + let bru: Vec = shared(&brushes) + .into_iter() + .filter(|n| src_brushes.iter().find(|b| b.name == *n).is_some_and(|b| brush_art(&mut b.clone()).into_iter().any(|a| uses_moved(a, maps)))) + .collect(); + if sym.is_empty() && pat.is_empty() && bru.is_empty() { + break; + } + unshare(&mut symbols, &sym, &used.symbols, |c| dst.symbols.iter().any(|s| s.name == c)); + unshare(&mut patterns, &pat, &used.patterns, |c| dst.pattern(c).is_some()); + unshare(&mut brushes, &bru, &used.brushes, |c| dst_brushes.iter().any(|b| b.name == c)); + } let mut rename = |r: Ref| { let (map, k) = match r { @@ -272,6 +356,8 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { if let Some(mut def) = src.symbols.iter().find(|s| s.name == old).cloned() { def.name = new.to_string(); refs(Arc::make_mut(&mut def.art), &mut rename); + // The art takes ids of `dst`: those of `src` may name objects of `dst` already. + def.art = Arc::new(dst.reid(&def.art)); dst.symbols.push(def); } } @@ -280,6 +366,7 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { def.name = new.to_string(); for a in &mut def.art { refs(Arc::make_mut(a), &mut rename); + *a = Arc::new(dst.reid(a)); } dst.patterns.push(def); } diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 9f58e4851d..8e4afaa295 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -378,8 +378,10 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { /// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file /// without Link gives (its resources joining `d`, renamed where `d` uses a name for something /// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand. +/// A placed document in opacity-mask art or in the art of a symbol, graph design or pattern is +/// replaced where it is stored. pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let n = d.stored_node(id).map(|(n, _)| Node::clone(n)).ok_or(EngineError::NoNode(id))?; let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) }; let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) @@ -395,6 +397,10 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m); let mut out = n.clone(); out.kind = art.kind; + if d.node(id).is_none() { + *d.stored_node_mut(id).ok_or(EngineError::NoNode(id))? = out; + return Ok(()); + } let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; d.remove(id)?; d.insert(par, index, out)?; diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index dfa44a1436..3fa082bdb0 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -672,13 +672,53 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool { } } +#[cfg(test)] +thread_local! { + /// Tests: runs once in the next `plugin.install` on this thread after its checks and before it + /// installs, as another session changing the plug-ins in between would. + pub(crate) static BEFORE_INSTALL: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once in the next `plugin.install` on this thread right after it reads the grant + /// held for the id. + pub(crate) static AFTER_GRANT_READ: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once in the next `plugin.install` on this thread after it installs and before it + /// writes the store. + pub(crate) static BEFORE_STORE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Tests: runs once on this thread when [`hold_plugin_changes`] finds the lock held and waits. + pub(crate) static WHILE_WAITING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Held by `plugin.install` from its first read of the grant until the store holds what it +/// installed, and throughout by `plugin.remove`, by loads of the stored plug-ins +/// ([`Session::load_stored_plugins`]) and by loads of a plug-ins folder: these run one after the +/// other across sessions. +static PLUGIN_CHANGES: Mutex<()> = Mutex::new(()); + +/// Takes [`PLUGIN_CHANGES`], waiting while another session holds it. +pub(crate) fn hold_plugin_changes() -> std::sync::MutexGuard<'static, ()> { + match PLUGIN_CHANGES.try_lock() { + Ok(held) => held, + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + #[cfg(test)] + if let Some(waiting) = WHILE_WAITING.take() { + waiting(); + } + PLUGIN_CHANGES.lock().unwrap_or_else(|e| e.into_inner()) + } + } +} + /// `plugin.install`: loads and checks the module, refuses `grant` and `allowHeavy` that neither an /// [`InstallApproval`] for this module nor the session allows, one that asks for a heavy budget /// unless `allowHeavy` says a person agreed, one that lists permissions no person has granted this /// very module unless `grant` lists them all, one that would take another module's place -/// ([`takes_over`]) unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records -/// the grant with the module's hash, keeps it for the next start and installs it → its -/// `plugin.list` entry. A module that lists no permissions ends any grant held for its id. +/// ([`takes_over`]) unless `replace` is true, and one whose SHA-256 isn't the `hash` given. It then +/// checks the plug-in installed under the id and its grant again while it records the grant with +/// the module's hash and installs it ([`registry::install_checked`]), and only then keeps both for +/// the next start → its `plugin.list` entry. A refused install changes neither the plug-in store +/// nor the plug-ins and grants of the process. A module that lists no permissions ends any grant +/// held for its id. A `plugin.remove` in another session waits until the install has written the +/// store. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; // One use: spent whatever this install does. @@ -696,21 +736,36 @@ fn install(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); } - if replace == Some(false) && registry::get(plugin.id()).is_some() { - return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); + // A removal in another session waits until the store holds what this install wrote. + let _held = hold_plugin_changes(); + // The grant the checks below count on, the approval included. The install checks it again, + // with the plug-in installed under the id, before anything is stored: another session may + // change either in between. + let seen_grant = registry::granted(plugin.id()); + #[cfg(test)] + if let Some(between) = AFTER_GRANT_READ.take() { + between(); } - // Another module under the id gets the settings and cache kept for it: whoever installs it asks - // the user first. - if replace != Some(true) && takes_over(s, &plugin) { + let installed_already = || bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())); + let taken_over = || { let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME); - return Err(bad( + bad( C, format!( "“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees", plugin.id() ), - )); + ) + }; + if replace == Some(false) && registry::get(plugin.id()).is_some() { + return Err(installed_already()); } + // Another module under the id gets the settings and cache kept for it: whoever installs it asks + // the user first. + if replace != Some(true) && takes_over(s, &plugin) { + return Err(taken_over()); + } + let (installed_already, taken_over) = (installed_already(), taken_over()); // `grant` and `allowHeavy` say that a person agreed, and a request alone can't say that. let approval = approval.filter(|a| a.covers(&plugin)); if !s.plugin_grants_from_requests && ((grant.is_some() && approval.is_none()) || (allow_heavy && !approval.is_some_and(|a| a.heavy))) { @@ -744,15 +799,55 @@ fn install(s: &mut Session, p: &Value) -> Result { // this very module already covers all of them. let m = plugin.manifest(); let wanted = m.permissions(); - if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { + let needs_approval = || { let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME); let asked = permissions::names(&wanted).join(", "); - return Err(bad( + bad( C, format!( "“{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants" ), - )); + ) + }; + let granted_here = grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))); + // Without a plug-in store, the grant read above approves the module or doesn't. + let approved = match s.plugin_dir { + None => !m.asks_permissions() || seen_grant.as_ref().is_some_and(|g| g.covers(&m.id, plugin.hash(), &wanted)), + Some(_) => s.plugin_approved(&plugin), + }; + if !approved && !granted_here { + return Err(needs_approval()); + } + let needs_approval = needs_approval(); + let (id, hash, asks) = (m.id.clone(), plugin.hash().to_string(), m.asks_permissions()); + let g = Grant { id: id.clone(), hash: hash.clone(), permissions: wanted }; + let update = if asks { registry::GrantUpdate::Record(GrantToken::after_engine_approval(g.clone())) } else { registry::GrantUpdate::Revoke }; + // Another module installed under the id since the checks above takes the place they found free, + // or held by these bytes. A grant that changed since no longer approves what they approved, + // unless `grant` approves this module by itself or the new grant covers it. + let check = |installed: Option<&Plugin>, held: Option<&Grant>| { + if let Some(installed) = installed { + match replace { + Some(false) => return Err(installed_already), + None if installed.hash() != hash => return Err(taken_over), + _ => {} + } + } + if asks && !granted_here && held != seen_grant.as_ref() && !held.is_some_and(|h| h.covers(&id, &hash, &g.permissions)) { + return Err(needs_approval); + } + Ok(()) + }; + #[cfg(test)] + if let Some(between) = BEFORE_INSTALL.take() { + between(); + } + // The plug-in gets the settings and cache kept for its id. A module without permissions takes + // the id over, so nothing the user approved for the id applies any more. + let installed = registry::install_checked(plugin, update, check, || s.attach_plugin_storage(&id), err)?; + #[cfg(test)] + if let Some(between) = BEFORE_STORE.take() { + between(); } // Installed plug-ins stay installed after a restart (#2541). The module is written first and its // grant only once it is there; when the grant can't be written, the store gets the module it @@ -760,18 +855,14 @@ fn install(s: &mut Session, p: &Value) -> Result { // which the grant doesn't cover. A write that reports a failure may still have put the old // module back, so the store is read again to tell which module it holds. A stored module that // can't be read back isn't replaced by one that needs a grant. - let previous = s.stored_plugin_bytes(plugin.id()); - let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); - let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); - if m.asks_permissions() { - let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; - registry::grant(GrantToken::after_engine_approval(g.clone())); - if stored && !s.store_grant(&m.id, Some(g)) { - stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s); + let previous = s.stored_plugin_bytes(&id); + let mut stored = (previous.is_ok() || !asks) && s.store_plugin(&id, &bytes); + let holds_new = |s: &Session| s.stored_plugin_bytes(&id).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); + if asks { + if stored && !s.store_grant(&id, Some(g)) { + stored = !s.put_back_plugin(&id, previous.ok().flatten().as_deref()) && holds_new(s); } } else { - // This module takes the id over, so nothing the user approved for the id applies any more. - registry::revoke(&m.id); // When the write failed, the old stored module stays in the folder and needs its grant at // the next launch. A grant that can't be removed still approves the old bytes, so the store // gets the module it held back. When that module can't be read or written back, or there @@ -780,23 +871,21 @@ fn install(s: &mut Session, p: &Value) -> Result { // approves nothing, and the new module stays; one that can't be opened or read may approve // the old bytes once it reads again. let writes = s.grant_writes(); - if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { - let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); + if stored && !s.store_grant(&id, None) && s.holds_plugin_grant(&id) { + let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(&id, held.as_deref())); stored = !back && holds_new(s); if !back || matches!(previous, Ok(None)) { - s.ignore_stored_grant(&m.id, writes); + s.ignore_stored_grant(&id, writes); } } } - // It gets the settings and cache kept for its id. - s.attach_plugin_storage(plugin.id()); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. - let file = s.stored_plugin_file(plugin.id()); + let file = s.stored_plugin_file(&id); let mut changed = false; if file.is_none() || stored { - changed = safe_mode::set_enabled(&safe_mode::Item::of(&plugin), true); + changed = safe_mode::set_enabled(&safe_mode::Item::of(&installed), true); if let Some(file) = file { changed |= safe_mode::set_enabled(&safe_mode::Item::file(file), true); } @@ -804,7 +893,6 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - let installed = registry::install(plugin).map_err(err)?; let mut v = describe_installed(&installed); if !unknown.is_empty() { v["unknownCommands"] = json!(unknown); @@ -904,6 +992,8 @@ fn load_folder(s: &Session, folder: &str) -> Value { None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), }; + // A `plugin.install` in another session finishes writing the store first. + let _held = hold_plugin_changes(); match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) { Ok(r) => { for id in &r.loaded { @@ -1074,6 +1164,8 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + // An install in another session finishes its store writes first. + let _held = hold_plugin_changes(); let installed = registry::get(id).is_some(); // Settings and cache the store keeps for an id that isn't installed are deleted too. let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id); diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index f4b91edd9b..71fbcc8540 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -413,6 +413,7 @@ pub(crate) fn transaction(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess st.interaction = None; } saved.restore(s); + s.sync_front_profiles(); Err(e) } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 5958a84b53..21d9d65c3d 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -314,6 +314,7 @@ pub(super) const ALLOWED: &[&str] = &[ "object.move", "object.nudge", "object.objectsOnPath.attach", + "object.objectsOnPath.detach", "object.objectsOnPath.expand", "object.objectsOnPath.moveMember", "object.objectsOnPath.options", @@ -478,6 +479,7 @@ pub(super) const ALLOWED: &[&str] = &[ "recolor.pickTheme", "recolor.randomize", "recolor.reduce", + "recolor.weigh", "select.add", "select.all", "select.allOnArtboard", diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a73485f4fa..748e0a125a 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -454,10 +454,10 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> { /// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they /// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`), the /// Perspective Grid Options (`perspective.widget.options`), the Pathfinder Options -/// (`object.pathfinder.options`) and the Snap to Glyph options (`view.snapToGlyph.options`). -/// `prefs.get` and `prefs.set` take them by key (a partial object updates what it names) and -/// `prefs.reset` without a category resets them. -pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinder", "glyphSnap"]; +/// (`object.pathfinder.options`), the Snap to Glyph options (`view.snapToGlyph.options`) and the +/// Pixel Snapping Options (`view.snapToPixel.options`). `prefs.get` and `prefs.set` take them by key +/// (a partial object updates what it names) and `prefs.reset` without a category resets them. +pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinder", "glyphSnap", "pixelSnap"]; /// Validate a value for preference group `key` against `current`. fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result { @@ -477,6 +477,10 @@ fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result< let cur: vectorcraft_tools::GlyphSnap = serde_json::from_value(current.clone()).unwrap_or_default(); cur.merged(v).map(|o| json!(o)) } + "pixelSnap" => { + let cur: vectorcraft_tools::PixelSnap = serde_json::from_value(current.clone()).unwrap_or_default(); + cur.merged(v).map(|o| json!(o)) + } "perspectiveWidget" => { let mut merged = current.clone(); if let (Some(o), Some(p)) = (merged.as_object_mut(), v.as_object()) { @@ -788,7 +792,7 @@ pub fn specs() -> Vec { "Export Preferences", [], None, - "{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder and glyphSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}", + "{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}", always, export ), diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 931bcce681..8194f85547 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -7,7 +7,9 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; -use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; +use vectorcraft_color::recolor::{ + ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, WeighUnit, cluster, row_shares, same_ink, weigh as weigh_rows, +}; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -63,6 +65,15 @@ pub fn specs() -> Vec { has_doc, pick_theme ), + cmd!( + query "recolor.weigh", + "Prominent Colors", + [], + None, + "{map: rows [{from, to, …}] or {current: new} as recolor.apply, weights?: [numbers ≥ 0, one per row; an excluded row's is ignored, and at least one other must be above 0], ids?: as recolor.colors} → {map: the rows as given, each with its current colours (from) moved as below, shares: [one per row: its percent of the uses (recolor.colors' count) of the current colours of the rows that aren't excluded; 0 for an excluded row]} for Recolor Artwork's Prominent Colors (VectorCraft's own rule): without weights the rows stay as they are. With weights, current colours move one at a time between the rows that aren't excluded and have a new colour (to), while a move brings each row's share of the uses nearer its share of the weights, first the colour nearest in Lab to the first colour of the row it goes to compared with the row it leaves; a row's first colour stays in it, the tints of one global swatch in a row move together, a colour that another row with a new colour lists first stays where it is, the other rows with a new colour drop their entries for a colour that moves, and a use counts for the row recolor.apply recolors it with (rows with a new colour first, an exact colour before a hex one, the first row that lists it), else for the first other row that isn't excluded and lists it. The colours that can move are compared with the rows at most 50,000,000 times in all, and when they times the rows that aren't excluded pass that, none move. Changes nothing", + has_doc, + weigh + ), ] } @@ -1062,6 +1073,181 @@ fn colors(s: &mut Session, p: &Value) -> Result { Ok(json!({ "colors": v })) } +/// `recolor.weigh`'s rows as given, each with `from` as a list (a `{current: new}` map gives a +/// row per entry). The rows have already been read by [`parse_rows`]. +fn given_rows(p: &Value) -> Vec { + match p.get("map") { + Some(Value::Array(rows)) => rows + .iter() + .map(|r| { + let mut r = r.clone(); + if let Some(f) = r.get("from").filter(|f| !f.is_array()).cloned() { + r["from"] = json!([f]); + } + r + }) + .collect(), + Some(Value::Object(m)) => m.iter().map(|(k, v)| json!({"from": [k], "to": v})).collect(), + _ => vec![], + } +} + +fn weigh(s: &mut Session, p: &Value) -> Result { + const C: &str = "recolor.weigh"; + let rows = parse_rows(p, C)?; + let weights = match p.get("weights") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) if a.len() == rows.len() => Some( + a.iter() + .map(|v| { + v.as_f64() + .filter(|x| x.is_finite() && *x >= 0.0) + .ok_or_else(|| bad(C, format!("each weight must be a number of 0 or more (got {v})"))) + }) + .collect::>>()?, + ), + Some(v) => return Err(bad(C, format!("`weights` must be a list of {} numbers, one per row (got {v})", rows.len()))), + }; + let active: Vec = (0..rows.len()).filter(|&i| !rows[i].exclude).collect(); + if let Some(w) = &weights + && !active.is_empty() + && !active.iter().any(|&i| w[i] > 0.0) + { + return Err(bad(C, "at least one row that isn't excluded needs a weight above 0")); + } + let (ids, only) = scope_ids(s, p, C)?; + let st = s.doc()?; + let mut used: Vec> = used_colors(&st.doc, &ids, only.as_ref()).into_iter().map(Some).collect(); + let mut by_key: HashMap = HashMap::new(); + let mut by_hex: HashMap> = HashMap::new(); + for (i, u) in used.iter().enumerate().filter_map(|(i, u)| Some((i, u.as_ref()?))) { + by_key.insert(ColorKey::of(&u.color), i); + by_hex.entry(u.color.to_hex()).or_default().push(i); + } + // Each current colour's uses and global swatch. A use counts where recolor.apply recolors it: + // rows with a new colour first, exact colours before hex colours, the first row that lists it; + // then the other rows that aren't excluded, in the same way. `owner` holds the row and entry that took each colour, + // and `shadows` the entries of rows with a new colour whose colours other entries took. + let mut owner: Vec> = vec![None; used.len()]; + let mut shadows: Vec<(usize, usize, Vec)> = vec![]; + let mut found: Vec)>> = rows.iter().map(|r| vec![(0, None); r.from.len()]).collect(); + for recolors in [true, false] { + for hex in [false, true] { + for (r, row) in rows.iter().enumerate().filter(|(_, r)| !r.exclude && r.new_color().is_some() == recolors) { + for (e, m) in row.from.iter().enumerate().filter(|(_, m)| matches!(m, Match::Hex(_)) == hex) { + let hits: Vec = match m { + Match::Key(k) => by_key.get(k).copied().into_iter().collect(), + Match::Hex(h) => by_hex.get(h).cloned().unwrap_or_default(), + }; + let (mut uses, mut link, mut took, mut taken) = (0, None, false, vec![]); + for i in hits { + match used[i].take() { + Some(u) => { + uses += u.count; + link = link.or(u.swatch); + took = true; + owner[i] = Some((r, e)); + } + None => taken.push(i), + } + } + found[r][e] = (uses, link); + if recolors && !took && !taken.is_empty() { + shadows.push((r, e, taken)); + } + } + } + } + } + // The units that move: in each row that isn't excluded, the colours linked to one global + // swatch go together, each other colour on its own, and the unit of the first colour stays. + let (mut units, mut members): (Vec, Vec<(usize, Vec)>) = (vec![], vec![]); + let mut unit_of: HashMap<(usize, usize), usize> = HashMap::new(); + for (n, &r) in active.iter().enumerate() { + let mut groups: Vec<(Option<&String>, Vec, usize)> = vec![]; + let mut by_link: HashMap<&String, usize> = HashMap::new(); + for (e, (uses, link)) in found[r].iter().enumerate() { + let g = match link { + Some(l) => *by_link.entry(l).or_insert_with(|| { + groups.push((Some(l), vec![], 0)); + groups.len() - 1 + }), + None => { + groups.push((None, vec![], 0)); + groups.len() - 1 + } + }; + groups[g].1.push(e); + groups[g].2 += uses; + } + for (k, (_, entries, uses)) in groups.into_iter().enumerate() { + for &e in &entries { + unit_of.insert((r, e), units.len()); + } + units.push(WeighUnit { color: rows[r].from[entries[0]].color(), uses: uses as f64, row: n, fixed: k == 0 }); + members.push((r, entries)); + } + } + // recolor.apply matches a colour to the first row with a new colour that lists it. A colour + // that another such row's first colour also lists stays where it is; another row's other + // entries for a colour that moves are dropped from that row below. + let owners = |taken: &[usize]| -> Vec { taken.iter().filter_map(|&i| unit_of.get(&owner[i]?).copied()).collect() }; + for (_, _, taken) in shadows.iter().filter(|(_, e, _)| *e == 0) { + for u in owners(taken) { + units[u].fixed = true; + } + } + if let Some(w) = &weights { + // Only rows with a new colour recolor what they hold, so only they give and take colours. + let movers: Vec = (0..active.len()).filter(|&n| rows[active[n]].new_color().is_some()).collect(); + let anchors: Vec = + movers.iter().map(|&n| rows[active[n]].from.first().map(Match::color).or(rows[active[n]].to).unwrap_or_default()).collect(); + let mut mover_of = vec![None; active.len()]; + for (m, &n) in movers.iter().enumerate() { + mover_of[n] = Some(m); + } + let at: Vec> = units.iter().map(|u| mover_of[u.row]).collect(); + let sub: Vec = units.iter().zip(&at).filter_map(|(u, m)| Some(WeighUnit { row: (*m)?, ..*u })).collect(); + let to = weigh_rows(&sub, &anchors, &movers.iter().map(|&n| w[active[n]]).collect::>()); + for ((u, _), m) in units.iter_mut().zip(&at).filter(|(_, m)| m.is_some()).zip(to) { + u.row = movers[m]; + } + } + let mut out = given_rows(p); + if weights.is_some() { + // Each row's colours: those that stayed, in their order, then those that came to it. + // A row's entry for a colour that moved to another row would take it back, so it goes. + let moved = |u: usize| active[units[u].row] != members[u].0; + let dropped: std::collections::HashSet<(usize, usize)> = + shadows.iter().filter(|(_, e, taken)| *e != 0 && owners(taken).into_iter().any(moved)).map(|(r, e, _)| (*r, *e)).collect(); + let old: Vec> = out.iter().map(|r| r["from"].as_array().cloned().unwrap_or_default()).collect(); + let mut stayed: Vec> = active.iter().map(|_| vec![]).collect(); + let mut came: Vec> = active.iter().map(|_| vec![]).collect(); + for (u, (r, entries)) in units.iter().zip(&members) { + let kept = entries.iter().copied().filter(|&e| !dropped.contains(&(*r, e))); + if active[u.row] == *r { + stayed[u.row].extend(kept); + } else { + came[u.row].extend(kept.filter_map(|e| old[*r].get(e).cloned())); + } + } + for (n, &r) in active.iter().enumerate() { + stayed[n].sort_unstable(); + let mut from: Vec = stayed[n].iter().filter_map(|&e| old[r].get(e).cloned()).collect(); + from.append(&mut came[n]); + if let Some(row) = out.get_mut(r) { + row["from"] = Value::Array(from); + } + } + } + let shares = row_shares(&units, active.len()); + let mut all = vec![0.0; rows.len()]; + for (n, &r) in active.iter().enumerate() { + all[r] = shares[n]; + } + Ok(json!({"map": out, "shares": all})) +} + /// The `method` parameter (`default` without one, or for null). A `method` that is not a string, /// such as `5`, names no method. fn method_param(p: &Value, cmd: &str, default: Method) -> Result { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index bca9ed6ea9..65fe18b282 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -14,7 +14,7 @@ //! paint shows instead of the character colour under it. use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "New Graphic Style", ["Window", "Graphic Styles"], None, - "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", + "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", has_doc, style_new ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Break Link to Graphic Style", ["Window", "Graphic Styles"], None, - "{ids?|id?, target?: \"object\"|\"contents\"} unlink the objects (default: the selection) from their graphic style; they keep their look", + "{ids?|id?, target?: \"object\"|\"contents\"} unlink the objects (default: the selection) from their graphic style; they keep their look. A graph series group's id stands for the group and the paths directly in it", has_doc, style_break_link ), @@ -307,10 +307,11 @@ fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node) impl Session { /// The graphic style of the first selected object: the one it was last linked to, and whether - /// it is still linked (still has that style's look). + /// it is still linked (still has that style's look). A graph series group that holds nothing of its own stands + /// for the topmost path directly inside it, which a style applied to the group links. pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> { let st = self.active()?; - let n = st.doc.node(*st.selection.subjects().first()?)?; + let n = super::appearance::look_holder(&st.doc, st.doc.node(*st.selection.subjects().first()?)?); let g = st.doc.graphic_style_by_id(n.extras().graphic_style?)?; Some((g, in_sync(n, g))) } @@ -331,7 +332,11 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let i = style_index(&s.doc()?.doc, name)?; let add = bool_or(p, "add", false); let chars = s.prefs.override_char_color; - let ids = super::appearance::appearance_targets(s, p)?; + let own = super::appearance::appearance_targets(s, p)?; + let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own); + // The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves, + // and a style added on top unlinks them. + let groups = super::appearance::stood_for(&own, &ids); if ids.is_empty() { if p.get("ids").is_some() || p.get("id").is_some() { return Err(bad("graphicStyle.apply", "no such objects")); @@ -353,6 +358,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { n.extras_mut().graphic_style = None; } } + // The series groups their paths stand for are unlinked with them. + set_link(d, &groups, None); // The graph stores the rows of the kinds the style shows, now on top of each stack. let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); let kinds = (shows(true), shows(false)); @@ -364,6 +371,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { for id in &ids { apply_style(d, *id, &g, &mut shared, chars); } + super::appearance::clear_stood_for(d, &groups); super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } Ok(()) @@ -372,8 +380,11 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { } fn style_new(s: &mut Session, p: &Value) -> Result { - let (src, n) = source(s, p, "graphicStyle.new")?; + let (_, n) = source(s, p, "graphicStyle.new")?; let d = &s.doc()?.doc; + // A series group that holds nothing of its own shows the look of its paths: the style is made from the topmost. + let n = super::appearance::look_holder(d, n); + let src = n.id; let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { Some(n) => unique_name(n, |x| d.graphic_style(x).is_some()), None => d.new_graphic_style_name(), @@ -459,6 +470,9 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let cmd = "graphicStyle.redefine"; let (src, n) = source(s, p, cmd)?; let d = &s.doc()?.doc; + // A series group that holds nothing of its own is redefined from the topmost of its paths, which a style links. + let n = super::appearance::look_holder(d, n); + let src = if n.id == src { src } else { n.id }; let i = match str_param(p, "name") { Some(name) => style_index(d, name)?, None => n @@ -480,12 +494,18 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); + // A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and + // goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look. + let mut follow_marks = super::graph::series_group_marks(d, &follow); + let edited: HashSet = edited.into_iter().collect(); + follow_marks.retain(|nid| *nid != src && !edited.contains(nid)); + super::appearance::clear_stood_for(d, &super::appearance::stood_for(&follow, &follow_marks)); let g = d.graphic_styles[i].clone(); let mut shared = None; - for nid in &follow { + for nid in &follow_marks { apply_style(d, *nid, &g, &mut shared, chars); } - super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); + super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); Ok(()) })?; @@ -493,7 +513,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } fn style_break_link(s: &mut Session, p: &Value) -> Result { - let ids = super::appearance::appearance_targets(s, p)?; + let ids = super::appearance::whole_targets(s, p)?; s.edit("Break Link to Graphic Style", |d, _| { set_link(d, &ids, None); Ok(()) diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 6c80c962d6..c1c3801e40 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -135,6 +135,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { let mut story: Vec = vec![]; let mut sources: Vec = vec![]; let mut later_wins = vec![]; + let mut earlier_wins = vec![]; let mut live = vec![]; for id in &thread { match (text_of(doc, *id), text_of(before, *id)) { @@ -143,6 +144,9 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { // A frame whose first paragraph was just restyled gives the paragraph it // shares with the frame before it that style. later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0))); + // A frame whose last paragraph was just restyled keeps that style in the + // paragraph it shares with the frame after it, even when it is empty. + earlier_wins.push(old.is_some_and(|o| o.para_at(o.paragraph_count() - 1) != t.para_at(t.paragraph_count() - 1))); sources.push(t.clone()); live.push(*id); } @@ -151,6 +155,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { (None, Some(old)) => { story.extend(old.runs.iter().cloned()); later_wins.push(false); + earlier_wins.push(false); sources.push(old.clone()); } (None, None) => {} @@ -160,7 +165,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { continue; } let source_refs: Vec<&TextObject> = sources.iter().collect(); - let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins); + let paras = vectorcraft_text::thread::story_paras_restyled(&source_refs, &later_wins, &earlier_wins); vectorcraft_text::edit::normalize(&mut story); if live.len() >= 2 { // Threaded frames keep their size: Auto Size is off for them (as in Illustrator). diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index b6ea41df3e..a622901c01 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -60,6 +60,130 @@ fn a_restyled_empty_frame_keeps_its_style_before_an_unedited_empty_frame() { assert_eq!([hyphenates(a, 0), hyphenates(a, 1), hyphenates(b, 0), hyphenates(c, 0)], [false, true, true, true]); } +/// Every paragraph's left indent in frame `id`. +fn left_indents(s: &Session, id: u64) -> Vec { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.paragraph_styles().iter().map(|p| p.left_indent).collect(), + _ => panic!("not text"), + } +} + +/// Area type `height` high holding `text`, threaded into `empties` empty rectangles after it. +fn tail_thread(text: &str, height: f64, empties: usize) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": height}})).unwrap()); + let mut ids = vec![a]; + for k in 0..empties { + let x = 200.0 * (k + 1) as f64; + ids.push(new_id(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 150, "height": 60})).unwrap())); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + (s, ids) +} + +/// Restyling a frame's empty last paragraph (after its final paragraph break) gives that +/// paragraph the style in the next frame as well, instead of the next frame's old style. +#[test] +fn editing_a_frames_empty_last_paragraph_survives_thread_reflow() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "a\n", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!([text(&s, a), text(&s, b)], ["a\n", ""]); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + // The same format again changes nothing and records no undo step. + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 0.0], vec![0.0])); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); + s.execute("edit.undo", &json!({})).unwrap(); + + // A format of the whole frame changes its last paragraph as well. + s.execute("text.setFormat", &json!({"ids": [a], "leftIndent": 20})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![20.0, 20.0], vec![20.0])); + // Both frames formatted in one command agree. + s.execute("text.setFormat", &json!({"ids": [a, b], "leftIndent": 40})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![40.0, 40.0], vec![40.0])); +} + +/// The restyled empty last paragraph keeps its style when the next frame holds text that +/// continues it, and across an unedited empty frame; a later restyle of a later frame still wins. +#[test] +fn a_restyled_empty_last_paragraph_keeps_its_style_against_later_frames() { + // One line high: the text added to the next frame stays there. + let (mut s, ids) = tail_thread("a\n", 20.0, 1); + let (a, b) = (ids[0], ids[1]); + s.execute("text.editRange", &json!({"id": b, "start": 0, "end": 0, "insert": "def"})).unwrap(); + assert_eq!([text(&s, a), text(&s, b)], ["a\n", "def"]); + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!([text(&s, a), text(&s, b)], ["a\n", "def"]); + assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); + + let (mut s, ids) = tail_thread("a\n", 60.0, 2); + s.execute("text.setFormat", &json!({"ids": [ids[0]], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!(ids.iter().map(|id| left_indents(&s, *id)).collect::>(), [vec![0.0, 30.0], vec![30.0], vec![30.0]]); + s.execute("text.setFormat", &json!({"ids": [ids[2]], "leftIndent": 10})).unwrap(); + assert_eq!(ids.iter().map(|id| left_indents(&s, *id)).collect::>(), [vec![0.0, 10.0], vec![10.0], vec![10.0]]); +} + +/// The restyled last paragraph keeps its style with type on a path on either side of the boundary. +#[test] +fn a_restyled_empty_last_paragraph_flows_onto_type_on_a_path() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "a\n", "area": {"width": 150, "height": 60}})).unwrap()); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + assert_eq!([text(&s, a), text(&s, on_path)], ["a\n", ""]); + s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!((left_indents(&s, a), left_indents(&s, on_path)), (vec![0.0, 30.0], vec![30.0])); + + // Type on a path before area type. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap(); + let on_path = type_on_a_line(&mut s); + s.execute("text.editRange", &json!({"id": on_path, "start": 0, "end": 0, "insert": "a\n"})).unwrap(); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()); + s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap(); + assert_eq!([text(&s, on_path), text(&s, b)], ["a\n", ""]); + s.execute("text.setFormat", &json!({"ids": [on_path], "start": 2, "end": 2, "leftIndent": 30})).unwrap(); + assert_eq!((left_indents(&s, on_path), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0])); +} + +/// A restyled empty last paragraph in a numbered list is counted once, and the next frame's +/// first paragraph does not continue the paragraph before the break. +#[test] +fn a_restyled_empty_last_list_paragraph_keeps_the_counts() { + let (mut s, ids) = tail_thread("One\nTwo\n", 60.0, 1); + let (a, b) = (ids[0], ids[1]); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "start": 8, "end": 8, "leftIndent": 30})).unwrap(); + let doc = &s.doc().unwrap().doc; + let frame = |id| match &doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + }; + let (ta, tb) = (frame(a), frame(b)); + let shown = [ta.plain_text(), tb.plain_text()].concat(); + assert_eq!(shown, "One\nTwo\n"); + let split = ta.paragraph_count() - 1; + assert_eq!(ta.para_at(split).left_indent, 30.0, "{:?}", [ta.plain_text(), tb.plain_text()]); + assert_eq!(tb.para_at(0).left_indent, 30.0); + assert!(tb.para_at(0).list.is_some()); + assert_eq!(tb.list_count as usize, split, "the paragraphs before the shared one are counted once"); + assert_eq!(tb.first_para_continues, !ta.plain_text().ends_with('\n')); +} + #[test] fn thread_flows_reflows_on_edit_and_survives_delete() { let (mut s, a, b) = setup(); diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 560da3942c..6e26e9a9e6 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -1,5 +1,6 @@ //! View → New View… / Edit Views…: saved views stored in the document; per-document view -//! toggles (Show Transparency Grid); the Character panel's Snap to Glyph options. +//! toggles (Show Transparency Grid); the Character panel's Snap to Glyph options; the Pixel +//! Snapping Options. use serde_json::{Value, json}; use vectorcraft_apischema::{Bound, Param, ParamSchema, Type, Writes}; @@ -72,6 +73,18 @@ static GLYPH_SNAP: ParamSchema = ParamSchema::new( .returns("the options") .writes(Writes::Preferences); +static PIXEL_SNAP: ParamSchema = ParamSchema::new( + &[ + Param::new("drawing", Type::Boolean).optional(), + Param::new("moving", Type::Boolean).optional(), + Param::new("scaling", Type::Boolean).optional(), + ], + "the Pixel Snapping Options, kept in the preferences (the pixelSnap group, all on by default): which edits View › Snap to Pixel lands on whole pixels: drawing (the points the drawing tools place, the rulers' origin and measured points), moving (the selection, its anchors and segments, artboards and ruler guides moved, and the Rotate, Reflect and Shear tools' drags) and scaling (the edges a bounding-box, Free Transform, artboard or slice handle drags, and the Scale tool's drag); an edit whose option is off snaps as it does with Snap to Pixel off; {} reads them", +) +.returns("the options") +.writes(Writes::Preferences) +.since(4); + pub fn specs() -> Vec { vec![ cmd!( @@ -125,6 +138,16 @@ pub fn specs() -> Vec { glyph_snap_options, schema: &GLYPH_SNAP ), + cmd!( + query "view.snapToPixel.options", + "Pixel Snapping Options", + [], + None, + "{drawing?: bool, moving?: bool, scaling?: bool} the Pixel Snapping Options, kept in the preferences (the pixelSnap group, all on by default): which edits View › Snap to Pixel lands on whole pixels: drawing (the points the drawing tools place, the rulers' origin and measured points), moving (the selection, its anchors and segments, artboards and ruler guides moved, and the Rotate, Reflect and Shear tools' drags) and scaling (the edges a bounding-box, Free Transform, artboard or slice handle drags, and the Scale tool's drag); an edit whose option is off snaps as it does with Snap to Pixel off; {} reads them → the options", + always, + pixel_snap_options, + schema: &PIXEL_SNAP + ), ] } @@ -133,6 +156,11 @@ fn glyph_snap_options(s: &mut Session, p: &Value) -> Result { Ok(json!(s.prefs.glyph_snap)) } +fn pixel_snap_options(s: &mut Session, p: &Value) -> Result { + s.prefs.pixel_snap = s.prefs.pixel_snap.merged(p).map_err(|e| bad("view.snapToPixel.options", e))?; + Ok(json!(s.prefs.pixel_snap)) +} + fn caps_lock(s: &mut Session, p: &Value) -> Result { if let Some(on) = p.get("on") { s.caps_lock = on.as_bool().ok_or_else(|| bad("view.capsLock", "on must be true or false"))?; @@ -379,15 +407,17 @@ mod tests { ("view.saved.list", json!({"all": true}), "`all`"), ("view.transparencyGrid", json!({"on": 1}), "`on`"), ("view.snapToGlyph.options", json!({"bounds": "off"}), "`bounds`"), + ("view.snapToPixel.options", json!({"moving": "off"}), "`moving`"), + ("view.snapToPixel.options", json!({"drawing": false, "rotating": false}), "`rotating`"), ] { let st = s.doc().unwrap(); let doc = (st.doc.views.clone(), st.history.undo.len(), st.is_dirty()); - let (grid, caps, snap) = (st.transparency_grid, s.caps_lock, s.prefs.glyph_snap); + let (grid, caps, snap, pixel) = (st.transparency_grid, s.caps_lock, s.prefs.glyph_snap, s.prefs.pixel_snap); let e = s.execute(id, ¶ms).unwrap_err().to_string(); assert!(e.contains(id) && e.contains(named), "{id} {params}: {e}"); let st = s.doc().unwrap(); assert_eq!((&st.doc.views, st.history.undo.len(), st.is_dirty()), (&doc.0, doc.1, doc.2), "{id} {params}"); - assert_eq!((st.transparency_grid, s.caps_lock, s.prefs.glyph_snap), (grid, caps, snap), "{id} {params}"); + assert_eq!((st.transparency_grid, s.caps_lock, s.prefs.glyph_snap, s.prefs.pixel_snap), (grid, caps, snap, pixel), "{id} {params}"); } } diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index a17fbaf3f8..ee594bae25 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -19,7 +19,9 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 8] = [ /// The commands that resize the artboards the app passes (Object › Artboards): the app passes the /// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to /// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to -/// each of their steps that names none. +/// each of their steps that names none; a Fit to Artwork Bounds step that names neither `index` nor +/// `indices`, while the Artboard tool is chosen with several artboards selected, gets those as its +/// `indices` instead. pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"]; /// `(command, param)` pairs the journal records only so a replay reproduces the original run @@ -162,6 +164,11 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ "object.ungroup", ]; +/// Commands whose `ids` (or `id`) may name ruler guide nodes as well as objects: the Layers panel's +/// eye and lock on a guide's row, and a drag down either column that passes guide rows. Every other +/// command given a ruler guide's id refuses it (the guide commands take guides by index). +pub(crate) const TAKES_GUIDE_IDS: &[&str] = &["layer.setProps"]; + /// The non-empty `ids` given to a command whose params description names `ids` as a key (the /// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of /// one when no `ids` are given and the description takes `id` so diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3c3460ecb2..e3f2b91ee7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -286,6 +286,9 @@ pub struct Session { /// While the active tool's actions run: their commands aren't "a command from outside the /// tool" ([`Session::after_command`]). pub(crate) in_tool_actions: bool, + /// The warnings of the previews that settling an interaction applied, while a top-level command + /// or the tool's actions collect them ([`Session::collect_settled_warnings`]). + settled_warnings: Option>, /// While [`Session::preview`] runs its command: its edits are the interaction's own. previewing: bool, /// The images Remove Background decoded during the interaction's preview steps, emptied when diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 05ef5e84f5..98813e999f 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -260,6 +260,9 @@ pub struct Prefs { /// Character panel › Show Snap to Glyph Options: which guides View › Snap to Glyph gives /// (`view.snapToGlyph.options`; a preference group, see [`cmd::prefscmds::PREF_GROUPS`]). pub glyph_snap: vectorcraft_tools::GlyphSnap, + /// Pixel Snapping Options: whether View › Snap to Pixel snaps drawing, moving and scaling + /// (`view.snapToPixel.options`; a preference group, see [`cmd::prefscmds::PREF_GROUPS`]). + pub pixel_snap: vectorcraft_tools::PixelSnap, /// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored). /// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*` /// edit it. @@ -523,6 +526,7 @@ impl Default for Prefs { eyedropper: Default::default(), pathfinder: Default::default(), glyph_snap: Default::default(), + pixel_snap: Default::default(), flattener_presets: vec![], trace_presets: vec![], gradient_presets: vec![], diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index 08bc0f719d..8be87b5ab8 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -26,10 +26,11 @@ use crate::cmd::{CommandSpec, bad, command_specs}; use crate::{EngineError, Result}; /// The API level of the commands' parameters. -pub const API_VERSION: u32 = 3; +pub const API_VERSION: u32 = 4; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = + &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a), (4, 0x1be4_be72_fe16_54c4)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { @@ -311,12 +312,17 @@ mod tests { ("view.transparencyGrid", json!({"on": 1})), ("view.snapToGlyph.options", json!({"bounds": false, "angular": true})), ("view.snapToGlyph.options", json!({"bounds": null})), + ("view.snapToPixel.options", json!({"drawing": false, "scaling": true})), + ("view.snapToPixel.options", json!({"moving": null})), + ("view.snapToPixel.options", json!({"moving": "off"})), + ("view.snapToPixel.options", json!({"rotating": false})), ("view.capsLock", json!(null)), ("view.saved.new", json!(null)), ("view.saved.edit", json!(null)), ("view.saved.list", json!(null)), ("view.transparencyGrid", json!(null)), ("view.snapToGlyph.options", json!(null)), + ("view.snapToPixel.options", json!(null)), ("view.capsLock", json!([])), ("view.saved.new", json!(5)), ("view.transparencyGrid", json!("on")), @@ -325,7 +331,7 @@ mod tests { ("engine.apiReference", json!({"prefix": "view."})), ("engine.apiReference", json!([])), ]; - assert_eq!(schemas().len(), 7); + assert_eq!(schemas().len(), 8); for (id, schema) in schemas() { let written = schema.to_json(); check_schema(&written, id).unwrap_or_else(|e| panic!("{e}\n{written:#}")); @@ -423,6 +429,7 @@ mod tests { ("view.saved.list", "nothing"), ("view.transparencyGrid", "session"), ("view.snapToGlyph.options", "preferences"), + ("view.snapToPixel.options", "preferences"), ]; for (id, writes) in expected { let got = listed.iter().find(|(c, _)| *c == id).map(|(_, w)| w.clone()); diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index 4ec68dbbde..f1928228df 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -321,7 +321,8 @@ impl Files { std::env::var("JSINCLUDE").map(|v| v.split(';').map(str::trim).filter(|p| !p.is_empty()).map(PathBuf::from).collect()).unwrap_or_default() } - /// The file being run now, as `$.fileName` gives it (empty for a script given as text). + /// The file being run now: the script, or the file `$.evalFile` is running (empty for a script + /// given as text). `$.fileName` gives it to code that `eval` or `Function` compiled. pub(crate) fn file_name(&self) -> String { self.frames.last().map(|f| f.shown.to_string()).unwrap_or_default() } diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 53f6e18f75..1edeb84e93 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -672,6 +672,7 @@ impl Savepoint { s.pending_paint = self.pending_paint; self.choices.put(s); s.journal.truncate(self.journal); + s.sync_front_profiles(); exact } } @@ -822,6 +823,57 @@ impl Host { } } + /// The file and line that line `line` of the source with tag `tag` came from, as `$.line` and + /// `$.stack` give them: the run's script, a file it included or a file `$.evalFile` gave, in + /// this run or in an earlier run of its named engine, with the run's name standing for a script + /// given as text. Code that `eval` or `Function` compiled has no map and keeps its own line, + /// named `[eval]`; code whose map is gone, such as one the named engine let go to stay within + /// [`KEPT_MAP_BYTES`], is at line 0 of `[unknown]`. + fn source_place(&self, tag: u32, line: u32) -> (String, u32) { + if tag == vectorcraft_js::UNKNOWN_SOURCE { + return ("[eval]".to_string(), line); + } + let named = |map: &include::SourceMap, script: Option<&Arc>| { + let (index, file, line, _) = map.locate(line, 1); + match script { + Some(name) if index == 0 && file.is_empty() => (name.to_string(), line), + _ => (file.to_string(), line), + } + }; + let script = self.script_tag.get(); + let placed = match tag.checked_sub(script).filter(|_| script != vectorcraft_js::UNKNOWN_SOURCE) { + Some(0) => self.script_map.try_borrow().ok().map(|m| named(&m, Some(&self.report_name))), + Some(n) => self.eval_maps.try_borrow().ok().and_then(|maps| maps.get(n as usize - 1).map(|m| named(m, None))), + None => { + self.kept_maps.as_ref().and_then(|k| k.try_borrow().ok()).and_then(|k| k.get(tag).map(|kept| named(&kept.map, kept.script.as_ref()))) + } + }; + placed.unwrap_or_else(|| ("[unknown]".to_string(), 0)) + } + + /// `$.fileName` read by the code at `site`: the file that code is in, by the source map of the + /// source it came from (this run's script, a file `$.evalFile` gave, or code an earlier run of + /// the named engine compiled), so a function keeps the file it was defined in. Code from a file + /// `#include` inserted is in that file. A script given as text has no name, and neither has + /// code whose map the named engine no longer keeps. Code that `eval` or `Function` compiled has + /// no map, and is given the file being run now. + fn file_name_at(&self, site: vectorcraft_js::CallSite) -> String { + let name = |m: &include::SourceMap| m.locate(site.line, site.column).1.to_string(); + let tag = site.source; + if tag == vectorcraft_js::UNKNOWN_SOURCE { + return self.files.borrow().file_name(); + } + let script = self.script_tag.get(); + match tag.checked_sub(script).filter(|_| script != vectorcraft_js::UNKNOWN_SOURCE) { + Some(0) => name(&self.script_map.borrow()), + Some(n) => self.eval_maps.borrow().get(n as usize - 1).map(name).unwrap_or_default(), + None => { + let kept = self.kept_maps.as_ref().map(|k| k.borrow()); + kept.as_ref().and_then(|k| k.get(tag)).map(|k| name(&k.map)).unwrap_or_default() + } + } + } + /// Adds a line to the run's console and, during a slice, to the session's. fn line(&self, entry: console::Entry, cut: bool) { let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry }; @@ -1254,9 +1306,9 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) { /// The `$` object: `$.write(...values)` writes the values to the Script Console as `String(value)` /// gives them, one after another, and `$.writeln(...values)` does the same and ends the line. -/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file -/// being run and `$.includePath` the `#includepath` list. Its other members are -/// [`dollar::members`]. +/// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file the +/// code reading it is in ([`Host::file_name_at`]) and `$.includePath` the `#includepath` list. Its +/// other members are [`dollar::members`]. fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let writer = |end: bool| { let l = link.clone(); @@ -1278,6 +1330,15 @@ fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc { *host.script_map.borrow_mut() = expanded.map; realm.set_strict_writes(expanded.strict == Some(true)); + if fresh { + let l = link.clone(); + realm.set_source_locator(Rc::new(move |tag, line| match current(&l) { + Ok(h) => h.source_place(tag, line), + Err(_) => ("[unknown]".to_string(), 0), + })); + } (if fresh { realm.add_script_extensions() } else { Ok(()) }) .and_then(|()| if fresh { realm.define_host_object("vc", vc_object(&link)) } else { Ok(()) }) .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) @@ -2199,6 +2267,9 @@ fn failure_text(report: &Value) -> String { fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result, String> { let depth = std::mem::replace(&mut s.depth, 0); job.world.swap(s); + // The run's front document draws and converts through its own profiles, and the interface's + // again after the slice. + s.sync_front_profiles(); s.script_in_run = true; let cell = job.host.session.clone(); std::mem::swap(s, &mut *cell.borrow_mut()); @@ -2221,6 +2292,7 @@ fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() && !sets_in_any(t, &names)), - None => self.sync_opened_composite_fonts(index), + None => { + self.sync_opened_composite_fonts(index); + self.sync_front_profiles(); + } } true } @@ -403,6 +418,7 @@ impl Session { } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; self.sync_front_composite_fonts(); + self.sync_front_profiles(); true } diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 3d0d9ba1c6..73977f7d64 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -234,6 +234,27 @@ impl Session { self.active().and_then(|d| d.interaction.as_ref().filter(|it| std::sync::Arc::ptr_eq(&it.doc, &d.doc)).and_then(|it| it.preview.clone())) } + /// Keep `warning`, from a preview that settling an interaction applied, for whatever collects + /// such warnings now ([`Session::collect_settled_warnings`]). Nothing keeps it when nothing + /// collects, so it is never reported with a later, unrelated command. + pub(crate) fn note_settled_warning(&mut self, warning: &str) { + if let Some(kept) = &mut self.settled_warnings + && !kept.iter().any(|w| w == warning) + { + kept.push(warning.to_string()); + } + } + + /// Run `f`, and → its result with the warnings of the previews that settling an interaction + /// applied while it ran ([`Session::note_settled_warning`]). Warnings an outer `f` collects are + /// kept for it. + pub(crate) fn collect_settled_warnings(&mut self, f: impl FnOnce(&mut Self) -> R) -> (R, Vec) { + let outer = self.settled_warnings.replace(Vec::new()); + let r = f(self); + let settled = std::mem::replace(&mut self.settled_warnings, outer).unwrap_or_default(); + (r, settled) + } + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { if self.script_running() { return Err(EngineError::Busy); @@ -311,6 +332,8 @@ impl Session { self.paint = paint.clone(); } self.pending_paint = None; + // The previous preview's Assign Profile is undone with it. + self.sync_front_profiles(); let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params).or_else(|| cmd::bounds_rewrite(self, cmd, params)); let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); // A selected object's converted appearance must not replace the current paints while @@ -436,6 +459,8 @@ impl Session { st.undo_interaction(); // Guides locked and slices locked or hidden during the interaction stay deselected. deselect_hidden(&menu, &st.doc, &mut st.selection); + // A canceled preview's Assign Profile is undone with it. + self.sync_front_profiles(); // With New Art Has Basic Appearance off, new art takes the appearance of the selection as // the document has it again, not as a preview left it. self.inherit_new_art(); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 23079fe901..3bc5c72378 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,8 +1,29 @@ use super::{ - CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, - strip_replay_only, waits_for_tool_edit, + CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, TAKES_GUIDE_IDS, Value, cmd, find_command, + given_ids, guard, strip_replay_only, waits_for_tool_edit, }; +/// A command's result `r` with the warnings of the previews that settling an interaction applied +/// before it ran: `previewWarning` holds them, and `warning` too when the command gives none of its +/// own, so the status bar shows the command's own warning when there is one. A null result becomes +/// an object holding them; any other result that isn't an object is returned as it is. +fn with_settled_warnings(mut r: Value, settled: &[String]) -> Value { + if settled.is_empty() { + return r; + } + if r.is_null() { + r = Value::Object(serde_json::Map::new()); + } + if let Value::Object(m) = &mut r { + let w = Value::String(settled.join("; ")); + if m.get("warning").is_none_or(Value::is_null) { + m.insert("warning".into(), w.clone()); + } + m.insert("previewWarning".into(), w); + } + r +} + impl Session { /// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back. fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec) -> std::result::Result<(), String> { @@ -54,7 +75,14 @@ impl Session { self.finish_tool_edit_later()?; } let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id)); - let r = self.execute_spec(spec, id, params); + // A preview that a top-level command applied first (a drag with Real-time Drawing off, kept + // before Undo) reports its warning in the command's result. + let r = if self.depth == 0 { + let (r, settled) = self.collect_settled_warnings(|s| s.execute_spec(spec, id, params)); + r.map(|v| with_settled_warnings(v, &settled)) + } else { + self.execute_spec(spec, id, params) + }; self.reading = outer; r } @@ -87,7 +115,7 @@ impl Session { if id == "effect.refreshDeferred" { cmd::given_object_ids(params, id)?; } else { - cmd::given_objects(self, params, id)?; + cmd::given_nodes(self, params, id, TAKES_GUIDE_IDS.contains(&id))?; } } if SELECTS_GIVEN_IDS.contains(&id) { @@ -106,7 +134,8 @@ impl Session { // whichever tool is chosen. let given = given_ids(spec.params, params); // The guide commands take ruler guides by index; an object command given one's id is refused. - if let (Some(ids), Some(st)) = (&given, self.active()) + if !TAKES_GUIDE_IDS.contains(&id) + && let (Some(ids), Some(st)) = (&given, self.active()) && let Some(guide) = ids.iter().find(|i| st.doc.node(**i).is_some_and(|n| n.is_ruler_guide())) { let value = guide.0; @@ -168,6 +197,10 @@ impl Session { let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); let open = (self.depth == 0).then(|| self.docs.iter().map(|d| d.uid).collect::>()); let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; + // Colors are drawn and converted through the profiles of the document in front now, also + // after an undone or redone Assign Profile, a failed or panicked command's documents put + // back, and a batch step, before the next step converts through them. + self.sync_front_profiles(); if r.is_err() && self.depth == 0 { self.batch_paint_docs.clear(); // A failed or panicked command's documents are put back: type is drawn in their @@ -364,3 +397,25 @@ impl Session { r } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::with_settled_warnings; + + /// The command's own warning stays the one the status bar shows, and the settled preview's is + /// kept beside it; a null result holds the preview's; other results are left as they are. + #[test] + fn settled_warnings_join_the_result() { + let settled = ["a".to_string(), "b".to_string()]; + assert_eq!( + with_settled_warnings(json!({"ran": 1, "warning": "own"}), &settled), + json!({"ran": 1, "warning": "own", "previewWarning": "a; b"}) + ); + assert_eq!(with_settled_warnings(json!({"warning": null}), &settled), json!({"warning": "a; b", "previewWarning": "a; b"})); + assert_eq!(with_settled_warnings(json!(null), &settled[..1]), json!({"warning": "a", "previewWarning": "a"})); + assert_eq!(with_settled_warnings(json!([1]), &settled), json!([1])); + assert_eq!(with_settled_warnings(json!({"ran": 1}), &[]), json!({"ran": 1})); + } +} diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index d06934926c..3af4cafe5f 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -164,6 +164,10 @@ impl Session { if self.depth == 0 { self.trim_histories(); } + // A canceled group's Assign Profile is undone with it. + if cancel { + self.sync_front_profiles(); + } } /// When journal entry `n` is a `command.batch` with `steps` noted in canceled undo groups and @@ -280,5 +284,7 @@ impl Session { g.journal.retain(|&(n, _)| n < scope.journal); g.steps.retain(|&(n, _)| n < scope.journal); } + // A canceled scope's Assign Profile is undone with it. + self.sync_front_profiles(); } } diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 4943353ee0..8a18ce9824 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -421,6 +421,8 @@ impl Session { if safe_mode::active() { return; } + // A `plugin.install` in another session writes the store before this reads it. + let _held = crate::cmd::plugin::hold_plugin_changes(); let grants = self.counted_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2352776651..6c231dc7e5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4033,6 +4033,38 @@ fn snap_to_pixel_rounds_drawing_and_moves() { assert_eq!((b.x0, b.y0), (17.0, 16.0)); } +/// Pixel Snapping Options (`view.snapToPixel.options`): with While Drawing off a rectangle drawn +/// with Snap to Pixel on keeps its fractional corners while a move still lands on whole pixels, and +/// with While Moving off a move keeps its fractional offset while drawing snaps again. +#[test] +fn pixel_snapping_options_choose_the_edits_snap_to_pixel_rounds() { + let mut s = session(); + let v = ViewInfo { snap_to_pixel: true, smart_guides: false, snap_to_point: false, ..Default::default() }; + let drag = |s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)| { + s.select_tool(tool, v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap(); + }; + let bounds = |s: &Session, id| { + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0, b.x1, b.y1) + }; + s.execute("view.snapToPixel.options", &json!({"drawing": false})).unwrap(); + drag(&mut s, "rectangle", (10.25, 10.5), (60.5, 40.25)); + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(bounds(&s, id), (10.25, 10.5, 60.5, 40.25), "drawing does not snap"); + drag(&mut s, "selection", (30.0, 30.0), (37.5, 34.75)); + assert_eq!(bounds(&s, id).0, 18.0, "moving still snaps the left edge"); + assert_eq!(bounds(&s, id).1, 15.0, "moving still snaps the top edge"); + s.execute("view.snapToPixel.options", &json!({"drawing": true, "moving": false})).unwrap(); + drag(&mut s, "selection", (40.0, 30.0), (45.25, 33.5)); + assert_eq!((bounds(&s, id).0, bounds(&s, id).1), (23.25, 18.5), "moving does not snap"); + drag(&mut s, "rectangle", (100.25, 100.5), (140.5, 130.75)); + let drawn = s.doc().unwrap().selection.objects[0]; + assert_eq!(bounds(&s, drawn), (100.0, 101.0, 141.0, 131.0), "drawing snaps again"); +} + #[test] fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() { let mut s = session(); diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 93f531d1e8..05fbf1a72e 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -542,6 +542,47 @@ fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() { assert_eq!(spec(&s, g).1.color_shift, 0.0); } +/// A blend made without a spine has the Appearance widget alone: a drag of it with the Selection +/// tool or the Blend tool changes the color shift in one undo step, and there is no Easing widget +/// to drag. +#[test] +fn dragging_the_appearance_widget_of_a_blend_without_a_spine_is_one_undo_step() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "createSpine": "never", "orientation": "path"})).unwrap()); + assert!(spec(&s, g).1.no_spine); + let v = ViewInfo::default(); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); + }; + for (n, tool) in [(1.0, "selection"), (2.0, "blend")] { + s.select_tool(tool, v).unwrap(); + sel(&mut s, &[g]); + let d0 = depth(&s); + // The Appearance widget sits 16 px above the halfway point (120, 20) of the line between + // the key centers. + ptr(&mut s, PointerKind::Down, 120.0, 4.0); + ptr(&mut s, PointerKind::Drag, 135.0, 4.0); + ptr(&mut s, PointerKind::Up, 135.0, 4.0); + let sp = spec(&s, g).1; + assert_eq!((sp.color_shift, sp.ease.ramp, depth(&s)), (15.0 * n, 0.0, d0 + 1), "{tool}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g], "{tool}: the blend stays selected"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.color_shift, 15.0, "each drag is its own undo step"); + // 16 px below there is no widget: a drag there leaves the easing and the color shift alone. + s.select_tool("selection", v).unwrap(); + sel(&mut s, &[g]); + let before = spec(&s, g).1; + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 150.0, 36.0); + ptr(&mut s, PointerKind::Up, 150.0, 36.0); + let after = spec(&s, g).1; + assert_eq!((after.ease, after.color_shift), (before.ease, before.color_shift), "no Easing widget"); +} + #[test] fn native_blend_easing_values_clamp_and_non_finite_become_default() { for (bad, ramp, shift) in [ diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 49a806b258..21cbf678eb 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3788,6 +3788,103 @@ fn a_trace_run_needs_the_library_colours_it_traced_with() { assert!(!run.current(&s), "{name} changed"); } +/// `image_doc`'s image with its disc 20 px to the left: the same size, other pixels. +fn moved_disc_png() -> Vec { + vectorcraft_trace::Raster::from_fn(100, 100, |x, y| { + let (dx, dy) = (x as f64 + 0.5 - 30.0, y as f64 + 0.5 - 50.0); + if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] } + }) + .encode_png() +} + +/// Puts `bytes` under the image key `key`, or takes the key's image data away (`None`). +fn set_image_data(s: &mut Session, key: &str, bytes: Option>) { + s.edit("image data", |d, _| { + match bytes { + Some(b) => d.images.insert(key.into(), ImageBlob::new("image/png", b)), + None => d.images.remove(key), + }; + Ok(()) + }) + .unwrap(); +} + +/// The middle of the first traced path of the Image Trace object the reply `r` names, in x. +fn traced_center_x(s: &Session, r: &Value) -> f64 { + outline(&traced_paths(&node(s, NodeId(r["id"].as_u64().unwrap())))[0]).0.bounds().unwrap().center().x +} + +/// A trace made away from its command no longer applies once the pixels under its image's key +/// change, though the object, its image record and the settings stay as they were; both commands +/// then trace the pixels there now. The same bytes in another allocation still apply. +#[test] +fn a_trace_run_needs_the_pixels_it_traced() { + let p = json!({"params": {"ignoreWhite": true}}); + for command in ["imageTrace.make", "imageTrace.makeAndExpand"] { + let mut s = session(); + image_doc(&mut s); + let run = cmd::TraceRun::prepare(&s, command, &p).unwrap(); + let traced = run.run(&|_, _| true).unwrap(); + // The same bytes, allocated anew: still current. + let same = s.doc().unwrap().doc.images["img1"].bytes.to_vec(); + set_image_data(&mut s, "img1", Some(same)); + assert!(run.current(&s), "{command}: equal bytes"); + // Other pixels under the same key: no longer current, and the hand-off is dropped. + set_image_data(&mut s, "img1", Some(moved_disc_png())); + assert!(!run.current(&s), "{command}: other pixels"); + traced.hand_in(&mut s); + let r = s.execute(command, run.params()).unwrap(); + assert!(s.trace_hand_off.is_none(), "{command}: dropped"); + // The disc is now at image x 30, which the ×2 placement at 50 puts at 110. + let x = traced_center_x(&s, &r); + assert!((x - 110.0).abs() < 1.5, "{command}: traced the pixels there now, at {x}"); + } +} + +/// Image data that is missing or empty: a run made before it went no longer applies, the run's +/// command gives the error it gives without a run, and nothing panics. +#[test] +fn a_trace_run_of_missing_or_empty_image_data() { + let p = json!({"params": {"ignoreWhite": true}}); + let mut s = session(); + image_doc(&mut s); + let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap(); + let traced = run.run(&|_, _| true).unwrap(); + set_image_data(&mut s, "img1", None); + assert!(!run.current(&s), "missing"); + traced.hand_in(&mut s); + let e = s.execute(run.command(), run.params()).unwrap_err().to_string(); + assert!(e.contains("image data `img1` is missing"), "{e}"); + set_image_data(&mut s, "img1", Some(vec![])); + assert!(!run.current(&s), "empty"); + let empty = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap(); + assert!(empty.current(&s), "two empty blobs are equal"); + assert!(matches!(empty.run(&|_, _| true), Err(EngineError::Other(_)))); + assert!(matches!(s.execute(run.command(), run.params()), Err(EngineError::Other(_)))); +} + +/// Tracing an Image Trace object again follows the same rule: once the pixels of the image it +/// holds change, a run made before no longer applies, and the command traces the pixels there now. +#[test] +fn a_trace_run_of_an_image_trace_object_needs_its_pixels() { + let p = json!({"params": {"ignoreWhite": true}}); + let mut s = session(); + image_doc(&mut s); + let g = s.execute("imageTrace.make", &p).unwrap()["id"].as_u64().unwrap(); + let again = json!({"id": g, "params": {"ignoreWhite": true}}); + let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &again).unwrap(); + assert_eq!(run.target(), NodeId(g)); + let traced = run.run(&|_, _| true).unwrap(); + assert!(run.current(&s)); + set_image_data(&mut s, "img1", Some(moved_disc_png())); + assert!(!run.current(&s)); + traced.hand_in(&mut s); + let r = s.execute(run.command(), run.params()).unwrap(); + assert!(s.trace_hand_off.is_none(), "dropped"); + let x = traced_center_x(&s, &r); + assert!((x - 110.0).abs() < 1.5, "traced the pixels there now, at {x}"); +} + /// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same /// circle as its traced path; without the option it is a plain path. #[test] diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index c801401de0..5c806884d8 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -326,6 +326,31 @@ fn update_link_takes_the_files_clipping_path_into_symbols_too() { assert_eq!(symbol_clip_width(&s, "Pic"), None); } +#[test] +fn update_and_relink_by_id_keep_the_clipping_path_of_a_dcs_image_in_a_symbol() { + let dir = temp_dir("symbol-clip-by-id"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image is listed"); + let id = row["id"].as_u64().unwrap(); + assert_eq!(row["format"], "DCS", "{list}"); + let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path"); + // The composite now drawn through the left quarter of the page (same length). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + assert_eq!(s.execute("links.update", &json!({ "ids": [id] })).unwrap()["updated"], json!([id])); + let after = symbol_clip_width(&s, "Pic").expect("still clipped"); + assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}"); + // Relinked by its id to a plain image: the DCS file's clipping path goes. + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + s.execute("links.relink", &json!({ "ids": [id], "path": png.to_string_lossy() })).unwrap(); + assert_eq!(symbol_clip_width(&s, "Pic"), None); +} + #[test] fn a_file_that_lost_its_clipping_path_unclips_the_image() { let dir = temp_dir("update-lost-clip"); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index fbf1b22d4a..f29df7ff23 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1367,6 +1367,37 @@ fn smooth_tool_reduces_jagged_path() { assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0)); } +/// A drag of the Smooth tool along a line jagged by up to 6 pt leaves no corner between a curve and +/// a line: every anchor between the path's ends turns by at most 1°. +#[test] +fn smooth_tool_leaves_no_corner_where_a_curve_meets_a_line() { + let mut s = session(); + let mut seed = 7u64; + let anchors: Vec<_> = (0..=60) + .map(|i| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let noise = ((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0; + json!({"x": 100.0 + f64::from(i) * 5.0, "y": 200.0 + 6.0 * noise}) + }) + .collect(); + let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let drag_points: Vec<(f64, f64)> = (0..=64).map(|i| (90.0 + f64::from(i) * 5.0, 200.0)).collect(); + drag(&mut s, "smooth", &drag_points, Mods::default()); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.anchors.len() < 61, "the path is smoothed: {}", sp.anchors.len()); + let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord); + for i in 1..sp.segment_count() { + let (prev, next) = (sp.segment(i - 1), sp.segment(i)); + let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0); + let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0); + let turn = arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees(); + let kinds = (sp.segment_is_line(i - 1), sp.segment_is_line(i)); + assert!(turn <= 1.0, "anchor {i} turns {turn}° (lines before and after: {kinds:?}): {:?}", sp.anchors[i]); + } +} + /// Smoothing a path with direct-selected anchors leaves the path selected whole: the selection no /// longer names anchors by indices into the anchors the smooth replaced. A selected path the drag /// does not reach keeps its anchors selected, and undo brings the old selection back. @@ -1412,6 +1443,169 @@ fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() { } } +/// A selected path through `anchors` (x, y, and the in and out handles when given). +fn smooth_test_path(s: &mut Session, anchors: &[serde_json::Value], closed: bool) -> NodeId { + let r = s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + id +} + +/// `path.smoothRegion` along `drag` with the Smooth tool's reach (12 pt at 100%) and default Fidelity. +fn smooth_pass(s: &mut Session, drag: &[Point]) { + let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect(); + s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 2.5})).unwrap(); +} + +/// Points 1/32 of a segment apart along every segment of `p`. +fn smooth_test_samples(p: &PathData) -> Vec { + use kurbo::ParamCurve; + let mut v = vec![]; + for sp in &p.subpaths { + for i in 0..sp.segment_count() { + let c = sp.segment(i); + v.extend((0..32).map(|k| c.eval(f64::from(k) / 32.0))); + } + } + v +} + +/// How much `p` turns along its length, in radians: a wobbly path turns much more than a smooth one. +fn total_turning(p: &PathData) -> f64 { + let pts = smooth_test_samples(p); + pts.windows(3) + .map(|w| (w[1] - w[0], w[2] - w[1])) + .filter(|(u, v)| u.hypot() > 1e-9 && v.hypot() > 1e-9) + .map(|(u, v)| u.cross(v).atan2(u.dot(v)).abs()) + .sum() +} + +/// Deterministic noise in -1..1. +fn smooth_noise(seed: &mut u64) -> f64 { + *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (*seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 +} + +/// A drag along y = `y` from x = `x0` to `x1`, one point every 5 pt. +fn smooth_drag_along(y: f64, x0: f64, x1: f64) -> Vec { + let n = ((x1 - x0) / 5.0).ceil() as usize; + (0..=n).map(|i| Point::new(x0 + (x1 - x0) * i as f64 / n as f64, y)).collect() +} + +/// Each drag of the Smooth tool smooths the path under it, and a second drag over the same place +/// smooths it further, as the help page describes +/// (https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html): a curve that wobbles +/// 4 pt either side of its line every 12 pt, with an anchor at each crest, wobbles less after each drag. +#[test] +fn each_smooth_drag_smooths_a_wobble_larger_than_fidelity_further() { + let mut s = session(); + let anchors: Vec<_> = (0..=20) + .map(|k| { + let (x, y) = (100.0 + 12.0 * f64::from(k), if k % 2 == 0 { 196.0 } else { 204.0 }); + json!({"x": x, "y": y, "in": [x - 4.4, y], "out": [x + 4.4, y]}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, false); + let drag = smooth_drag_along(200.0, 90.0, 350.0); + let wobble = + |p: &PathData| smooth_test_samples(p).iter().filter(|q| (130.0..=310.0).contains(&q.x)).map(|q| (q.y - 200.0).abs()).fold(0.0, f64::max); + let mut before = path(&s, id); + for pass in 1..=2 { + smooth_pass(&mut s, &drag); + let after = path(&s, id); + assert_ne!(after, before, "pass {pass} changes the path"); + let (t0, t1) = (total_turning(&before), total_turning(&after)); + assert!(t1 < 0.9 * t0, "pass {pass}: turning {t0} -> {t1}"); + let (w0, w1) = (wobble(&before), wobble(&after)); + assert!(w1 < 0.8 * w0, "pass {pass}: wobble {w0} -> {w1}"); + let (a, b) = (before.subpaths[0].anchors[0].p, after.subpaths[0].anchors[0].p); + assert_eq!(a, b, "pass {pass}: the path keeps its start"); + before = after; + } +} + +/// Smoothing a line with up to 1 pt of noise again and again, over all of it or over its middle, +/// keeps it close to the line under the noise: each refit stays near the path it smooths. +#[test] +fn repeated_smooth_drags_stay_close_to_the_line_under_the_noise() { + let off = |p: &PathData| { + let d: Vec = smooth_test_samples(p).iter().map(|q| (q.y - 200.0).abs()).collect(); + (d.iter().sum::() / d.len() as f64, d.iter().copied().fold(0.0, f64::max)) + }; + for (x0, x1) in [(90.0, 410.0), (150.0, 350.0)] { + let mut s = session(); + let mut seed = 7u64; + let anchors: Vec<_> = (0..=60).map(|i| json!({"x": 100.0 + 5.0 * f64::from(i), "y": 200.0 + smooth_noise(&mut seed)})).collect(); + let id = smooth_test_path(&mut s, &anchors, false); + let drag = smooth_drag_along(200.0, x0, x1); + let (mean0, _) = off(&path(&s, id)); + for pass in 1..=5 { + smooth_pass(&mut s, &drag); + let (mean, max) = off(&path(&s, id)); + assert!( + mean <= 1.1 * mean0 && max <= 1.5, + "drag {x0}-{x1}, pass {pass}: {mean} on average and {max} at most from the line, {mean0} before" + ); + } + } +} + +/// A drag over the whole of a noisy closed path leaves no corner where the path starts. +#[test] +fn smoothing_a_whole_closed_path_leaves_no_kink_at_its_start() { + let mut s = session(); + let mut seed = 5u64; + let (c, r) = (Point::new(300.0, 300.0), 80.0); + let anchors: Vec<_> = (0..64) + .map(|i| { + let a = f64::from(i) / 64.0 * std::f64::consts::TAU; + let rr = r + 2.5 * smooth_noise(&mut seed); + json!({"x": c.x + rr * a.cos(), "y": c.y + rr * a.sin()}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, true); + let ring: Vec = (0..=128).map(|i| f64::from(i) / 128.0 * std::f64::consts::TAU).map(|a| c + r * kurbo::Vec2::from_angle(a)).collect(); + smooth_pass(&mut s, &ring); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < 64, "{} anchors", sp.anchors.len()); + let (last, first) = (sp.segment(sp.segment_count() - 1), sp.segment(0)); + let tin = if last.p3.distance(last.p2) > 1e-9 { last.p3 - last.p2 } else { last.p3 - last.p1 }; + let tout = if first.p1.distance(first.p0) > 1e-9 { first.p1 - first.p0 } else { first.p2 - first.p0 }; + let kink = tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees(); + assert!(kink <= 1.0, "a {kink}° corner at the start"); +} + +/// A drag over the whole of a closed path smooths a bump at its first anchor as much as the same +/// bump on the far side of it. +#[test] +fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() { + let mut s = session(); + let (c, r) = (Point::new(300.0, 300.0), 80.0); + let anchors: Vec<_> = (0..64) + .map(|i| { + let a = f64::from(i) / 64.0 * std::f64::consts::TAU; + let rr = if i % 32 == 0 { r + 6.0 } else { r }; + json!({"x": c.x + rr * a.cos(), "y": c.y + rr * a.sin()}) + }) + .collect(); + let id = smooth_test_path(&mut s, &anchors, true); + let ring: Vec = (0..=128).map(|i| f64::from(i) / 128.0 * std::f64::consts::TAU).map(|a| c + r * kurbo::Vec2::from_angle(a)).collect(); + // A Fidelity of 0.5 pt, so the refit follows the smoothed path closely on both sides. + let pts: Vec<_> = ring.iter().map(|p| json!([p.x, p.y])).collect(); + s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 0.5})).unwrap(); + let p = path(&s, id); + use std::f64::consts::{PI, TAU}; + // How far the path rises above the circle within 0.3 radians of the angle `near`. + let bump = |near: f64| { + let off = |q: &Point| (((q.y - c.y).atan2(q.x - c.x) - near + PI).rem_euclid(TAU) - PI).abs(); + smooth_test_samples(&p).iter().filter(|q| off(q) < 0.3).map(|q| q.distance(c) - r).fold(0.0, f64::max) + }; + let (start, far) = (bump(0.0), bump(PI)); + assert!(far < 5.0, "the far bump stays {far} pt high"); + assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt"); +} + #[test] fn join_tool_joins_two_lines() { let mut s = session(); @@ -2555,6 +2749,49 @@ fn remove_anchor_points_selects_one_next_anchor_per_subpath_and_per_path() { assert_eq!(selected_anchors(&s, other), [(0, 0)]); } +#[test] +fn remove_anchor_points_on_many_paths_and_subpaths_matches_removing_one_at_a_time() { + // Many anchors over many subpaths and paths, curved and straight, open and closed: each + // subpath ends as removing its anchors one at a time, the last first, leaves it. Paths left + // with nothing go, the rest stay selected in order, and one undo step brings all back. + let mut s = session(); + let curvy = "M0 0 C10 20 30 20 40 0 C50 -20 70 -20 80 0 C90 20 110 20 120 0 C130 -20 150 -20 160 0 Z \ + M0 100 L40 120 C60 140 80 80 100 100 L140 90 L180 110 L220 100"; + let ids: Vec = (0..4).map(|i| path_from(&mut s, if i == 2 { "M0 0 L10 0 L20 5" } else { curvy })).collect(); + let picks: [&[(usize, usize)]; 4] = + [&[(0, 0), (0, 2), (0, 3), (1, 1), (1, 2), (1, 5)], &[(0, 1), (1, 0), (1, 6)], &[(0, 0), (0, 1)], &[(0, 3), (1, 3), (1, 4)]]; + for (k, (&id, &anchors)) in ids.iter().zip(&picks).enumerate() { + let mode = if k == 0 { "set" } else { "add" }; + s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors, "mode": mode})).unwrap(); + } + let before: Vec = ids.iter().map(|&id| path(&s, id)).collect(); + assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 1); + for (k, &id) in ids.iter().enumerate() { + let mut want = before[k].clone(); + let mut order = picks[k].to_vec(); + order.sort_unstable_by(|a, b| b.cmp(a)); + for (si, ai) in order { + vectorcraft_pathops::remove_anchor(&mut want.subpaths[si], ai); + } + want.subpaths.retain(|sp| sp.anchors.len() >= 2); + if want.subpaths.is_empty() { + assert!(s.doc().unwrap().doc.node(id).is_none(), "path {k} goes"); + } else { + assert_eq!(path(&s, id).subpaths, want.subpaths, "path {k}"); + } + } + let kept = [ids[0], ids[1], ids[3]]; + assert_eq!(s.doc().unwrap().selection.objects, kept); + s.execute("edit.undo", &json!({})).unwrap(); + for (k, &id) in ids.iter().enumerate() { + assert_eq!(path(&s, id), before[k]); + assert_eq!(selected_anchors(&s, id), picks[k]); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(ids[2]).is_none()); + assert_eq!(s.doc().unwrap().selection.objects, kept); +} + #[test] fn remove_anchor_point_tool_and_delete_still_select_the_remainder_whole() { // Only Remove Anchor Points moves the selection on. The Delete Anchor Point tool leaves the @@ -4480,6 +4717,12 @@ fn blob_anchors(s: &mut Session, fidelity: f64) -> usize { /// [`blob_anchors`] with the blob scaled by `scale`: drawn the same on screen zoomed in 1 / `scale` /// times. fn scaled_blob_anchors(s: &mut Session, fidelity: f64, scale: f64) -> usize { + blob_anchors_through(s, "path.freehand", &json!({}), Some(fidelity), scale) +} + +/// [`scaled_blob_anchors`] drawn with `command` and the params in `extra`, at `fidelity` (with none, +/// the command's default). +fn blob_anchors_through(s: &mut Session, command: &str, extra: &serde_json::Value, fidelity: Option, scale: f64) -> usize { let pts: Vec<_> = (0..1200) .map(|i| { let a = i as f64 / 1200.0 * std::f64::consts::TAU; @@ -4487,7 +4730,14 @@ fn scaled_blob_anchors(s: &mut Session, fidelity: f64, scale: f64) -> usize { json!([(400.0 + r * a.cos()) * scale, (300.0 + r * a.sin()) * scale]) }) .collect(); - let r = s.execute("path.freehand", &json!({"points": pts, "closed": true, "fidelity": fidelity, "keepSelected": false})).unwrap(); + let mut p = json!({"points": pts, "closed": true, "keepSelected": false}); + if let Some(f) = fidelity { + p["fidelity"] = json!(f); + } + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let r = s.execute(command, &p).unwrap(); path(s, NodeId(r["id"].as_u64().unwrap())).subpaths.iter().map(|sp| sp.anchors.len()).sum() } @@ -4499,13 +4749,11 @@ fn pencil_fidelity_goes_down_to_a_hundredth_and_lower_keeps_more() { assert!(counts.windows(2).all(|w| w[0] <= w[1]), "{counts:?}"); // Below the old floor of 0.05 the stroke keeps more of itself. assert!(counts[4] > counts[2], "{counts:?}"); - // Below the floor, the lowest Fidelity (0.01 px) at the highest zoom (64000 %), is read as the - // floor (#5729). + // The floor is the lowest Fidelity (0.01 px) at the highest zoom (64000 %) (#5729); below it, + // `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`. let floor = vectorcraft_tools::draw2::MIN_FIDELITY / 640.0; let at_floor = blob_anchors(&mut s, floor); assert!(at_floor >= counts[4], "{counts:?}"); - assert_eq!(blob_anchors(&mut s, floor / 10.0), at_floor); - assert_eq!(blob_anchors(&mut s, 0.0), at_floor); } /// #5729: the tools send Fidelity / zoom, so a stroke drawn the same on screen zoomed in keeps as @@ -4521,6 +4769,89 @@ fn pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_in() { } } +/// The tools send Fidelity / zoom up to the highest Fidelity (100 px) at the lowest zoom (3.13 %): +/// a stroke drawn the same on screen zoomed out keeps as many anchors as at 100 %, as a Pencil or a +/// Paintbrush stroke and through `brush.freehand` with a brush. A tolerance above +/// `MAX_FIT_TOLERANCE` is an error, and a stroke given no Fidelity is fitted at 1.5 pt. +#[test] +fn pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out() { + let mut s = session(); + let max = vectorcraft_tools::draw2::MAX_FIT_TOLERANCE; + // The blob six times the size, so that 100 px still keeps some of its waves. + let big = 6.0; + for (command, extra) in [ + ("path.freehand", json!({})), + ("path.freehand", json!({"style": "brush"})), + ("brush.freehand", json!({"style": "brush", "brush": "3 pt. Round"})), + ] { + for fidelity in [20.0, 100.0] { + let want = blob_anchors_through(&mut s, command, &extra, Some(fidelity), big); + for zoom in [0.2, 0.0313] { + let got = blob_anchors_through(&mut s, command, &extra, Some(fidelity / zoom), big / zoom); + assert_eq!(got, want, "{command} {extra}: {fidelity} px at {zoom}x"); + // Fitted at 100 pt, the stroke on screen would keep the detail of a finer Fidelity. + if fidelity / zoom > 100.0 { + let at_100 = blob_anchors_through(&mut s, command, &extra, Some(100.0), big / zoom); + assert!(at_100 > want, "{command} {extra}: {fidelity} px at {zoom}x, 100 pt keeps {at_100} of {want}"); + } + } + } + let mut p = json!({"points": [[0, 0], [100, 50], [200, 0]], "fidelity": max * 10.0}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let err = s.execute(command, &p).unwrap_err().to_string(); + assert!(err.contains("`fidelity` must be from"), "{command} {extra}: {err}"); + let default = blob_anchors_through(&mut s, command, &extra, None, 1.0); + assert_eq!(default, blob_anchors_through(&mut s, command, &extra, Some(1.5), 1.0), "{command} {extra}"); + } +} + +/// `path.freehand` and `brush.freehand` take a `fidelity` from `MIN_FIT_TOLERANCE` (the lowest +/// Fidelity at the highest zoom) to `MAX_FIT_TOLERANCE` (the highest at the lowest zoom), both ends +/// included, and reject a value just outside either end, a tenth of the lowest, 0, a negative one or +/// a huge one, leaving the document as it was. +#[test] +fn pencil_takes_fidelity_within_its_range_and_rejects_the_rest() { + let (lo, hi) = (vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, vectorcraft_tools::draw2::MAX_FIT_TOLERANCE); + let points = json!([[0, 0], [100, 50], [200, 0], [300, 50]]); + for (command, extra) in [("path.freehand", json!({})), ("brush.freehand", json!({"style": "brush", "brush": "3 pt. Round"}))] { + let run = |s: &mut Session, fidelity: f64| { + let mut p = json!({"points": points, "fidelity": fidelity}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + s.execute(command, &p) + }; + let mut s = session(); + for fidelity in [100.0 / 0.0313, hi, lo, 0.01 / 640.0] { + let r = run(&mut s, fidelity); + assert!(r.as_ref().is_ok_and(|r| r["id"].is_u64()), "{command}: {fidelity} pt: {r:?}"); + } + let before = s.doc().unwrap().doc.clone(); + for fidelity in [f64::from_bits(hi.to_bits() + 1), f64::from_bits(lo.to_bits() - 1), lo / 10.0, 0.0, -1.5, 1e300] { + let err = run(&mut s, fidelity).unwrap_err().to_string(); + assert!(err.contains("`fidelity` must be from"), "{command}: {fidelity} pt: {err}"); + } + assert_eq!(s.doc().unwrap().doc, before, "{command}"); + } +} + +/// A stroke that continues an open path closes it when it ends within four Fidelities of the path's +/// other end, at most 400 pt away: at the coarsest Fidelity one ending 1000 pt away leaves it open, +/// and one ending 300 pt away closes it. +#[test] +fn pencil_extend_closes_within_400_pt_at_any_fidelity() { + let mut s = session(); + let max = vectorcraft_tools::draw2::MAX_FIT_TOLERANCE; + for (end, closed) in [([0, 1000], false), ([0, 300], true)] { + let l = line(&mut s, 0.0, 0.0, 2000.0, 0.0); + let points = json!([[2000, 0], [2000, 1000], [1000, 1500], end]); + s.execute("path.freehand", &json!({"points": points, "fidelity": max, "extend": {"id": l.0}, "keepSelected": false})).unwrap(); + assert_eq!(path(&s, l).subpaths[0].closed, closed, "ending at {end:?}"); + } +} + /// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never /// crash, and what they draw has finite anchors. #[test] diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 3fd187016f..d853b91110 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -535,9 +535,9 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() { } /// The reply lists each object whose appearance expanded once, by the id it has in the document, -/// starting with the selected object. In "group copies" the two copies of the square share an id -/// until Expand Appearance gives them ids of their own, and the reply names the group, then each -/// copy by its new id. +/// starting with the selected object. In "group copies" the group's art holds the square with its +/// stroke's effect applied, so the reply names the group alone; the two copies of the square share +/// ids until Expand Appearance gives every object in them an id of its own. #[test] fn the_reply_names_each_expanded_copy_by_its_own_id() { for case in RANDOM_CASES { @@ -551,8 +551,17 @@ fn the_reply_names_each_expanded_copy_by_its_own_id() { assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}"); if case == "group copies" { let art = node(&s, id); - let copies: Vec = art.children().unwrap()[0].children().unwrap().iter().map(|c| c.id.0).collect(); - assert_eq!(ids, [vec![id.0], copies].concat(), "{case}"); + assert_eq!(ids, [id.0], "{case}"); + let copies = art.children().unwrap(); + assert_eq!(copies.len(), 2, "{case}"); + let mut seen = std::collections::HashSet::new(); + let mut repeated = vec![]; + art.walk(&mut |n| { + if !seen.insert(n.id) { + repeated.push(n.id); + } + }); + assert!(repeated.is_empty(), "{case}: {repeated:?}"); } } } diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index f6ee8015be..5eb66ec6c8 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -192,6 +192,87 @@ fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() { assert_eq!(action, [fit("artboard.fitToArt"), fit("artboard.fitToArt"), fit("artboard.fitToSelection")]); } +/// A document of three artboards with art of its own on each → its artboards before any fit. +fn three_artboards_with_art(s: &mut Session) -> Vec { + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3, "created": null})).unwrap(); + for (x, h) in [(10, 30), (130, 20), (250, 40)] { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap(); + } + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect() +} + +/// The artboards of [`three_artboards_with_art`], each fitted to its art. +fn three_fitted_artboards() -> [vectorcraft_geom::Rect; 3] { + let rect = vectorcraft_geom::Rect::new; + [rect(10.0, 10.0, 30.0, 40.0), rect(130.0, 10.0, 150.0, 30.0), rect(250.0, 10.0, 270.0, 50.0)] +} + +/// A Fit to Artwork Bounds step that names neither `index` nor `indices`, while the Artboard tool +/// is chosen with several artboards selected, fits each of those (not the batch's artboard), as the +/// command does on its own; a step's own `index` or `indices` wins, and with one artboard or none +/// selected the step takes the batch's artboard. +#[test] +fn a_batch_fit_step_uses_the_artboard_tool_selection() { + let fitted = three_fitted_artboards(); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let fit = |params: Value| json!({"artboard": 1, "commands": [{"command": "artboard.fitToArt", "params": params}]}); + let session = |picks: &Value| { + let mut s = Session::new(); + let was = three_artboards_with_art(&mut s); + s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + s.set_tool_option("selected", picks); + (s, was) + }; + let (mut s, was) = session(&json!([2, 0])); + s.execute("command.batch", &fit(json!({}))).unwrap(); + assert_eq!(boards(&s), [fitted[0], was[1], fitted[2]], "artboards 1 and 3, the selected ones, and not the batch's artboard 2"); + let (mut s, was) = session(&json!([2, 0])); + s.execute("command.batch", &fit(json!({"index": 1}))).unwrap(); + assert_eq!(boards(&s), [was[0], fitted[1], was[2]], "a step's own index"); + let (mut s, was) = session(&json!([2, 0])); + s.execute("command.batch", &fit(json!({"indices": [1, 2]}))).unwrap(); + assert_eq!(boards(&s), [was[0], fitted[1], fitted[2]], "a step's own indices"); + for picks in [json!([2]), json!([])] { + let (mut s, was) = session(&picks); + s.execute("command.batch", &fit(json!({}))).unwrap(); + assert_eq!(boards(&s), [was[0], fitted[1], was[2]], "the batch's artboard with {picks} selected"); + } +} + +/// The journal records the artboards a batch's Fit to Artwork Bounds step took from the Artboard +/// tool, so a replay in a new session fits the same ones; an action leaves them out and fits the +/// artboards selected when it plays. +#[test] +fn a_batch_fit_selection_replays_but_an_action_uses_the_current_selection() { + let fitted = three_fitted_artboards(); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let mut s = Session::new(); + let was = three_artboards_with_art(&mut s); + s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([0, 2])); + let start = s.journal.len(); + let steps = json!([{"command": "artboard.fitToArt", "params": {}}]); + s.execute("command.batch", &json!({"artboard": 1, "commands": steps})).unwrap(); + assert_eq!(boards(&s), [fitted[0], was[1], fitted[2]]); + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!((id.as_str(), &p["commands"][0]["params"]), ("command.batch", &json!({"indices": [0, 2]}))); + let r = replayed(&s); + assert_ne!(r.tool_id(), "artboard", "the new session's tool selects no artboards"); + assert_eq!(boards(&r), boards(&s), "the replay fits the recorded artboards"); + let action = s.journal_for_action(start); + assert_eq!(action, [("command.batch".to_string(), json!({"commands": steps}))]); + // Played on a fresh copy of the document with other artboards selected. + let mut a = Session::new(); + three_artboards_with_art(&mut a); + a.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + a.set_tool_option("selected", &json!([1, 2])); + a.execute("command.batch", &json!({"artboard": 0, "commands": action[0].1["commands"]})).unwrap(); + assert_eq!(boards(&a), [was[0], fitted[1], fitted[2]], "the action fits the artboards selected when it plays"); +} + #[test] fn a_save_records_its_date_and_replays_it() { let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned(); diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index 9787401285..e683b156e1 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind}; +use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind, StoredRoot}; use vectorcraft_geom::Rect; use crate::*; @@ -446,8 +446,8 @@ fn links_outside_the_layers_find_their_files_after_the_folder_moves() { } /// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved -/// with the document is read again for the layers' image and for the symbol's, and both links take -/// its new path, size and hash. +/// with the document is read again for the layers' image and for the symbol's, both are listed by +/// their ids, and both links take its new path, size and hash. #[test] fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { let a = Folder::new("nested-modified-a"); @@ -458,13 +458,14 @@ fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); let id = place(&mut s, &pic); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &pic); save(&mut s, &a.file("poster.vectorcraft")); write(&pic, &png(30, 30, BLUE)); std::fs::remove_dir_all(&b.0).unwrap(); std::fs::rename(&a.0, &b.0).unwrap(); let r = open(&mut s, &b.file("poster.vectorcraft")); - assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}"); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0, in_symbol.0])), "{r}"); let moved = PathBuf::from(b.file("art/photo.png")); let blue = png(30, 30, BLUE); let mut images = vec![]; @@ -521,7 +522,8 @@ fn a_symbols_link_without_a_relative_path_follows_the_moved_file() { /// Update Links reads a changed file again for the images inside symbols, patterns and opacity /// masks too: those showing the same content as an image in the layers, and those no object in the -/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step. +/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step, +/// and the reply lists each by its id. #[test] fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let dir = Folder::new("nested-update"); @@ -533,11 +535,13 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let id = place(&mut s, &photo); s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &photo); // logo.png only in a pattern and in an opacity mask. let p = place(&mut s, &logo); s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap(); s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap(); + let p = stored_image_in(&s, StoredRoot::Pattern { pattern: 0, art: 0 }); let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); let mask = place(&mut s, &logo); s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); @@ -546,7 +550,8 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { write(&photo, &blue); write(&logo, &green); let u = s.execute("links.update", &json!({})).unwrap(); - assert_eq!(u, json!({"updated": [id.0], "missing": []})); + // By file: photo.png in the layers and the symbol, then logo.png in the mask and the pattern. + assert_eq!(u, json!({"updated": [id.0, in_symbol.0, mask.0, p.0], "missing": []})); // (name, size, hash, pixel size) of each image, which shows its file's pixels. let images = |s: &Session| { let d = &s.doc().unwrap().doc; @@ -576,6 +581,193 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s)); } +/// The files only art outside the layers shows: one in a symbol, a graph design, a pattern and an +/// opacity mask each, no object in the layers linked to any of them. +const NESTED: [&str; 4] = ["symbol.png", "design.png", "pattern.png", "mask.png"]; + +/// A document whose only links are [`NESTED`]'s files in `dir`. +fn nested_links(dir: &Folder) -> Session { + for (i, name) in (0u8..).zip(NESTED) { + write(&dir.file(name), &png(20, 10, [20 + 50 * i, 90, 160])); + } + let mut s = session(); + let id = place(&mut s, &dir.file("symbol.png")); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [s.doc().unwrap().selection.objects[0].0]})).unwrap(); + let id = place(&mut s, &dir.file("design.png")); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let id = place(&mut s, &dir.file("pattern.png")); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &dir.file("mask.png")); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s +} + +/// The id of the `links.list` row of file `name`. +fn row_id(s: &mut Session, name: &str) -> u64 { + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == name).unwrap_or_else(|| panic!("{name} in {list}")); + row["id"].as_u64().unwrap() +} + +/// The link of stored object `id`. +fn stored_link(s: &Session, id: u64) -> Option { + match &s.doc().unwrap().doc.stored_node(NodeId(id)).unwrap().0.kind { + NodeKind::Image(im) => im.link.clone(), + k => panic!("not an image: {k:?}"), + } +} + +/// The id of the first image stored in the art of `root`. +fn stored_image_in(s: &Session, root: StoredRoot) -> NodeId { + let mut found = None; + s.doc().unwrap().doc.visit_stored(|n, place| { + if found.is_none() && place.root == root && matches!(n.kind, NodeKind::Image(_)) { + found = Some(n.id); + } + }); + found.unwrap_or_else(|| panic!("no image in {root:?}")) +} + +/// Images in symbols, graph designs, patterns and opacity masks are listed and checked with ids of +/// their own, one row each with what holds it, and the missing filter counts them. +#[test] +fn links_outside_the_layers_are_listed_and_checked_with_ids() { + let dir = Folder::new("nested-ids"); + let mut s = nested_links(&dir); + let list = s.execute("links.list", &json!({})).unwrap(); + let rows = list["links"].as_array().unwrap(); + let owners: Vec<(&str, &str)> = rows.iter().map(|r| (r["name"].as_str().unwrap(), r["owner"]["kind"].as_str().unwrap_or("-"))).collect(); + assert_eq!(owners, [("mask.png", "layers"), ("symbol.png", "symbol"), ("design.png", "graphDesign"), ("pattern.png", "pattern")], "{list}"); + assert_eq!(rows[1]["owner"]["name"], "Mark"); + let masked = s.doc().unwrap().doc.layers[0].children().unwrap().iter().find(|n| n.extras().mask.is_some()).unwrap().id; + assert_eq!(rows[0]["owner"]["maskOf"], json!(masked.0)); + let ids: std::collections::BTreeSet = rows.iter().map(|r| r["id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 4, "{list}"); + assert!(rows.iter().all(|r| r["status"] == "ok" && r["linked"] == true), "{list}"); + let check = s.execute("links.check", &json!({})).unwrap(); + let checked: Vec<(&str, &Value)> = check["links"].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap(), &l["ids"])).collect(); + assert_eq!(checked.len(), 4, "{check}"); + for (name, ids) in checked { + assert_eq!(*ids, json!([row_id(&mut s, name)]), "{check}"); + } + std::fs::remove_file(dir.file("design.png")).unwrap(); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((check["missing"].as_u64(), check["modified"].as_u64()), (Some(1), Some(0)), "{check}"); + let design = row_id(&mut s, "design.png"); + let missing = s.execute("links.list", &json!({"show": "missing"})).unwrap(); + assert_eq!((missing["missing"].as_u64(), &missing["links"][0]["id"]), (Some(1), &json!(design)), "{missing}"); + let one = s.execute("links.check", &json!({"ids": [design]})).unwrap(); + assert_eq!(one["links"][0]["status"], "missing", "{one}"); +} + +/// Every links command takes the id of an image outside the layers and acts on that object: Link +/// Info, Go To (which clears the selection and names the symbol), Placement Options, Relink, Update and +/// Embed. +#[test] +fn links_commands_act_on_images_outside_the_layers_by_id() { + let dir = Folder::new("nested-act"); + let mut s = nested_links(&dir); + let symbol = row_id(&mut s, "symbol.png"); + let info = s.execute("links.info", &json!({"id": symbol})).unwrap(); + assert_eq!((info["fileName"].as_str(), info["status"].as_str(), info["id"].as_u64()), (Some("symbol.png"), Some("ok"), Some(symbol)), "{info}"); + let other = super::rect(&mut s, 0.0, 0.0, 5.0, 5.0); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let go = s.execute("links.goTo", &json!({"id": symbol})).unwrap(); + assert_eq!( + (go["onCanvas"].clone(), go["owner"]["kind"].clone(), go["owner"]["name"].clone()), + (json!(false), json!("symbol"), json!("Mark")), + "{go}" + ); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing on the canvas is selected"); + s.execute("links.placementOptions", &json!({"ids": [symbol], "preserve": "fit"})).unwrap(); + let placement = s.execute("links.placementOptions", &json!({"ids": [symbol]})).unwrap(); + assert_eq!(placement["placement"]["preserve"], "fit"); + // Relink: the symbol's image shows the new file. + let other = dir.file("other.png"); + write(&other, &png(40, 20, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": other})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + assert_eq!(stored_link(&s, symbol).unwrap().path, other); + // Update: a changed pattern file is read again for the pattern's image. + let pattern = row_id(&mut s, "pattern.png"); + let changed = png(30, 30, RED); + write(&dir.file("pattern.png"), &changed); + let r = s.execute("links.update", &json!({"ids": [pattern]})).unwrap(); + assert_eq!(r["updated"], json!([pattern]), "{r}"); + assert_eq!(stored_link(&s, pattern).unwrap().size, Some(changed.len() as u64)); + // Embed: the design's image keeps its pixels and no longer links. + let design = row_id(&mut s, "design.png"); + let r = s.execute("links.embed", &json!({"ids": [design]})).unwrap(); + assert_eq!(r["embedded"], json!([design]), "{r}"); + assert!(stored_link(&s, design).is_none()); + let row = s.execute("links.list", &json!({"show": "embedded"})).unwrap(); + assert_eq!(row["links"][0]["id"], json!(design), "{row}"); + // Relink All Instances takes the other stored objects linked to the same file. + let mask = row_id(&mut s, "mask.png"); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": dir.file("mask.png"), "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + let r = s.execute("links.relink", &json!({"ids": [mask], "path": other, "scope": "all"})).unwrap(); + let mut relinked: Vec = r["relinked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + relinked.sort(); + let mut both = vec![symbol, mask]; + both.sort(); + assert_eq!(relinked, both, "{r}"); +} + +/// Opening a document reports missing and modified files that only art outside the layers shows, +/// by their objects' ids, and those ids stay the same after the document is saved and opened again. +#[test] +fn open_reports_links_outside_the_layers_by_id() { + let dir = Folder::new("nested-open"); + let mut s = nested_links(&dir); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + let ids: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + std::fs::remove_file(dir.file("symbol.png")).unwrap(); + write(&dir.file("pattern.png"), &png(30, 30, RED)); + let r = open(&mut s, &path); + let names = |k: &str| r[k].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap().to_string(), l["ids"].clone())).collect::>(); + assert_eq!(names("missingLinks"), [("symbol.png".to_string(), json!([ids[0]]))], "{r}"); + assert_eq!(names("modifiedLinks"), [("pattern.png".to_string(), json!([ids[2]]))], "{r}"); + let again: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + assert_eq!(again, ids, "the ids are saved"); +} + +/// A document whose symbol and mask art repeat the ids of objects in the layers (or have none) +/// opens with ids of their own there, the layer tree's kept; the links commands then tell them apart. +#[test] +fn nested_objects_that_repeat_ids_open_with_ids_of_their_own() { + let dir = Folder::new("nested-repeat"); + let mut s = nested_links(&dir); + let layer_image = place(&mut s, &dir.file("symbol.png")); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + // The symbol's image takes the id of the image in the layers, the mask's image none. + let mut saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + saved["document"]["symbols"][0]["art"]["id"] = json!(layer_image.0); + let layer = &mut saved["document"]["layers"][0]["kind"]["children"]; + let masked = layer.as_array_mut().unwrap().iter_mut().find(|n| n.get("mask").is_some() || n["extras"].get("mask").is_some()).unwrap(); + let mask = if masked.get("mask").is_some() { &mut masked["mask"] } else { &mut masked["extras"]["mask"] }; + mask["art"]["id"] = json!(0); + std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap(); + open(&mut s, &path); + let list = s.execute("links.list", &json!({})).unwrap(); + let ids: Vec = list["links"].as_array().unwrap().iter().map(|r| r["id"].as_u64().unwrap()).collect(); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!((ids.len(), unique.len()), (5, 5), "{list}"); + assert!(!ids.contains(&0), "{list}"); + assert_eq!(list["links"][0]["id"], json!(layer_image.0), "the layer tree keeps its ids: {list}"); + let symbol = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").unwrap()["id"].as_u64().unwrap(); + s.execute("links.embed", &json!({"ids": [symbol]})).unwrap(); + assert!(stored_link(&s, symbol).is_none()); + assert!(image(&s, layer_image).link.is_some(), "the image in the layers stays linked"); +} + /// An image that Placement Options clip, alone in a group the user made, goes into a new clip group /// inside that group when its file grows, in the layers and in a symbol's art; the user's group /// stays a plain group. diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index f029eccd3d..a9a3a17ec7 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -213,7 +213,7 @@ fn go_to_selects_the_image() { run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); assert_ne!(s.doc().unwrap().selection.objects, vec![id]); let r = run(&mut s, "links.goTo", json!({"id": id.0})); - assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0]})); + assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0], "onCanvas": true})); assert_eq!(s.doc().unwrap().selection.objects, vec![id]); let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[1].id; assert!(s.execute("links.goTo", &json!({"id": rect.0})).is_err(), "not an image"); diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index 7fc8f0a4ba..0a5b9d7dbe 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -47,6 +47,30 @@ fn update_changed_reads_changed_files_again_once() { assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1); } +/// A file only a symbol's art shows is watched too, and read again by the id of the symbol's image. +#[test] +fn update_changed_reads_a_file_only_a_symbol_shows() { + let dir = Folder::new("watch-symbol"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + let id = place(&mut s, &path); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0]; + s.execute("edit.clear", &json!({"ids": [instance.0]})).unwrap(); + let mut in_symbol = None; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) { + in_symbol = Some(n.id.0); + } + }); + let in_symbol = in_symbol.expect("the symbol's image"); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([])); + save(&path, &png(40, 40, BLUE)); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([in_symbol])); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0); +} + #[test] fn a_background_look_follows_update_links() { for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] { diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 10939d4b2a..c372bcf784 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -643,6 +643,32 @@ fn envelope_top_object_shares_a_side_by_control_polygon_length() { assert!(near(Point::new(500.0, 200.0)) < 0.5, "the middle of the bottom: {pts:?}"); } +#[test] +fn envelope_top_object_keeps_its_look_when_anchors_are_added() { + // Add Anchor Points splits each segment of the top object in two without changing its shape. + // The top curve's parameter runs fast at its start, so its halves have control polygons of + // different lengths; the envelope still bends the two rectangles as the unsplit curve does. + let made = |add_anchors: bool| { + let mut s = session(); + let left = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let right = rect(&mut s, 200.0, 100.0, 100.0, 100.0); + let d = "M400 100 C520 40 590 70 600 100 L600 200 L400 200 Z"; + let t = id_of(&s.execute("path.create", &json!({ "d": d })).unwrap()); + if add_anchors { + sel(&mut s, &[t]); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + assert_eq!(node(&s, t).path_data().map(|p| p.anchor_count()), Some(8)); + } + sel(&mut s, &[left, right, t]); + let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap()); + all_anchors(&live::expand_live(&node(&s, e))) + }; + let (plain, split) = (made(false), made(true)); + assert_eq!(plain.len(), split.len()); + let off = plain.iter().zip(&split).map(|(p, q)| p.distance(*q)).fold(0.0, f64::max); + assert!(off < 1e-6, "the art moves by up to {off} pt"); +} + #[test] fn envelope_release_and_expand() { let mut s = session(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 44a0ddde1b..abb0bf31eb 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -4040,9 +4040,11 @@ fn ruler_guides_select_move_and_delete() { assert!(selected(&s).is_empty()); assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1])); let layer = s.doc().unwrap().current_layer().unwrap().0; + let id = s.doc().unwrap().doc.ruler_guides()[1].id.unwrap().0; assert_eq!( s.execute("guide.list", &json!({})).unwrap()[1], - json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + json!({"index": 1, "id": id, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}) ); assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err()); // A move takes each selected guide along its own axis, in one undo step. @@ -4323,11 +4325,14 @@ fn artboard_guides_go_with_their_artboard() { s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap(); let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap(); let layer = s.doc().unwrap().current_layer().unwrap().0; + let ids: Vec = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect(); assert_eq!( list(&mut s), json!([ - {"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}, - {"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true}, + {"index": 0, "id": ids[0], "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}, + {"index": 1, "id": ids[1], "vertical": false, "pos": 100.0, "selected": false, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}, ]) ); let spans = |s: &Session| { @@ -4368,7 +4373,8 @@ fn artboard_guides_go_with_their_artboard() { assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1])); assert_eq!( list(&mut s)[0], - json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + json!({"index": 0, "id": ids[1], "vertical": false, "pos": 100.0, "selected": true, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}) ); assert_eq!(undo_len(&s), n + 1); s.execute("edit.undo", &json!({})).unwrap(); @@ -4412,9 +4418,11 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() { s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap(); s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap(); let layer = s.doc().unwrap().current_layer().unwrap().0; + let id = s.doc().unwrap().doc.ruler_guides()[1].id.unwrap().0; assert_eq!( s.execute("guide.list", &json!({})).unwrap()[1], - json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}) + json!({"index": 1, "id": id, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}) ); } @@ -4569,6 +4577,12 @@ fn ruler_guides_live_on_layers() { assert!(s.doc().unwrap().doc.ruler_guides().iter().all(|g| g.layer == Some(first))); s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); + // `indexes` that is not a list, or given with `index`, is an error; an index given twice counts once. + assert!(s.execute("guide.setLayer", &json!({"indexes": 0, "layer": first.0})).is_err()); + assert!(s.execute("guide.setLayer", &json!({"index": 0, "indexes": [1], "layer": first.0})).is_err()); + assert!(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.layer).eq([Some(first), Some(second)])); + assert_eq!(s.execute("guide.setLayer", &json!({"indexes": [1, 1], "layer": first.0})).unwrap()["count"], 1); + s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); // Deleting a layer deletes its guides; undo brings them back. s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap(); assert_eq!(guides(&s), [(true, 200.0)]); @@ -4606,6 +4620,122 @@ fn guides_on_hidden_or_locked_layers_are_left_alone() { assert_eq!(guides(&s).len(), 3); } +/// A guide hidden or locked on its own can't be moved, deleted or moved to another layer, and the +/// error names the guide, not its layer; a guide on a hidden or locked layer names the layer. +#[test] +fn a_guide_hidden_or_locked_on_its_own_is_named_in_the_error() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + for pos in [100.0, 200.0] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let ids: Vec = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect(); + s.execute("layer.setProps", &json!({"ids": [ids[0]], "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [ids[1]], "visible": false})).unwrap(); + let own = "the guide is hidden or locked"; + for i in [0, 1] { + assert_eq!(s.execute("guide.move", &json!({"index": i, "pos": 50})).unwrap_err().to_string(), own, "{i}"); + assert_eq!(s.execute("guide.remove", &json!({"index": i})).unwrap_err().to_string(), own, "{i}"); + assert_eq!(s.execute("guide.remove", &json!({"indexes": [i]})).unwrap_err().to_string(), own, "{i}"); + let p = json!({"index": i, "layer": second.0}); + assert_eq!(s.execute("guide.setLayer", &p).unwrap_err().to_string(), own, "{i}"); + } + // Shown and unlocked, but on a locked layer: the error names the layer. + s.execute("layer.setProps", &json!({"ids": [ids[0]], "locked": false})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap(); + let e = s.execute("guide.move", &json!({"index": 0, "pos": 50})).unwrap_err().to_string(); + assert_eq!(e, "the guide is on a hidden or locked layer"); +} + +/// Object › Lock › Selection and Hide › Selection lock and hide a selected ruler guide on its own, +/// with the selected art, in one undo step; Unlock All and Show All unlock and show it again and +/// select it as a guide. While Lock Guides is on nothing selects it, and in isolation mode Lock +/// leaves it alone. +#[test] +fn lock_hide_unlock_and_show_all_reach_ruler_guides() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + for pos in [100.0, 200.0] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let flags = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.hidden, g.locked)).collect::>(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let clear = (false, false); + // A guide selected on its own: Lock locks it alone and deselects it. + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + let n = undo(&s); + s.execute("object.lock", &json!({})).unwrap(); + assert_eq!((flags(&s), undo(&s)), (vec![(false, true), clear], n + 1)); + assert!(selected_guides(&s).is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((flags(&s), selected_guides(&s)), (vec![clear, clear], vec![0])); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(flags(&s)[0], (false, true)); + // A guide and art selected together: Hide hides both, in one step. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap(); + let n = undo(&s); + s.execute("object.hide", &json!({})).unwrap(); + assert_eq!((flags(&s), undo(&s)), (vec![(false, true), (true, false)], n + 1)); + assert!(!s.doc().unwrap().doc.node(a).unwrap().visible); + // Unlock All and Show All reach the guides and select them as guides. + assert_eq!(s.execute("object.unlockAll", &json!({})).unwrap()["count"], 1); + assert_eq!((flags(&s)[0], selected_guides(&s), selected(&s)), (clear, vec![0], vec![])); + assert_eq!(s.execute("object.showAll", &json!({})).unwrap()["count"], 2); + assert_eq!((flags(&s), selected_guides(&s), selected(&s)), (vec![clear, clear], vec![1], vec![a])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(flags(&s)[1], (true, false), "Show All is one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + // With Lock Guides on, Unlock All unlocks a guide but leaves it unselected. + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + s.execute("object.unlockAll", &json!({})).unwrap(); + assert_eq!((flags(&s)[0], selected_guides(&s)), (clear, vec![])); + s.execute("view.guides.lock", &json!({"locked": false})).unwrap(); + // In isolation mode Lock takes only the objects inside the isolated group. + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + sel(&mut s, &[a]); + let _ = s.execute("guide.select", &json!({"indexes": [0], "toggle": true})); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).unwrap().locked); + assert_eq!(flags(&s)[0], clear); +} + +/// `guide.remove` with `indexes` deletes those guides in one undo step, an index given twice +/// once; a locked guide among them, or an unknown index, deletes nothing. +#[test] +fn guide_remove_takes_several_indexes() { + let mut s = session(); + for pos in [100.0, 200.0, 300.0] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let id = s.doc().unwrap().doc.ruler_guides()[2].id.unwrap(); + s.execute("layer.setProps", &json!({"ids": [id.0], "locked": true})).unwrap(); + assert!(s.execute("guide.remove", &json!({"indexes": [0, 2]})).is_err()); + assert!(s.execute("guide.remove", &json!({"indexes": [0, 7]})).is_err()); + assert!(s.execute("guide.remove", &json!({"indexes": 0})).is_err()); + assert!(s.execute("guide.remove", &json!({"index": 0, "indexes": [1]})).is_err()); + // A list longer than 10,000 entries is refused by every guide command that takes one. + let long = json!(vec![0; 10_001]); + assert!(s.execute("guide.remove", &json!({"indexes": long})).is_err()); + assert!(s.execute("guide.select", &json!({"indexes": long})).is_err()); + let layer = s.doc().unwrap().doc.layers[0].id; + assert!(s.execute("guide.setLayer", &json!({"indexes": long, "layer": layer.0})).is_err()); + assert_eq!(s.execute("guide.select", &json!({"indexes": vec![0; 10_000]})).unwrap()["selected"], json!([0])); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 3); + let n = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("guide.remove", &json!({"indexes": [1, 0, 1]})).unwrap()["count"], 2); + assert_eq!((guides(&s), s.doc().unwrap().history.undo.len()), (vec![(true, 300.0)], n + 1)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 3); +} + /// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo /// step each. #[test] diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index 4ae8321167..d9e76f85bf 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -681,3 +681,197 @@ fn the_space_saves_and_reopens_and_goes_with_a_copy() { let copy = s.doc().unwrap().selection.objects.to_vec(); assert_eq!(space(&s, copy[0]), (Some(40.0), 25.0)); } + +/// Where `id` is in the tree: its parent and its index there (0 at the bottom). +fn place_of(s: &Session, id: NodeId) -> (Option, usize) { + let (parent, index, _) = s.doc().unwrap().doc.position(id).unwrap(); + (parent, index) +} + +#[test] +fn detach_takes_the_chosen_object_off_the_path_and_the_others_move_up_in_one_undo_step() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({"rotate": 30})); + let (layer, at) = place_of(&s, g); + s.execute("object.objectsOnPath.selectMember", &json!({"member": middle.0})).unwrap(); + let drawn = s.doc().unwrap().doc.node(middle).unwrap().clone(); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.detach", &json!({})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": middle.0})); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + let (on_path, children) = orders(&s, g); + assert_eq!(on_path, [left, right]); + assert!(children.len() == 3 && !children.contains(&middle), "{children:?}"); + assert_eq!(place_of(&s, middle), (layer, at + 1), "just above the group, in its layer"); + assert_eq!(s.doc().unwrap().doc.node(middle), Some(&drawn), "the object keeps its art, place and rotation"); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[middle][..], None), "the detached object alone is selected"); + // The two left stay on the ends of the path; the path stays. + assert_eq!(center(&s, left), Point::new(50.0, 300.0)); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); + assert_eq!(s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().unwrap().path, path); + // The detached object is free: moving it moves it, and the group does not take it back. + s.execute("object.move", &json!({"ids": [middle.0], "dx": 0, "dy": -100})).unwrap(); + assert_eq!(center(&s, middle), Point::new(200.0, 200.0)); + assert_eq!(orders(&s, g).0, [left, right]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let (on_path, children) = orders(&s, g); + assert_eq!(on_path, [left, middle, right], "undo puts it back on the path"); + assert!(children.contains(&middle), "{children:?}"); + assert_eq!(center(&s, middle), Point::new(200.0, 300.0)); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(middle)), "and the choice back"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [left, right]); + assert_eq!(place_of(&s, middle), (layer, at + 1)); +} + +#[test] +fn after_the_first_object_is_detached_the_second_takes_the_start() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({})); + select(&mut s, &[]); + let v = s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": left.0})); + assert_eq!(orders(&s, g).0, [middle, right]); + assert_eq!(center(&s, middle), Point::new(50.0, 300.0), "the second object moves up to the start"); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); + assert_eq!(center(&s, left), Point::new(50.0, 300.0), "the detached object stays where it was drawn"); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [left]); +} + +#[test] +fn detach_on_a_closed_path_spreads_the_others_all_the_way_round_again() { + let mut s = session(); + let ids: Vec = (0..4).map(|i| rect(&mut s, 10.0 + 40.0 * f64::from(i), 0.0, 10.0, 10.0)).collect(); + let ring = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let g = attach(&mut s, ring, &ids, json!({})); + s.execute("object.objectsOnPath.detach", &json!({"member": ids[1].0})).unwrap(); + assert_eq!(orders(&s, g).0, [ids[0], ids[2], ids[3]]); + let data = s.doc().unwrap().doc.node(ring).unwrap().path_data().unwrap().clone(); + let stops = vectorcraft_doc::objects_on_path::stops(&data, 3, None, 0.0).unwrap(); + for (id, (p, _)) in [ids[0], ids[2], ids[3]].iter().zip(stops) { + let c = center(&s, *id); + assert!((c.x - p.x).abs() < 1e-3 && (c.y - p.y).abs() < 1e-3, "a third of the way round apart: {c:?} {p:?}"); + } +} + +#[test] +fn detach_keeps_a_space_value_and_the_start() { + let mut s = session(); + let (g, [a, b, c], _) = three_on_a_line(&mut s); + s.execute("object.objectsOnPath.options", &json!({"start": 20})).unwrap(); + s.execute("object.objectsOnPath.detach", &json!({"member": a.0})).unwrap(); + assert_eq!(space(&s, g), (Some(100.0), 20.0)); + assert_eq!(center(&s, b), Point::new(70.0, 300.0), "the second object moves up to the first place"); + assert_eq!(center(&s, c), Point::new(170.0, 300.0)); + // A Space value the path is too short for stays, and two objects get more of the path. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.objectsOnPath.options", &json!({"spacing": 200, "start": 0})).unwrap(); + assert_eq!(center(&s, c), Point::new(350.0, 300.0), "three objects drawn 150 pt apart"); + s.execute("object.objectsOnPath.detach", &json!({"member": b.0})).unwrap(); + assert_eq!(space(&s, g), (Some(200.0), 0.0)); + assert_eq!(center(&s, a), Point::new(50.0, 300.0)); + assert_eq!(center(&s, c), Point::new(250.0, 300.0), "two objects 200 pt apart"); +} + +#[test] +fn the_group_and_its_path_stay_with_one_object_or_none_left() { + let mut s = session(); + let ([left, middle, _], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle], json!({"pivot": 7})); + s.execute("object.objectsOnPath.options", &json!({"spacing": 30})).unwrap(); + s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(orders(&s, g), (vec![middle], vec![path, middle])); + let b = bounds(&s, middle); + assert!((b.center().x - 50.0).abs() < 1e-6 && (b.y1 - 300.0).abs() < 1e-6, "the one left on the start, by its Pivot: {b:?}"); + s.execute("object.objectsOnPath.detach", &json!({"member": middle.0})).unwrap(); + let n = s.doc().unwrap().doc.node(g).unwrap().clone(); + let rec = n.extras().objects_on_path.as_deref().unwrap(); + assert_eq!((rec.path, rec.members.len(), rec.pivot, rec.spacing), (path, 0, 7, Some(30.0)), "a live group with no objects"); + assert_eq!(orders(&s, g).1, [path]); + // An object moved back in joins the path. + s.execute("layer.move", &json!({"ids": [left.0], "target": g.0, "place": "inside"})).unwrap(); + assert_eq!(orders(&s, g).0, [left]); +} + +#[test] +fn detach_refuses_what_is_not_one_chosen_editable_object_on_a_path() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.detach", &p).unwrap_err().to_string(); + let choose = "choose the object to detach with its Select/Move widget"; + let steps = s.doc().unwrap().history.undo.len(); + select(&mut s, &[g]); + assert!(err(&mut s, json!({})).contains(choose), "the group without a chosen object"); + select(&mut s, &[]); + assert!(err(&mut s, json!({})).contains(choose), "nothing selected"); + assert!(err(&mut s, json!({"member": path.0})).contains("`member` must name an object attached"), "the path"); + assert!(err(&mut s, json!({"member": g.0})).contains("`member` must name"), "the group itself"); + assert!(err(&mut s, json!({"member": loose.0})).contains("`member` must name"), "an object on no path"); + assert!(err(&mut s, json!({"member": 99999})).contains("no such object")); + assert!(err(&mut s, json!({"member": "a"})).contains("invalid object id")); + // Hidden and locked objects stay; a locked group keeps all of its objects. + select(&mut s, &[c]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": c.0})).contains("`member` names an object that is hidden or locked")); + s.execute("object.unlockAll", &json!({})).unwrap(); + select(&mut s, &[b]); + s.execute("object.hide", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": b.0})).contains("hidden or locked"), "a hidden object"); + s.execute("object.showAll", &json!({})).unwrap(); + select(&mut s, &[g]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": a.0})).contains("the Objects on Path group is hidden or locked")); + s.execute("object.unlockAll", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused detached anything"); + let undone = s.doc().unwrap().history.undo.len(); + assert_eq!(undone, steps + 6, "only the lock, hide and show steps"); +} + +#[test] +fn an_object_detached_from_a_group_on_another_path_does_not_join_that_path() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let inner = attach(&mut s, path, &[left, middle], json!({})); + let outer_line = line(&mut s, (20.0, 380.0), (380.0, 380.0)); + let outer = attach(&mut s, outer_line, &[inner, right], json!({})); + let outer_members = orders(&s, outer).0; + s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(orders(&s, inner).0, [middle]); + assert_eq!(orders(&s, outer).0, outer_members, "the outer path keeps its objects and takes no new one"); + let (layer, at) = place_of(&s, outer); + assert_eq!(place_of(&s, left), (layer, at + 1), "above the outer group"); +} + +#[test] +fn hidden_and_locked_objects_left_on_the_path_move_up_too() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({})); + select(&mut s, &[right]); + s.execute("object.lock", &json!({})).unwrap(); + select(&mut s, &[middle]); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(orders(&s, g).0, [middle, right]); + assert_eq!(center(&s, middle), Point::new(50.0, 300.0), "the hidden object moves up to the start"); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); +} + +#[test] +fn objects_moved_out_together_in_the_layers_panel_all_leave_the_path() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({})); + s.execute("layer.move", &json!({"ids": [left.0, middle.0], "target": g.0, "place": "above"})).unwrap(); + assert_eq!(orders(&s, g), (vec![right], vec![path, right])); + assert_eq!(center(&s, right), Point::new(50.0, 300.0), "the one left takes the start"); + let (layer, at) = place_of(&s, g); + assert_eq!((place_of(&s, left), place_of(&s, middle)), ((layer, at + 1), (layer, at + 2)), "above the group, in their order"); +} diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 38918cf2c4..b8c4724853 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -257,6 +257,25 @@ fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canva } } +/// A group's Transform effect runs after its member's own Offset Path in Expand Appearance as on +/// the canvas: the 100 pt square offset to 120 pt and scaled by half expands 60 pt wide. +#[test] +fn a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}})); + run(&mut s, "object.group", json!({})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50}})); + let before = render(&s); + run(&mut s, "effect.expandAppearance", json!({})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "{diff} pixels differ"); + let doc = &s.doc().unwrap().doc; + let w = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). @@ -610,6 +629,89 @@ fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { } } +/// Selected type "Hi" at 48 pt, its characters filled with a gradient that repeats 100 times along +/// a vector `len` pt long. +fn spread_gradient_type(s: &mut Session, len: f64) -> u64 { + let t = run(s, "text.create", json!({"x": 50, "y": 100, "text": "Hi", "size": 48}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [t]})); + run( + s, + "paint.setFill", + json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [50, 100], "end": [50.0 + len, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}}), + ); + t +} + +/// How many objects in the document have a gradient fill, and how many are type. +fn gradients_and_type(s: &Session) -> (usize, usize) { + let (mut gradients, mut texts) = (0, 0); + s.doc().unwrap().doc.walk(|n| { + gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_))); + texts += usize::from(matches!(n.kind, NodeKind::Text(_))); + }); + (gradients, texts) +} + +/// Type is outlined before its fills expand, and each glyph's gradient fits that glyph's own box: +/// `object.expand.info` reports a gradient (and a fill) only when a glyph's box needs no more runs +/// than are written, so Expand turns it into art. Both Expand Gradient To options leave the +/// glyphs whose gradient needs more runs with their gradient, and Object still outlines the type. +/// The query leaves the document and its history as they are. +#[test] +fn type_whose_gradient_needs_more_runs_than_written_reports_no_gradient() { + // 0.2 pt runs (100 repeats over 20 pt) across glyphs about 30 pt wide: under the limit. + // 0.02 pt runs (over 2 pt): far over it. + for (len, expands) in [(20.0, true), (2.0, false)] { + let mut s = session(); + spread_gradient_type(&mut s, len); + let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.layers.clone()); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone(), info["object"].clone()), (json!(expands), json!(expands), json!(true)), "{len}"); + assert!( + before == (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.layers.clone()), + "{len}: the query changed the document" + ); + for to in [json!({}), json!({"gradient": "mesh"})] { + let mut s = session(); + spread_gradient_type(&mut s, len); + run(&mut s, "object.expand", to.clone()); + let (gradients, texts) = gradients_and_type(&s); + assert_eq!(texts, 0, "{len} {to}: outlined"); + assert_eq!(gradients == 0, expands, "{len} {to}: {gradients} gradient fills left"); + } + } +} + +/// One glyph whose box needs more runs than are written does not hide another that Expand turns +/// into art: the 200 pt "H" keeps its gradient, the 48 pt "i" expands, and the query reports it. +#[test] +fn type_reports_a_gradient_when_any_glyph_expands() { + let mut s = session(); + let t = spread_gradient_type(&mut s, 20.0); + run(&mut s, "text.setRangeStyle", json!({"id": t, "start": 0, "end": 1, "size": 200})); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(true), json!(true))); + run(&mut s, "object.expand", json!({})); + assert_eq!(gradients_and_type(&s), (1, 0), "the large glyph keeps its gradient"); +} + +/// The same rule for type in the art of a symbol instance that Object expands. The symbol's art +/// is the type scaled down (its "i" about 2.5 pt wide), so its glyphs need more runs than are +/// written with 0.002 pt runs (over a 0.2 pt vector). +#[test] +fn type_in_symbol_art_reports_a_gradient_only_when_it_expands() { + for (len, expands) in [(20.0, true), (0.2, false)] { + let mut s = session(); + spread_gradient_type(&mut s, len); + let inst = run(&mut s, "symbol.new", json!({"name": "Hi"}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [inst]})); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!(info["gradient"], json!(expands), "{len}"); + assert_eq!((info["symbolArt"]["gradient"].clone(), info["symbolArt"]["fill"].clone()), (json!(expands), json!(expands)), "{len}: {info}"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/engine/src/tests/pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs index 7de08463f3..ecdf140c0e 100644 --- a/crates/engine/src/tests/pdfpresets.rs +++ b/crates/engine/src/tests/pdfpresets.rs @@ -123,40 +123,49 @@ fn presets_export_and_import_round_trip() { assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export"); } -/// `longPagesInPoints` is saved with a preset, comes back through a presets file and the -/// preferences, shows in `document.pdfSettings`, and a 10 m page exported with it reopens at 10 m. -/// A value that isn't a boolean is refused. +/// `longPagesInPoints` is on by default: a 10 m page exported without options is written in +/// points and reopens at 10 m. Turned off, it is saved with a preset, comes back through a presets +/// file and the preferences, shows in `document.pdfSettings`, and the page exported with it has a +/// `UserUnit` and reopens at 10 m. A value that isn't a boolean is refused. #[test] fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 28_346.456_692_913_386, "height": 2_834.645_669_291_338_5})).unwrap(); - s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": true, "preserveEditing": false})).unwrap(); - assert!(s.prefs.pdf_presets[0].settings.long_pages_in_points); + let reopened = |bytes: &[u8]| { + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + let rect = o.doc().unwrap().doc.artboards[0].rect; + assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + }; + let warned = |v: &Value, end: &str| v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().ends_with(end)); + // By default the page is written in points. + assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(true)); + let v = s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap(); + let bytes = pdf(&v); + assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); + assert!(warned(&v, "written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in"), "{v}"); + reopened(&bytes); + // Off in a preset. + s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": false, "preserveEditing": false})).unwrap(); + assert!(!s.prefs.pdf_presets[0].settings.long_pages_in_points); let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); assert_eq!(back.pdf_presets, s.prefs.pdf_presets); let text = s.execute("pdf.preset.export", &json!({"names": ["Banner"]})).unwrap()["data"].as_str().unwrap().to_string(); let mut t = session(); t.execute("pdf.preset.import", &json!({"data": text})).unwrap(); assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets); - let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": true})).unwrap(); - assert_eq!((&v["settings"]["longPagesInPoints"], &v["changed"]), (&json!(true), &json!([{"option": "longPagesInPoints", "value": true}])), "{v}"); - assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(false)); - // The preset writes the page in points; the file reopens at 10 m. - let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); - let bytes = pdf(&v); - assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); - assert!( - v["warnings"] - .as_array() - .unwrap() - .iter() - .any(|w| w.as_str().unwrap().ends_with("written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in")), + let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": false})).unwrap(); + assert_eq!( + (&v["settings"]["longPagesInPoints"], &v["changed"]), + (&json!(false), &json!([{"option": "longPagesInPoints", "value": false}])), "{v}" ); - let mut o = Session::new(); - o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - let rect = o.doc().unwrap().doc.artboards[0].rect; - assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + // The preset writes the page with a `UserUnit`; the file reopens at 10 m. + let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); + let bytes = pdf(&v); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit 1.969")); + assert!(warned(&v, "it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller"), "{v}"); + reopened(&bytes); for bad in [json!("yes"), json!(1)] { assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}"); assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Bad", "longPagesInPoints": bad}))), "{bad}"); diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index a0a8ededa0..8dbe06f349 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -365,6 +365,95 @@ fn a_file_read_apart_from_the_session_replaces_what_was_selected_when_place_star } } +/// Every open document with its art, selection and undo steps, and the journal, tool and tool +/// options: what a refused hand-off leaves as it was. +type Snapshot = (Vec<(u64, Arc, Vec, usize)>, usize, &'static str, Value); + +fn snapshot(s: &Session) -> Snapshot { + let docs = s.documents().iter().map(|d| (d.uid, d.doc.clone(), d.selection.objects.clone(), d.history.undo.len())).collect(); + (docs, s.journal.len(), s.tool_id(), s.tool_options()) +} + +/// A file read apart from the session is placed only in the document Place started in: with +/// another document active, Replace included when that document holds the same object under the +/// same id, or after the document closed, nothing changes in any document; with the document +/// active again, the file is placed in it. +#[test] +fn a_file_read_apart_from_the_session_is_placed_only_in_the_document_it_was_read_for() { + let blue = file("blue.png", &png(8, 8, [0, 0, 255, 255], None)); + let not_active = "the document it was prepared for is no longer active"; + for replace in [false, true] { + let p = with(blue.clone(), json!({"replace": replace})); + let mut s = session(); + let square = json!({"x": 10, "y": 10, "width": 20, "height": 20}); + let a = s.execute("shape.rectangle", &square).unwrap()["id"].clone(); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let b = s.execute("shape.rectangle", &square).unwrap()["id"].clone(); + assert_eq!(a, b, "the other document holds an object with the same id"); + assert!(node(&s, &b) == s.documents()[0].doc.node(NodeId(a.as_u64().unwrap())).unwrap().clone(), "and the same content"); + let before = snapshot(&s); + let err = s.place_read(read).unwrap_err(); + assert!(err.to_string().contains(not_active), "replace {replace}: {err}"); + assert!(snapshot(&s) == before, "replace {replace}: nothing placed, selected or journaled in either document"); + // Active again, the document Place started in takes the file. + let read = { + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.prepare_place(&p).unwrap().read().unwrap() + }; + s.execute("document.activate", &json!({"index": 1})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + let r = s.place_read(read).unwrap(); + assert_eq!(s.active_index(), Some(0)); + assert!(s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).is_some(), "replace {replace}: placed in the first document"); + let others = s.documents()[1].doc.layers.last().unwrap().children().unwrap().len(); + assert_eq!(others, 1, "replace {replace}: the other document keeps its one object"); + } + // The document Place started in closed: refused in the document left open, and with none left. + let mut s = session(); + let read = s.prepare_place(&blue).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("file.close", &json!({"index": 0})).unwrap(); + let before = snapshot(&s); + assert!(s.place_read(read).unwrap_err().to_string().contains(not_active)); + assert!(snapshot(&s) == before, "nothing placed in the document left open"); + let read = s.prepare_place(&blue).unwrap().read().unwrap(); + s.execute("file.close", &json!({})).unwrap(); + let journal = s.journal.len(); + assert!(s.place_read(read).is_err() && s.journal.len() == journal, "no document left: refused"); +} + +/// Files read for the place cursor apart from the session load it only in the document they were +/// read for: with another document active, or after that document closed, its tool and queue stay +/// as they were; with the document active again, the cursor is loaded. +#[test] +fn files_read_for_the_place_cursor_apart_from_the_session_load_it_only_in_their_document() { + let p = json!({"files": [file("a.png", &png(40, 20, [9, 9, 9, 255], None))], "link": false}); + let not_active = "the document it was prepared for is no longer active"; + let mut s = session(); + let read = s.prepare_place_queue(&p).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let before = snapshot(&s); + assert!(s.place_queue_read(read).unwrap_err().to_string().contains(not_active)); + assert!(snapshot(&s) == before && s.tool_id() == "selection", "the other document's tool and queue stay"); + let read = { + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.prepare_place_queue(&p).unwrap().read().unwrap() + }; + s.execute("document.activate", &json!({"index": 1})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.place_queue_read(read).unwrap()["count"], 1); + assert_eq!(s.tool_id(), "place"); + // The document they were read for closed. + let mut s = session(); + let read = s.prepare_place_queue(&p).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("file.close", &json!({"index": 0})).unwrap(); + let before = snapshot(&s); + assert!(s.place_queue_read(read).unwrap_err().to_string().contains(not_active)); + assert!(snapshot(&s) == before, "the document left open keeps its tool"); +} + /// A linked image keeps the preview its file was read with ([`cmd::place::PlaceJob::read`] makes /// it), so placing the read file does not decode the image again; an embedded image has none. #[test] @@ -662,6 +751,101 @@ fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() { assert!(linked, "still linked to the swatch it brought"); } +/// The symbol and pattern art placed art brings takes ids of the document's own: the file's ids +/// already name other objects there, and the Links panel names linked art in symbols by its id. +#[test] +fn placed_art_brings_its_symbols_and_patterns_with_ids_of_the_documents_own() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + // The document's objects take the same ids the file's do. + let mut s = session(); + for x in [0, 20, 40, 60] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 5, "height": 5})).unwrap(); + } + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let doc = &s.doc().unwrap().doc; + assert!(doc.symbols.iter().any(|x| x.name == "Star") && doc.pattern("Dots").is_some()); + let mut ids = vec![]; + doc.visit_stored(|n, _| ids.push(n.id.0)); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); +} + +/// Placing the same file again shares the symbols and patterns the first placement brought: their +/// art differs from the file's only in its ids, so no `Star 2` or `Dots 2` joins the document. +#[test] +fn placing_the_same_art_again_shares_its_symbols_and_patterns() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + let counts = |s: &Session| { + let doc = &s.doc().unwrap().doc; + (doc.symbols.len(), doc.patterns.len()) + }; + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let once = counts(&s); + for _ in 0..2 { + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + } + assert_eq!(counts(&s), once); + let doc = &s.doc().unwrap().doc; + assert!(!doc.symbols.iter().any(|x| x.name == "Star 2") && doc.pattern("Dots 2").is_none()); +} + +/// A symbol whose art matches the document's own but uses a pattern the document defines +/// differently is not shared: it comes in under a free name, using the pattern it brings. +#[test] +fn placed_art_does_not_share_a_symbol_whose_pattern_moves() { + let mut src = session(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star", "ids": [tile.0]})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + // The document's "Dots" changes; its "Star" still matches the file's. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + let dots = d.pattern_mut("Dots").unwrap(); + dots.tile = Rect::new(0.0, 0.0, 50.0, 50.0); + let r = place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let mut symbol = None; + node(&s, &r["ids"][0]).walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol: name, .. } = &n.kind { + symbol = Some(name.clone()); + } + }); + assert_eq!(symbol.as_deref(), Some("Star 2")); + let doc = &s.doc().unwrap().doc; + let star2 = doc.symbols.iter().find(|x| x.name == "Star 2").unwrap(); + let mut fills = vec![]; + star2.art.walk(&mut |n| { + fills.extend(n.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }, + _ => None, + })) + }); + assert_eq!(fills, ["Dots 2"]); +} + #[test] fn placed_art_brings_its_brushes_renaming_on_conflict() { // The file: a path with its own brush, and one with a brush the defaults hold. diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index cc4460d250..499f2d15c6 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -796,30 +796,269 @@ fn expanding_a_pattern_fill_keeps_each_piece_s_own_paint() { assert_eq!(patterns, 0, "the tiles are objects"); } -/// Two painted effects on one fill, the first returning a piece with two fills: the second replaces -/// the first of them and the other fill stays over it. +/// A path's bounds and the paints of its fills and strokes, each stroke with its weight. +type PaintedPath = (Option, Vec<(Paint, Option)>); + +/// The paths of `id`'s reshaped art that paint, in paint order, each with the fills and strokes of +/// it that paint. +fn painted_paths(s: &Session, id: NodeId) -> Vec { + let st = s.doc().unwrap(); + let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); + let mut paths = vec![]; + art.walk(&mut |n| { + if let Some(p) = n.path_data() { + let items: Vec<_> = n + .appearance + .items + .iter() + .map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => (f.paint.clone(), None), + vectorcraft_doc::AppearanceItem::Stroke(st) => (st.paint.clone(), Some(st.width)), + }) + .filter(|(paint, _)| !paint.is_none()) + .collect(); + if !items.is_empty() { + paths.push((p.bounds(), items)); + } + } + }); + paths +} + +fn paint(hex: &str) -> Paint { + Paint::solid(Color::from_hex(hex).unwrap()) +} + +/// Two painted effects on one fill, the first returning a piece with two fills: the second runs on +/// each of them, told which fill it is on. On the second fill it returns a blue square at 80 pt; on +/// the first it returns a result that is refused, which leaves only that fill, red, on the square at +/// 40 pt. #[test] -fn a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills() { +fn a_later_painted_effect_runs_on_every_fill_a_piece_returns() { let mut s = session(); let id = rect(&mut s, 10.0, "#ff0000"); let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); - let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &two)); - install_wat(&mut s, &tpl::returning(&painting("test.engine.onefill", true), &one)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.onsecond", true), tpl::Given::Params, r#""item":{"fill":1}"#, &blue)); let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twofills", "item": fill})); - run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onefill", "item": fill})); - assert_eq!(drawn(&s).pixel(50, 50), GREEN, "the other fill paints over the replaced one"); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onsecond", "item": fill})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (RED, BLUE), "the first fill as it was, the second moved and blue"); + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#ff0000"), None)]), (b, vec![(paint("#0000ff"), None)])]); + // The same result for both fills: each is replaced. + let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.onsecond", true), &one)); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#0000ff"), None)]), (a, vec![(paint("#0000ff"), None)])]); +} + +/// On a stroke, a later painted effect runs on each stroke the first returns, with its own weight +/// and place among them, and keeps their order. +#[test] +fn a_later_painted_effect_runs_on_every_stroke_a_piece_returns() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"})); + let two = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#ff0000"), "width": 2}, {"paint": solid("#00ff00"), "width": 6}]}]}) + .to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "strokes": [{"paint": solid("#0000ff"), "width": 4}]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twostrokes", true), &two)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.onsecondstroke", true), tpl::Given::Params, r#""item":{"stroke":1}"#, &blue)); + let stroke = node(&s, id).appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.twostrokes", "plugin.test.engine.onsecondstroke"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": stroke})); + } + let stroked: Vec<_> = painted_paths(&s, id).into_iter().filter(|(_, items)| items.iter().any(|(_, w)| w.is_some())).collect(); + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(stroked, [(a, vec![(paint("#ff0000"), Some(2.0))]), (b, vec![(paint("#0000ff"), Some(4.0))])]); +} + +/// A third painted effect runs on every fill the second returned, for each fill the first returned. +#[test] +fn a_third_painted_effect_visits_every_fill_from_the_second() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let first = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let second = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff"), solid("#00ffff")]}]}).to_string(); + let third = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#ffff00")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.third.a", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.third.b", true), &second)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.third.c", true), tpl::Given::Params, r#""item":{"fill":1}"#, &third)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["a", "b", "c"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.test.engine.third.{effect}"), "item": fill})); + } + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + let pair = [(a, vec![(paint("#0000ff"), None)]), (b, vec![(paint("#ffff00"), None)])]; + assert_eq!(painted_paths(&s, id), [pair.clone(), pair].concat()); +} + +/// A later live effect that changes geometry only runs on each fill a painted effect returned, told +/// which fill it is on: on the second it moves the square to 80 pt, and the first stays at 40 pt. +#[test] +fn a_later_geometry_effect_uses_each_generated_fill_s_context() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let moved = json!({"objects": [{"path": square_at(80.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.geomfills", true), &two)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.geomsecond", false), tpl::Given::Params, r#""item":{"fill":1}"#, &moved)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.geomfills", "plugin.test.engine.geomsecond"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#ff0000"), None)]), (b, vec![(paint("#00ff00"), None)])]); +} + +/// A later Outline Stroke effect runs on each stroke a painted effect returned, with that stroke's +/// weight: the 2 pt stroke's outline is 22 pt wide and the 6 pt stroke's 26 pt, in their order. +#[test] +fn a_later_outline_stroke_uses_each_generated_stroke_s_weight() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"})); + let two = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#ff0000"), "width": 2}, {"paint": solid("#00ff00"), "width": 6}]}]}) + .to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.outlinestrokes", true), &two)); + let stroke = node(&s, id).appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.outlinestrokes", "item": stroke})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "path.outlineStroke", "params": {}, "item": stroke})); let st = s.doc().unwrap(); let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); - let mut paints = vec![]; + // Each outline is a compound path (the ring around the square): its width and its strokes. + let mut outlines = vec![]; art.walk(&mut |n| { - if n.path_data().is_some() { - paints.push(n.appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect::>()); + if let vectorcraft_doc::NodeKind::Compound { children, .. } = &n.kind { + let bounds = children.iter().filter_map(|c| c.path_data().and_then(PathData::bounds)).reduce(|a, b| a.union(b)); + let strokes: Vec<_> = n + .appearance + .items + .iter() + .filter_map(|i| match i { + vectorcraft_doc::AppearanceItem::Stroke(st) => Some((st.paint.clone(), st.width)), + vectorcraft_doc::AppearanceItem::Fill(_) => None, + }) + .collect(); + outlines.push((bounds.map(|b| (b.width() * 1000.0).round() / 1000.0), strokes)); } }); - let fill = |hex: &str| Paint::solid(Color::from_hex(hex).unwrap()); - assert!(paints.contains(&vec![fill("#0000ff"), fill("#00ff00")]), "{paints:?}"); + assert_eq!( + outlines, + [(Some(22.0), vec![(paint("#ff0000"), 2.0)]), (Some(26.0), vec![(paint("#00ff00"), 6.0)])], + "each stroke is outlined at its own weight" + ); +} + +/// A later painted effect's runs on the fills a piece returned share the effects' budget: each run +/// that fits gives all its pieces, and once the budget is spent the remaining fills stay together +/// on the piece's path, with none of their run's pieces. +#[test] +fn painted_fills_share_the_budget_without_partial_results() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let fills = vec![solid("#ff0000"); vectorcraft_plugins::objects::MAX_ITEMS]; + let first = json!({"objects": [{"path": square_at(40.0), "fills": fills}]}).to_string(); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let many = json!({"objects": vec![json!({"path": tiny}); 400]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.budget.fills", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.budget.many", true), &many)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.budget.fills", "plugin.test.engine.budget.many"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let paths = painted_paths(&s, id); + let tinies = paths.iter().filter(|(b, _)| *b == tiny.bounds()).count(); + let kept: Vec<_> = paths.iter().filter(|(b, _)| *b == square_at(40.0).bounds()).collect(); + assert_eq!(kept.len(), 1, "the fills left out stay together"); + let ran = vectorcraft_plugins::objects::MAX_ITEMS - kept[0].1.len(); + assert!(ran > 1 && !kept[0].1.is_empty(), "{ran} runs gave their pieces"); + assert_eq!(tinies, ran * 400, "every run that fit gave all its pieces"); + assert!(paths.len() <= vectorcraft_plugins::objects::MAX_OBJECTS, "{} paths", paths.len()); +} + +/// A later painted effect runs on each of 256 fills a painted effect returned on an 8,000-anchor +/// path, although 256 copies of that path would pass the effects' budget, when what it returns +/// fits. +#[test] +fn a_later_painted_effect_runs_on_many_fills_of_a_large_piece_when_its_results_fit() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let zigzag: Vec = (0..8_000).map(|i| Point::new(40.0 + f64::from(i) * 20.0 / 8_000.0, if i % 2 == 0 { 40.0 } else { 60.0 })).collect(); + let large = PathData::single(SubPath::polyline(&zigzag, true)); + let fills = vec![solid("#ff0000"); vectorcraft_plugins::objects::MAX_ITEMS]; + let first = json!({"objects": [{"path": large, "fills": fills}]}).to_string(); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let small = json!({"objects": [{"path": tiny}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.large.fills", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.large.small", true), &small)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.large.fills", "plugin.test.engine.large.small"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let paths = painted_paths(&s, id); + let tinies = paths.iter().filter(|(b, _)| *b == tiny.bounds()).count(); + assert_eq!(tinies, vectorcraft_plugins::objects::MAX_ITEMS, "every fill's run"); + assert!(paths.iter().all(|(b, _)| *b != large.bounds()), "no fill left on the large path"); +} + +/// An open path of `curves` tall curves, each drawn with about 5,200 lines. +fn tall_curves(curves: u32) -> PathData { + let h = 3_000_000.0; + let anchors = (0..=curves) + .map(|i| { + let x = 40.0 + f64::from(i); + vectorcraft_geom::Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + PathData::single(SubPath::new(anchors, false)) +} + +/// A later painted effect's run on the first of two fills a painted effect returned on a path that +/// takes most of the effects' budget gives its result when it and the second fill's result fit +/// together, although the result and another copy of the path would not. +#[test] +fn a_later_painted_run_counts_the_results_after_it_before_keeping_room_for_its_input() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let (large, result) = (tall_curves(1_000), tall_curves(600)); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let two = json!({"objects": [{"path": large, "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let on_first = json!({"objects": [{"path": result}]}).to_string(); + let on_second = json!({"objects": [{"path": tiny}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.tall.fills", true), &two)); + let second = painting("test.engine.tall.each", true); + install_wat(&mut s, &tpl::when_given_else(&second, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_second)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.tall.fills", "plugin.test.engine.tall.each"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let want = [(result.bounds(), vec![(paint("#ff0000"), None)]), (tiny.bounds(), vec![(paint("#00ff00"), None)])]; + assert_eq!(painted_paths(&s, id), want, "both runs give their results"); +} + +/// When the results of a later painted effect's runs on the two fills of a large path do not fit +/// together, and the first fill's result and another copy of the path do not fit either, the first +/// fill stays on the path and the second fill's result, which fits beside the path, is drawn. +#[test] +fn a_later_painted_run_that_did_not_fit_beside_an_earlier_result_fits_once_that_is_left_out() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let (large, first, second) = (tall_curves(577), tall_curves(1_058), tall_curves(770)); + let two = json!({"objects": [{"path": large, "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let on_first = json!({"objects": [{"path": first}]}).to_string(); + let on_second = json!({"objects": [{"path": second}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.refit.fills", true), &two)); + let each = painting("test.engine.refit.each", true); + install_wat(&mut s, &tpl::when_given_else(&each, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_second)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.refit.fills", "plugin.test.engine.refit.each"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let want = [(large.bounds(), vec![(paint("#ff0000"), None)]), (second.bounds(), vec![(paint("#00ff00"), None)])]; + assert_eq!(painted_paths(&s, id), want, "the second fill's result is drawn"); } /// A Transform effect before a painted effect on a fill moves the fill's placed gradient: a piece @@ -1183,6 +1422,39 @@ fn the_first_view_defers_plug_in_effects_past_its_budget() { assert_eq!(fx_c.take_report(), Some(0)); } +/// A later painted effect deferred on the fills a piece returned is listed once, at its place among +/// the object's fill's effects, and a refresh of the object runs it on each of them. +#[test] +fn deferred_runs_on_the_fills_a_piece_returned_keep_the_fill_s_stage() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.two", true), &two)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.later", true), &blue)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.deferred.two", "item": fill})); + // The first effect's result is kept from a drawing outside a view's budget. + assert_eq!(drawn(&s).pixel(50, 50), GREEN); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.deferred.later", "item": fill})); + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + let img = fx.view(|| drawn(&s)); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (GREEN, WHITE), "the fills on the first effect's piece"); + let places = run(&mut s, "effect.deferredStatus", json!({}))["effects"] + .as_array() + .unwrap() + .iter() + .map(|e| (e["objectId"].as_u64().unwrap(), e["item"].as_u64(), e["effectIndex"].as_u64().unwrap())) + .collect::>(); + assert_eq!(places, [(id.0, Some(fill as u64), 1)], "the fill's stage, listed once"); + step.store(0, std::sync::atomic::Ordering::Relaxed); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": [id.0]})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + let img = fx.view(|| drawn(&s)); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (WHITE, BLUE), "both fills replaced"); +} + /// Deferred stages are listed where their effects are: after an effect that changes no geometry, /// and on a fill after a Transform effect that reaches its appearance, each of two instances of /// one plug-in at its own place. @@ -2820,6 +3092,188 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { vectorcraft_plugins::registry::revoke(id); } +/// An install refused because another session revoked the grant its checks relied on writes +/// nothing: a store that kept the grant and lost the module gets no module back, its grants stay as +/// they were, and the plug-in installed with the id and the other session's revoke stand. `grant` +/// approves the module by itself. +#[test] +fn a_grant_revoked_while_installing_leaves_the_store_as_it_was() { + use vectorcraft_plugins::permissions::sha256_hex; + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-revoked-while-installing"); + let id = "test.engine.permissions.revokedwhileinstalling"; + let bytes = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&bytes); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + std::fs::remove_file(&module).unwrap(); + + crate::cmd::plugin::BEFORE_INSTALL.set(Some(Box::new(move || { + registry::revoke(id); + }))); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read"), "{e}"); + assert!(!module.exists(), "no module is stored"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grants stay as they were"); + assert!(registry::granted(id).is_none(), "the other session's revoke stands"); + assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&bytes)), "the installed plug-in stays"); + + crate::cmd::plugin::BEFORE_INSTALL.set(Some(Box::new(move || { + registry::revoke(id); + }))); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + assert_eq!(std::fs::read(&module).unwrap(), bytes, "a module `grant` approves is stored"); + assert!(registry::granted(id).is_some_and(|g| g.hash == sha256_hex(&bytes))); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A plug-in another session installs with the id while a module is checked takes the place the +/// checks found free. Without `replace`, or with `replace: false`, the install is refused, writes no +/// module and leaves that plug-in installed; with `replace: true` the module takes its place. +#[test] +fn a_plug_in_installed_while_installing_needs_replace() { + use vectorcraft_plugins::permissions::sha256_hex; + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-installed-while-installing"); + let id = "test.engine.installedwhileinstalling"; + let ours = asking(id, "[]", &json!({"objects": []})); + let theirs = asking(id, "[]", &json!({"objects": [], "documentData": null})); + let data = vectorcraft_format::base64_encode(&ours); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let installed = || registry::get(id).map(|p| p.hash().to_string()); + let meanwhile = |theirs: Vec| -> Option> { + Some(Box::new(move || { + registry::install_bytes(&theirs).unwrap(); + })) + }; + + for (replace, why) in [(Value::Null, "is another module than"), (json!(false), "is already installed")] { + crate::cmd::plugin::BEFORE_INSTALL.set(meanwhile(theirs.clone())); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "replace": replace})).unwrap_err().to_string(); + assert!(e.contains(why), "{replace}: {e}"); + assert!(!module.exists(), "{replace}: no module is stored"); + assert_eq!(installed(), Some(sha256_hex(&theirs)), "{replace}: the other plug-in stays installed"); + registry::remove(id); + } + + crate::cmd::plugin::BEFORE_INSTALL.set(meanwhile(theirs)); + run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true})); + assert_eq!(std::fs::read(&module).unwrap(), ours); + assert_eq!(installed(), Some(sha256_hex(&ours))); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Without a plug-in store, the install's approval comes from the grant it read first, the one it +/// checks again before it installs: a grant another session records after that read approves +/// nothing, so a revoke that follows it can't be undone by the install recording it again. +#[test] +fn a_grant_recorded_and_revoked_while_installing_approves_nothing() { + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; + use vectorcraft_plugins::registry; + let id = "test.engine.permissions.recordedwhileinstalling"; + let bytes = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&bytes); + let mut s = session(); + let g = Grant { id: id.into(), hash: sha256_hex(&bytes), permissions: vec![Permission::DocRead] }; + crate::cmd::plugin::AFTER_GRANT_READ.set(Some(Box::new(move || { + registry::grant(GrantToken::after_engine_approval(g)); + }))); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read"), "{e}"); + assert!(registry::get(id).is_none(), "nothing is installed"); + registry::revoke(id); +} + +/// A `plugin.remove` in another session while a module is installed waits until the install has +/// written the store, then removes the module, its grant and the plug-in: nothing of it stays in +/// the store for the next launch. +#[test] +fn a_removal_while_installing_leaves_nothing_in_the_store() { + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-removed-while-installing"); + let id = "test.engine.permissions.removedwhileinstalling"; + let bytes = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let data = vectorcraft_format::base64_encode(&bytes); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let grants = folder.0.join("grants.json"); + + let (said, heard) = std::sync::mpsc::channel::<&'static str>(); + let (handed, remover) = std::sync::mpsc::channel(); + let dir = folder.0.clone(); + crate::cmd::plugin::BEFORE_STORE.set(Some(Box::new(move || { + let thread = std::thread::spawn(move || { + let tell = said.clone(); + crate::cmd::plugin::WHILE_WAITING.set(Some(Box::new(move || { + let _ = tell.send("waiting"); + }))); + let mut other = session(); + other.attach_plugin_store(dir); + let removed = other.execute("plugin.remove", &json!({"id": id})).map(|_| ()).map_err(|e| e.to_string()); + let _ = said.send("removed"); + removed + }); + // The removal has finished, or waits for this install. + let first = heard.recv().unwrap_or("gone"); + let _ = handed.send((first, thread)); + }))); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]})); + let (first, thread) = remover.recv().unwrap(); + assert_eq!(thread.join().unwrap(), Ok(())); + assert!(registry::get(id).is_none() && registry::granted(id).is_none(), "the plug-in and its grant are gone"); + assert!(!module.exists(), "no module stays in the store"); + assert!(!std::fs::read_to_string(&grants).is_ok_and(|g| g.contains(id)), "no grant stays in the store"); + assert_eq!(first, "waiting", "the removal waits for the install"); +} + +/// Another session that attaches the plug-in store while a new version is installed loads the +/// stored plug-ins once the install has written the store: the version that runs is the one the +/// store keeps for the next launch. +#[test] +fn a_store_loaded_while_installing_loads_the_new_version() { + use vectorcraft_plugins::permissions::sha256_hex; + use vectorcraft_plugins::registry; + let folder = crate::tests_links::Folder::new("plugin-store-loaded-while-installing"); + let id = "test.engine.permissions.loadedwhileinstalling"; + let old = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let new = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "documentData": null})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&old), "grant": ["doc.read"]})); + + let (said, heard) = std::sync::mpsc::channel::<&'static str>(); + let (handed, loader) = std::sync::mpsc::channel(); + let dir = folder.0.clone(); + crate::cmd::plugin::BEFORE_STORE.set(Some(Box::new(move || { + let thread = std::thread::spawn(move || { + let tell = said.clone(); + crate::cmd::plugin::WHILE_WAITING.set(Some(Box::new(move || { + let _ = tell.send("waiting"); + }))); + session().attach_plugin_store(dir); + let _ = said.send("loaded"); + }); + // The load has finished, or waits for this install. + let first = heard.recv().unwrap_or("gone"); + let _ = handed.send((first, thread)); + }))); + let data = vectorcraft_format::base64_encode(&new); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"], "replace": true})); + let (first, thread) = loader.recv().unwrap(); + thread.join().unwrap(); + assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&new)), "the new version runs"); + assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), new, "the store keeps the new version"); + assert_eq!(first, "waiting", "the load waits for the install"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// Runs the test `name` of this module again in a process of its own → whether this is that /// process. The list of disabled plug-ins is kept for the whole process, and the other tests here /// read it again each time they attach a plug-in store. diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 14669fd357..3808d06797 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -753,6 +753,9 @@ fn precise_cursors_replace_every_pen_state() { Cursor::ShapeBuilderErase, Cursor::AnchorPoint, Cursor::Curvature, + Cursor::LivePaintBucket, + Cursor::LivePaintBucketFace, + Cursor::LivePaintBucketEdge, ] { assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); } @@ -2473,6 +2476,65 @@ fn deferred_commit_forwards_preview_warnings() { assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type"))), "{ui:?}"); } +/// A session with a rectangle selected, and type too with `with_type`, tool `tool` chosen, +/// Real-time Drawing off, and an interaction labeled `label` whose deferred Bloat preview warns +/// that type is skipped when type is selected. +fn deferred_bloat(label: &str, with_type: bool, tool: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let text = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap(); + let ids = if with_type { json!([rect["id"], text["id"]]) } else { json!([rect["id"]]) }; + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + s.select_tool(tool, ViewInfo::default()).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.begin_interaction(label).unwrap(); + let p = json!({"tool": "bloat", "points": [[290.0, 200.0]], "width": 200.0, "height": 200.0}); + s.doc_mut().unwrap().interaction.as_mut().unwrap().preview = Some(("object.liquify".into(), p)); + s +} + +/// A command that applies a deferred preview before it runs (Undo, Clear History) reports the +/// preview's warning in its result, as the tool's own commit reports it; the preview is its own +/// step, which Undo takes back. A preview without a warning adds none. +#[test] +fn settling_a_deferred_preview_keeps_its_warning() { + let mut s = deferred_bloat("Bloat", true, "selection"); + let before = s.doc().unwrap().history.undo.len(); + let r = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(r["undone"], "Bloat", "{r}"); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("type")), "{r}"); + assert_eq!(r["previewWarning"], r["warning"], "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before, "the preview was a step of its own, and Undo took it back"); + let r = s.execute("edit.redo", &json!({})).unwrap(); + assert!(r.get("warning").is_none() && r.get("previewWarning").is_none(), "nothing was settled: {r}"); + + let mut s = deferred_bloat("Bloat", true, "selection"); + let r = s.execute("edit.clearHistory", &json!({})).unwrap(); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("type")), "{r}"); + + let mut s = deferred_bloat("Bloat", false, "selection"); + let r = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(r, json!({"undone": "Bloat"})); +} + +/// Typing kept as its own step when a tool's drag begins, or when a Cmd press lends the Selection +/// tool, shows its applied preview's warning in the status bar. +#[test] +fn settling_typing_for_a_tool_shows_its_preview_warning() { + use vectorcraft_tools::{Action, Mods}; + let warned = |ui: &[UiRequest]| ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type"))); + let mut s = deferred_bloat("Typing", true, "selection"); + let ui = s.apply_actions(vec![Action::Begin("Move".into())]).unwrap(); + assert!(warned(&ui), "{ui:?}"); + + let mut s = deferred_bloat("Typing", true, "rectangle"); + let cmd = Mods { cmd: true, ..Default::default() }; + let ui = s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 380.0).with_mods(cmd), ViewInfo::default()).unwrap(); + assert!(warned(&ui), "{ui:?}"); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 380.0).with_mods(cmd), ViewInfo::default()).unwrap(); +} + /// File Handling › Use Low Resolution Proxy for Linked EPS (#394): off by default; toggling it does /// not replace full-resolution pixels already in the document. #[test] @@ -2736,3 +2798,33 @@ fn snap_to_glyph_options_read_set_and_reset() { s.execute("prefs.reset", &json!({})).unwrap(); assert_eq!(s.execute("view.snapToGlyph.options", &json!({})).unwrap(), all); } + +/// Pixel Snapping Options: `view.snapToPixel.options` reads and sets While Drawing, While Moving +/// and While Scaling, all on by default, kept as the `pixelSnap` preference group; bad keys and +/// values change nothing, preferences saved without the group read it as all on, and resetting the +/// preferences turns them all back on. +#[test] +fn pixel_snapping_options_read_set_and_reset() { + let mut s = Session::new(); + let all = json!({"drawing": true, "moving": true, "scaling": true}); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), all); + let set = json!({"drawing": true, "moving": false, "scaling": true}); + assert_eq!(s.execute("view.snapToPixel.options", &json!({"moving": false})).unwrap(), set); + assert_eq!(s.execute("prefs.get", &json!({"key": "pixelSnap"})).unwrap(), set); + assert!(!s.prefs.pixel_snap.moving && s.prefs.pixel_snap.drawing && s.prefs.pixel_snap.scaling); + for bad in [json!({"moving": 1}), json!({"rotating": false})] { + assert!(s.execute("view.snapToPixel.options", &bad).is_err(), "{bad}"); + assert!(s.execute("prefs.set", &json!({"key": "pixelSnap", "value": bad})).is_err(), "{bad}"); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), set, "{bad}"); + } + s.execute("prefs.set", &json!({"key": "pixelSnap", "value": {"scaling": false}})).unwrap(); + let both = json!({"drawing": true, "moving": false, "scaling": false}); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), both); + // Preferences written before the group existed, and a group that names only some options. + let older: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap(); + assert_eq!(older.pixel_snap, vectorcraft_tools::PixelSnap::default()); + let partial: Prefs = serde_json::from_value(json!({"pixelSnap": {"drawing": false}})).unwrap(); + assert_eq!(partial.pixel_snap, vectorcraft_tools::PixelSnap { drawing: false, moving: true, scaling: true }); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), all); +} diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 4707ae98fd..90e7822ef8 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -1348,3 +1348,159 @@ fn theme_picker_refuses_bad_params() { assert!(e.contains(msg), "{p}: {e}"); } } + +const RED: &str = "rgb 255 0 0"; +const PURPLE: &str = "rgb 128 0 204"; +const BLUE: &str = "rgb 0 0 255"; + +/// Two red rectangles, a purple one and a blue one, selected. +fn prominent_art(s: &mut Session) -> [NodeId; 4] { + let ids = [rect(s, json!("#ff0000")), rect(s, json!("#ff0000")), rect(s, json!("#8000cc")), rect(s, json!("#0000ff"))]; + select_all(s); + ids +} + +#[test] +fn weigh_gives_each_rows_share_of_the_uses() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": BLUE, "to": null, "tint": 50}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map})); + assert_eq!(r["shares"], json!([75.0, 25.0])); + assert_eq!( + r["map"], + json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": null, "tint": 50}]), + "rows as given, from as a list" + ); + // An excluded row has no share, and the others share the uses. + let excl = json!([{"from": [RED, PURPLE], "to": null}, {"from": [BLUE], "to": null, "exclude": true}]); + assert_eq!(run(&mut s, "recolor.weigh", json!({"map": excl}))["shares"], json!([100.0, 0.0])); + // An excluded row before another that lists the same colour doesn't take its uses. + let first = json!([{"from": [RED], "exclude": true}, {"from": [RED]}, {"from": [BLUE, PURPLE]}]); + assert_eq!(run(&mut s, "recolor.weigh", json!({"map": first}))["shares"], json!([0.0, 50.0, 50.0])); + // A current colour that no art uses counts nothing, and a use counts for the first row only. + let twice = json!({"map": [{"from": [RED, "#123456"]}, {"from": [RED, PURPLE, BLUE]}]}); + assert_eq!(run(&mut s, "recolor.weigh", twice)["shares"], json!([50.0, 50.0])); + // As recolor.apply matches them: an exact colour before a hex one, a row with a new colour first. + let exact = json!({"map": [{"from": ["#ff0000", PURPLE], "to": "#00ff00"}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", exact)["shares"], json!([25.0, 75.0])); + let recolored = json!({"map": [{"from": [RED, PURPLE]}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", recolored)["shares"], json!([25.0, 75.0])); +} + +#[test] +fn weigh_moves_colors_to_the_rows_weights_and_changes_nothing() { + let mut s = session(); + let [_, _, purple, blue] = prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"], json!([{"from": [RED], "to": "#00ff00"}, {"from": [BLUE, PURPLE], "to": "#ffff00"}])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + assert_eq!(fill(&s, purple).to_hex(), "#8000cc", "a query leaves the art alone"); + // Recoloring with the weighed rows gives the moved colour its new row's colour. + select_all(&mut s); + run(&mut s, "recolor.apply", json!({"map": r["map"]})); + assert_eq!(fill(&s, purple).to_hex(), "#ffff00"); + assert_eq!(fill(&s, blue).to_hex(), "#ffff00"); + // A row's first colour stays, however small its weight. + let mut s = session(); + prominent_art(&mut s); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [0, 1]})); + assert_eq!(r["map"][0]["from"], json!([RED])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + // Excluded rows keep their colours and take none; their weights are ignored. + let three = + json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE], "to": "#000000", "exclude": true}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": three, "weights": [1, 50, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); + // A row without a new colour recolors nothing, so it neither gives nor takes colours. + let keep = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE]}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": keep, "weights": [0, 1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); +} + +/// A colour that two rows list moves only where recolor.apply recolors it with the row it moved +/// to, so the shares describe the recolor. +#[test] +fn weigh_keeps_a_moved_color_from_rows_that_also_list_it() { + const GREEN: &str = "rgb 0 255 0"; + let mut s = session(); + let [_, _, purple, _] = prominent_art(&mut s); + rect(&mut s, json!("#00ff00")); + select_all(&mut s); + // The purple moves from the first row to the third; the second row's entry for it goes. + let map = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE, PURPLE], "to": "#ffffff"}, {"from": [GREEN], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [2, 1, 2]})); + assert_eq!(r["map"][0]["from"], json!([RED])); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!([GREEN, PURPLE])); + assert_eq!(r["shares"], json!([40.0, 20.0, 40.0])); + run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"})); + assert_eq!(fill(&s, purple).to_hex(), "#ffff00", "the purple takes the row it moved to"); + // A colour that another row lists first stays: that row would take it back. + let mut s = session(); + let [_, _, purple, _] = prominent_art(&mut s); + rect(&mut s, json!("#00ff00")); + select_all(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [PURPLE, BLUE], "to": "#ffffff"}, {"from": [GREEN], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [2, 1, 2]})); + assert_eq!(r["map"][0]["from"], json!([RED, PURPLE])); + assert_eq!(r["map"][1]["from"], json!([PURPLE, BLUE])); + assert_eq!(r["shares"], json!([60.0, 20.0, 20.0])); + run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"})); + assert_eq!(fill(&s, purple).to_hex(), "#000000", "the purple keeps the first row's colour"); +} + +#[test] +fn weigh_moves_the_tints_of_a_global_swatch_together() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 90, "k": 0}, "global": true})); + rect(&mut s, json!("#ff0000")); + let full = rect(&mut s, json!("#00ff00")); + let tint = rect(&mut s, json!("#00ff00")); + rect(&mut s, json!("#0000ff")); + run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": "Brand"})); + run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": "Brand", "tint": 50})); + select_all(&mut s); + let brand: Vec = run(&mut s, "recolor.colors", json!({}))["colors"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["swatch"] == "Brand") + .map(|c| c["key"].clone()) + .collect(); + assert_eq!(brand.len(), 2); + let map = json!([{"from": [RED, brand[0], brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + // Even weights would take one tint alone, which would split the swatch: nothing moves. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + // The swatch's two colours move to the blue row as one. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 3]})); + assert_eq!(r["map"][1]["from"], json!([BLUE, brand[0], brand[1]])); + assert_eq!(r["shares"], json!([25.0, 75.0])); + // Colours that stay keep their order, a swatch's colours with another between them included. + let split = json!([{"from": [brand[0], RED, brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": split, "weights": [3, 1]})); + assert_eq!(r["map"][0]["from"], json!([brand[0], RED, brand[1]])); +} + +#[test] +fn weigh_refuses_bad_weights() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED]}, {"from": [BLUE], "exclude": true}]); + for (p, msg) in [ + (json!({"map": map, "weights": [1]}), "`weights` must be a list of 2 numbers, one per row"), + (json!({"map": map, "weights": "1, 1"}), "`weights` must be a list of 2 numbers"), + (json!({"map": map, "weights": [1, -1]}), "each weight must be a number of 0 or more (got -1)"), + (json!({"map": map, "weights": [1, "2"]}), "each weight must be a number of 0 or more"), + (json!({"map": map, "weights": [0, 5]}), "at least one row that isn't excluded needs a weight above 0"), + (json!({"weights": [1, 1]}), "missing `map`"), + (json!({"map": [{"to": "#ff0000"}]}), "each row needs `from`"), + ] { + let e = s.execute("recolor.weigh", &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } +} diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 5edd0d7fbd..99febca6b2 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -789,3 +789,29 @@ fn units_and_ruler_guides_survive_save_and_reopen() { } let _ = std::fs::remove_dir_all(d); } + +/// A ruler guide hidden or locked on its own (the Layers panel's eye and lock on its row, +/// `layer.setProps` on its node) stays so through Save, Close and Open, and the other guide stays +/// visible and unlocked. +#[test] +fn a_guide_hidden_or_locked_on_its_own_survives_save_and_reopen() { + let d = dir("guide-flags"); + for ext in ["vectorcraft", "pdf", "ai", "eps"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + for pos in [10, 20, 30] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + rect(&mut s); + let ids: Vec = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect(); + s.execute("layer.setProps", &json!({"ids": [ids[1]], "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [ids[2]], "locked": true})).unwrap(); + let p = path(&d, &format!("a.{ext}")); + s.execute("document.save", &json!({"path": p})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + s.execute("document.open", &json!({"path": p})).unwrap(); + let flags: Vec<_> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.pos, g.hidden, g.locked)).collect(); + assert_eq!(flags, [(10.0, false, false), (20.0, true, false), (30.0, false, true)], "{ext}"); + } + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index 57ff9b15cd..849d5b9080 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -63,7 +63,7 @@ fn each_member_has_its_value() { ("$.memCache", json!(0)), ("$.os", json!(os)), ("$.screens.length", json!(0)), - ("$.stack", json!("[global]:1\n")), + ("$.stack", json!("[global]@t.js:1\n")), ("$.strict", json!(false)), ("$.version", json!(version)), ("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")), @@ -165,7 +165,7 @@ fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() { fn error_line_and_stack_follow_the_running_code() { let mut s = Session::new(); let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());"; - assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true])); + assert_eq!(logged(&mut s, source)[0], json!([3, "[global]@t.js:5\nwhere@t.js:3\n", true])); assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one"); } diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 7e768c5875..a25b0489ba 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -202,6 +202,52 @@ fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() { let _ = std::fs::remove_dir_all(base); } +/// `$.fileName` in code an earlier run of a named engine kept names the file that code is in: that +/// run's script, a file it included or a file `$.evalFile` gave, not the file of the run that calls +/// it. Code from a script given as text, and code whose place the engine no longer keeps, has no +/// name; a dropped engine takes its functions with it. +#[test] +fn a_kept_function_reads_the_file_name_of_the_file_it_is_in() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-file-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for dir in ["a", "b"] { + std::fs::create_dir_all(base.join(dir)).unwrap(); + } + std::fs::write(base.join("a/inc.jsxinc"), "function fromInclude() { return $.fileName; }").unwrap(); + std::fs::write(base.join("a/ev.jsxinc"), "\nfunction fromEval() { return $.fileName; }").unwrap(); + let def = base.join("a/def.jsx"); + std::fs::write(&def, "#targetengine e\nfunction where() { return $.fileName; }\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');").unwrap(); + let caller = base.join("b/caller.jsx"); + let call = |s: &mut Session, text: &str| -> Vec { + std::fs::write(&caller, text).unwrap(); + let r = s.execute("script.run", &json!({"path": caller.to_string_lossy()})).unwrap(); + r["log"][0].as_array().unwrap().iter().map(|v| v.as_str().unwrap().replace('\\', "/")).collect() + }; + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + let from_file = call(&mut s, "#targetengine e\nvc.log([where(), fromInclude(), fromEval(), $.fileName]);"); + let from_text = log(&mut s, "#targetengine e\nvc.log([where(), $.fileName, eval('where()'), eval('$.fileName')]);"); + run(&mut s, "#targetengine e\nfunction fromText() { return $.fileName; }").unwrap(); + let text_kept = call(&mut s, "#targetengine e\nvc.log([fromText(), $.fileName]);"); + // Each run's own place takes the room of the ones before, so def.jsx's places go. + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300))); + for _ in 0..20 { + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + } + let evicted = call(&mut s, "#targetengine e\nvc.log([where(), fromEval(), $.fileName]);"); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + s.execute("script.engine.drop", &json!({"name": "e"})).unwrap(); + let dropped = log(&mut s, "#targetengine e\nvc.log(typeof where);"); + let _ = std::fs::remove_dir_all(&base); + let ends = |names: &[String], ends: &[&str]| names.len() == ends.len() && names.iter().zip(ends).all(|(n, e)| n.ends_with(e)); + assert!(ends(&from_file, &["a/def.jsx", "a/inc.jsxinc", "a/ev.jsxinc", "b/caller.jsx"]), "{from_file:?}"); + let shown: Vec = from_text[0].as_array().unwrap().iter().map(|v| v.as_str().unwrap().replace('\\', "/")).collect(); + assert!(ends(&shown, &["a/def.jsx", "", "a/def.jsx", ""]) && shown[1].is_empty() && shown[3].is_empty(), "{shown:?}"); + assert!(text_kept[0].is_empty() && text_kept[1].ends_with("b/caller.jsx"), "{text_kept:?}"); + assert!(evicted[0].is_empty() && evicted[1].is_empty() && evicted[2].ends_with("b/caller.jsx"), "{evicted:?}"); + assert_eq!(dropped, json!(["undefined"])); +} + /// A named engine keeps the places of its earlier runs' code within its budget, the oldest going /// first, and the code still runs once its place is gone. #[test] @@ -230,6 +276,44 @@ fn an_engine_keeps_the_newest_places_within_its_budget() { assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept"); } +/// `$.line` and `$.stack` in code an earlier run of a named engine compiled give the line of that +/// run's file: its script, or a file it included; a script given as text is named by that run's +/// name. Code `eval` and `Function` compiled keeps its own line, in `[eval]`, and code whose place +/// the engine did not keep is at line 0 of `[unknown]`. +#[test] +fn the_line_and_the_stack_of_code_an_earlier_run_kept_are_at_that_runs_files() { + use super::script_include::short_stack; + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-line-stack-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("inc.jsxinc"), "\n\nfunction fromInclude() {\n return [$.line, $.stack];\n}").unwrap(); + let def = base.join("def.jsx"); + std::fs::write(&def, "#targetengine e\nvar pad = 1;\n#include \"inc.jsxinc\"\nfunction fromMain() {\n return fromInclude();\n}").unwrap(); + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + run(&mut s, "#targetengine e\n\nfunction fromText() {\n return $.stack;\n}").unwrap(); + let call = |s: &mut Session, code: &str| { + let r = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"})); + r.unwrap_or_else(|e| panic!("{code}: {e}"))["log"][0].clone() + }; + let r = call(&mut s, "vc.log(fromMain());"); + assert_eq!(r[0], 4, "{r}"); + assert_eq!(short_stack(&r[1]), "[global]@caller.js:3\nfromMain@def.jsx:5\nfromInclude@inc.jsxinc:4\n"); + assert_eq!(short_stack(&call(&mut s, "vc.log(fromText());")), "[global]@caller.js:3\nfromText@t.js:4\n"); + assert_eq!(call(&mut s, "vc.log(eval('\\n\\n$.line'));"), 3); + assert_eq!(call(&mut s, "vc.log(eval('\\n$.stack'));"), "[global]@caller.js:3\n[global]@[eval]:2\n"); + let r = call(&mut s, "vc.log(new Function('\\nreturn $.stack;')());"); + assert_eq!(r, "[global]@caller.js:3\n[anonymous]@[eval]:4\n", "the line of the function source `Function` compiles"); + // A map larger than the budget is not kept. + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1))); + let lost = run(&mut s, "#targetengine e\n\n\nfunction lost() {\n return [$.line, $.stack];\n}"); + let r = lost.map(|_| call(&mut s, "vc.log(lost());")); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + let r = r.unwrap(); + assert_eq!(r, json!([0, "[global]@caller.js:3\nlost@[unknown]:0\n"])); + let _ = std::fs::remove_dir_all(base); +} + /// A run of a named engine whose script does not compile defines no code, so it keeps no place /// and pushes out none of the places kept before. An included file without a preprocessor line is /// first parsed with the script it joins, so its syntax error ends the run as it starts. diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index e5acc536cc..1ad659cfdd 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -374,6 +374,48 @@ fn script_eval_file_returns_last_value_and_shares_globals() { let _ = std::fs::remove_dir_all(base); } +/// `$.fileName` names the file the code reading it is in: a function `$.evalFile` gave names its +/// file after the file has returned, through nested files, and code an included file inserted names +/// that file. Code that `eval` compiled names the file being run. +#[test] +fn script_file_name_names_the_file_of_the_code_that_reads_it() { + let base = folder("filename"); + write(&base.join("main/inc.jsxinc"), "function fromInclude() { return $.fileName; }\nvar includedTop = $.fileName;"); + write( + &base.join("main/sub/outer.jsx"), + "function fromOuter() { return $.fileName; }\nvar before = $.fileName;\n$.evalFile('inner.jsx');\nvar after = $.fileName;\nfromInner();", + ); + write(&base.join("main/sub/inner.jsx"), "function fromInner() { return $.fileName; }\n$.fileName;"); + let r = run( + &base, + "#include \"inc.jsxinc\"\nvar nested = $.evalFile('sub/outer.jsx');\n\ + vc.log([fromInclude(), includedTop, before, after, nested, fromOuter(), fromInner(), $.fileName, eval('$.fileName')]);", + ) + .unwrap(); + let names: Vec = r["log"][0].as_array().unwrap().iter().map(|v| v.as_str().unwrap().replace('\\', "/")).collect(); + let ends = [ + "main/inc.jsxinc", + "main/inc.jsxinc", + "sub/outer.jsx", + "sub/outer.jsx", + "sub/inner.jsx", + "sub/outer.jsx", + "sub/inner.jsx", + "main/main.jsx", + "main/main.jsx", + ]; + assert_eq!(names.len(), ends.len(), "{names:?}"); + for (name, end) in names.iter().zip(ends) { + assert!(name.ends_with(end), "{name} ends with {end}: {names:?}"); + } + // A script given as text has no name, and neither have the functions it defines. + let r = Session::new() + .execute("script.run", &json!({"source": "function f() { return $.fileName; }\nvc.log([f(), $.fileName]);", "name": "t.js"})) + .unwrap(); + assert_eq!(r["log"], json!([["", ""]])); + let _ = std::fs::remove_dir_all(base); +} + #[test] fn script_eval_file_outside_scope_refused() { let base = folder("evaloutside"); @@ -454,6 +496,49 @@ fn script_included_error_has_local_file_and_line() { let _ = std::fs::remove_dir_all(base); } +/// `$.stack`'s text with each frame's file cut to its name: `function@name:line` a line. +pub(crate) fn short_stack(stack: &Value) -> String { + let stack = stack.as_str().unwrap_or_else(|| panic!("a stack: {stack}")); + stack + .lines() + .map(|frame| match (frame.split_once('@'), frame.rsplit_once(':')) { + (Some((name, _)), Some((place, line))) => { + let file = place.split_once('@').map_or(place, |(_, f)| f).rsplit(['/', '\\']).next().unwrap_or_default(); + format!("{name}@{file}:{line}\n") + } + _ => format!("{frame}\n"), + }) + .collect() +} + +/// `$.line` and `$.stack` give the line of the file the running code is in: the script, a file it +/// included (and a file that one included) or a file `$.evalFile` gave and the files it included, +/// whatever lines come before it in the run's expanded source. +#[test] +fn script_line_and_stack_are_at_the_line_of_each_file() { + let base = folder("line-stack"); + write( + &base.join("main/lib.jsxinc"), + "\nfunction libWhere() {\n return [$.line, $.stack];\n}\nvar libLine = $.line;\n#include \"deep.jsxinc\"\n", + ); + write(&base.join("main/deep.jsxinc"), "\n\nvar deepLine = $.line;\nfunction deepStack() {\n return $.stack;\n}"); + write(&base.join("main/sub/ev.jsx"), "#include \"inc.jsxinc\"\nfunction evWhere() {\n return incWhere();\n}\nevWhere();"); + write(&base.join("main/sub/inc.jsxinc"), "\n\nfunction incWhere() {\n return [$.line, $.stack];\n}"); + let text = "var a = 1;\nvar b = 2;\n#include \"lib.jsxinc\"\nvar mainLine = $.line;\nvc.log([libLine, deepLine, mainLine]);\n\ + vc.log(libWhere());\nvc.log(deepStack());\nvc.log($.evalFile('sub/ev.jsx'));"; + let log = run(&base, text).unwrap()["log"].clone(); + assert_eq!(log[0], json!([5, 3, 4]), "{log}"); + assert_eq!(log[1][0], 3, "{log}"); + assert_eq!(short_stack(&log[1][1]), "[global]@main.jsx:6\nlibWhere@lib.jsxinc:3\n"); + assert_eq!(short_stack(&log[2]), "[global]@main.jsx:7\ndeepStack@deep.jsxinc:5\n"); + assert_eq!(log[3][0], 4, "{log}"); + assert_eq!(short_stack(&log[3][1]), "[global]@main.jsx:8\n[global]@ev.jsx:5\nevWhere@ev.jsx:3\nincWhere@inc.jsxinc:4\n"); + // The file a frame names is the one `$.fileName` gives while that file runs. + let log = run(&base, "vc.log($.stack.slice(9, -3) === $.fileName);").unwrap()["log"].clone(); + assert_eq!(log, json!([true])); + let _ = std::fs::remove_dir_all(base); +} + /// Pumps the run in the app until it ends or waits at a dialog (`None`). fn pump(s: &mut Session) -> Option> { for _ in 0..10_000 { diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index ad1549140f..0452251a09 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -25,7 +25,8 @@ pub struct ViewInfo { /// View → Pixel Preview: while it is on, the tools use no Smart Guides. pub pixel_preview: bool, pub show_bbox: bool, - /// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi). + /// View → Snap to Pixel: drawing, moving and scaling land on whole pixels (points at 72 ppi), + /// each while its Pixel Snapping option (`view.snapToPixel.options`) is on. pub snap_to_pixel: bool, /// View → Snap to Point: picked points (a transform's reference point) land on anchors. pub snap_to_point: bool, @@ -75,6 +76,15 @@ fn nine_slice_guide_preview(cmd: &str, p: &Value) -> bool { && p.as_object().is_some_and(|m| m.contains_key("nineSliceGuides") && m.keys().all(|k| k == "name" || k == "nineSliceGuides")) } +/// `ui` with a status message first for each of `warnings` (which came before its requests) that it +/// doesn't already show. +fn with_status(mut ui: Vec, warnings: Vec) -> Vec { + let new: Vec = + warnings.into_iter().filter(|w| !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w))).map(UiRequest::Status).collect(); + ui.splice(0..0, new); + ui +} + /// Requests from tools that only the frontend can fulfil. #[derive(Clone, Debug, PartialEq)] pub enum UiRequest { @@ -227,24 +237,28 @@ impl Session { /// A press with Cmd held (Ctrl elsewhere) and any other tool than a selection tool: the /// selection tool [`Self::lent_tool`] takes the gesture, pressing without Cmd (a plain click, - /// not Select Behind). The tool lent to stays as it was, unaware, until the release. - fn lend_selection_tool(&mut self, ev: &PointerEvent) -> PointerEvent { + /// not Select Behind). The tool lent to stays as it was, unaware, until the release. → the event + /// for the tool, and the warnings of a preview that keeping the typing applied. + fn lend_selection_tool(&mut self, ev: &PointerEvent) -> Result<(PointerEvent, Vec)> { if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() { - return *ev; + return Ok((*ev, vec![])); } - let Some(id) = self.lent_tool(self.tool.id(), &ev.mods) else { return *ev }; + let Some(id) = self.lent_tool(self.tool.id(), &ev.mods) else { return Ok((*ev, vec![])) }; // Typing in progress is kept as its own step first: the lent tool's drag previews from the - // document its interaction started from, which would drop the typing (#1881). + // document its interaction started from, which would drop the typing (#1881). When it + // can't be kept, the press is not lent. + let mut settled = vec![]; if crate::cmd::typing_in_progress(self) { - // Fails only without a document, and then there is no typing to keep. - let _ = crate::cmd::settle_interaction(self); + let (r, warnings) = self.collect_settled_warnings(crate::cmd::settle_interaction); + r?; + settled = warnings; } // The Selection and Group Selection tools act on the instance whole. self.select_whole_instance(id); let lent = self.make_tool(id); self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone())); self.lender = Some(std::mem::replace(&mut self.tool, lent)); - PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev } + Ok((PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }, settled)) } /// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the @@ -355,6 +369,7 @@ impl Session { // Snap to Grid and Snap to Pixel are exclusive, as in Illustrator; left both on, the grid // wins instead of pixels quietly rounding every drag to whole points (#1816). snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid, + pixel_snap: self.prefs.pixel_snap, snap_to_point: view.snap_to_point, snap_tangent: self.prefs.snap_tangent, snap_perpendicular: self.prefs.snap_perpendicular, @@ -659,7 +674,8 @@ impl Session { } self.leave_draw_inside(ev, view)?; self.tell_tool_history_moved(); - let ev = &self.lend_selection_tool(ev); + let (ev, settled) = self.lend_selection_tool(ev)?; + let ev = &ev; let lent = self.lender.is_some(); let mut acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); if !keep { @@ -670,7 +686,7 @@ impl Session { if keep && ev.kind == PointerKind::Up { self.keep_tool_settings(); } - self.apply_actions(acts) + self.apply_actions(acts).map(|ui| with_status(ui, settled)) }); // The tool's own edits do not move the history for it. A selection tool that Cmd lends // edits for the lender, which hears of the edit at its next event. @@ -1375,7 +1391,14 @@ impl Session { /// Apply tool actions. Errors from previews are reported but keep the interaction alive. pub fn apply_actions(&mut self, acts: Vec) -> Result> { let outer = std::mem::replace(&mut self.in_tool_actions, true); - let r = self.apply_tool_actions(acts); + // A preview that settling applied (typing kept as its own step before a drag begins) shows + // its warning in the status bar, as the tool's own commit shows its preview's. + let r = if !outer && self.depth == 0 { + let (r, settled) = self.collect_settled_warnings(|s| s.apply_tool_actions(acts)); + r.map(|ui| with_status(ui, settled)) + } else { + self.apply_tool_actions(acts) + }; self.in_tool_actions = outer; if !outer && self.depth == 0 { self.trim_histories(); diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index 6c7e90a188..fe56377d4e 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -180,6 +180,106 @@ fn a_batch_converts_through_the_profile_it_assigns() { assert_eq!(converted, fill(&device, b)); } +/// The profiles a document is assigned are in use while it is in front: a new or opened document +/// assigned none draws through the working spaces, switching back to the assigned document puts its +/// profile in use again, and closing a document puts the profiles of the document left in front, or +/// with none open the working spaces, in use. +#[test] +fn assigned_profiles_follow_the_front_document() { + let _g = settings(); + let dir = fresh_dir("front-profiles"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let tagged = dir.join("tagged.vectorcraft"); + let mut s = session(); + let working = cms::working_settings(); + assert_ne!(working.cmyk, cms::DEVICE_CMYK); + exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK})); + exec(&mut s, "file.saveAs", json!({"path": tagged.to_str().unwrap()})); + exec(&mut s, "file.new", json!({"width": 100, "height": 100})); + assert_eq!(cms::active_settings(), working, "a new document draws through the working spaces"); + exec(&mut s, "document.activate", json!({"index": 0})); + assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK, "back in front, the document draws through its profile"); + assert!(s.set_active(1)); + assert_eq!(cms::active_settings(), working, "a tab switch to the untagged document"); + exec(&mut s, "file.close", json!({})); + assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK, "the document left in front draws through its profile"); + exec(&mut s, "file.close", json!({})); + assert_eq!(cms::active_settings(), working, "no document open: the working spaces"); + exec(&mut s, "document.open", json!({"path": tagged.to_str().unwrap()})); + assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK, "an opened document draws through its saved profile"); +} + +/// Undo and Redo of Assign Profile, and File › Revert to a file assigned no profile, put the +/// profiles the document has then in use. +#[test] +fn undo_redo_and_revert_put_the_document_profiles_in_use() { + let _g = settings(); + let dir = fresh_dir("undo-profiles"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let untagged = dir.join("untagged.vectorcraft"); + let mut s = session(); + let working = cms::working_settings(); + exec(&mut s, "file.saveAs", json!({"path": untagged.to_str().unwrap()})); + exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK})); + assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK); + exec(&mut s, "edit.undo", json!({})); + assert_eq!(doc_profiles(&s.doc().unwrap().doc), (None, None)); + assert_eq!(cms::active_settings(), working, "undone: the working spaces"); + exec(&mut s, "edit.redo", json!({})); + assert_eq!(doc_profiles(&s.doc().unwrap().doc), (None, Some(cms::DEVICE_CMYK.to_string()))); + assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK, "redone: the assigned profile"); + exec(&mut s, "file.revert", json!({})); + assert_eq!(doc_profiles(&s.doc().unwrap().doc), (None, None)); + assert_eq!(cms::active_settings(), working, "reverted: the working spaces"); +} + +/// A batch that assigns a profile and then fails puts the document back without it, and the +/// working spaces back in use. +#[test] +fn a_failed_batch_puts_the_profiles_in_use_back() { + let _g = settings(); + let _reset = ColorSettingsReset { profile: None, paths: vec![] }; + let mut s = session(); + let working = cms::working_settings(); + let steps = json!([ + {"command": "edit.assignProfile", "params": {"cmyk": cms::DEVICE_CMYK}}, + {"command": "document.activate", "params": {"index": 99}}, + ]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert_eq!(doc_profiles(&s.doc().unwrap().doc), (None, None)); + assert_eq!(cms::active_settings(), working); +} + +/// A batch step that switches documents makes the next step convert through the profiles of the +/// document it switched to, not those of the document the batch began in. +#[test] +fn a_batch_converts_the_document_it_switches_to_through_its_profiles() { + let _g = settings(); + let _reset = ColorSettingsReset { profile: None, paths: vec![] }; + let blue = |s: &mut Session| { + let id = rect(s, 10.0, 10.0, 50.0, 50.0); + exec(s, "paint.setFill", json!({"ids": [id.0], "color": "#3366cc"})); + id + }; + let fill = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color(); + let mut working = session(); + let w = blue(&mut working); + exec(&mut working, "file.documentColorMode", json!({"mode": "cmyk"})); + let mut s = session(); + let b = blue(&mut s); + exec(&mut s, "file.new", json!({"width": 100, "height": 100})); + exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK})); + let steps = json!([ + {"command": "document.activate", "params": {"index": 0}}, + {"command": "file.documentColorMode", "params": {"mode": "cmyk"}}, + ]); + exec(&mut s, "command.batch", json!({"commands": steps})); + assert_eq!(s.active_index(), Some(0)); + assert_eq!(fill(&s, b), fill(&working, w), "converted through the working CMYK space"); +} + /// View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in a /// document assigned another CMYK profile, in Proof Colors and in Separations Preview, and a change /// of the working CMYK space shows in the next proof while the document keeps its profile. diff --git a/crates/engine/tests/it/delete_path_pieces.rs b/crates/engine/tests/it/delete_path_pieces.rs index 0d8b7171f7..afc304e99f 100644 --- a/crates/engine/tests/it/delete_path_pieces.rs +++ b/crates/engine/tests/it/delete_path_pieces.rs @@ -2,7 +2,7 @@ use serde_json::json; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, NodeId, NodeKind, selection::Selection}; use vectorcraft_engine::Session; use vectorcraft_geom::{Affine, Anchor, PathData, Rect, SubPath}; use vectorcraft_testkit::{invariants::check_session, raster::render_artboard}; @@ -226,6 +226,151 @@ fn deletion_split_preserves_gradient_placement() { } } +/// Subpath `i` of a multi-subpath test path: four cubic anchors on their own row, so every piece +/// that survives a deletion can be matched exactly. +fn row(i: usize, closed: bool) -> SubPath { + let y = 10.0 + 3.0 * i as f64; + let anchors = (0..4) + .map(|k| { + let mut a = Anchor::corner((10.0 + 20.0 * f64::from(k), y).into()); + a.h_in = a.p + (-3.0, -1.0); + a.h_out = a.p + (3.0, 1.0); + a + }) + .collect(); + SubPath::new(anchors, closed) +} + +/// An open piece made of `indexes` of `sp`, its unused end handles retracted as deletion leaves them. +fn piece(sp: &SubPath, indexes: &[usize]) -> SubPath { + let mut run: Vec = indexes.iter().map(|&i| sp.anchors[i]).collect(); + if let Some(first) = run.first_mut() { + first.h_in = first.p; + } + if let Some(last) = run.last_mut() { + last.h_out = last.p; + } + SubPath::new(run, false) +} + +/// Put `subpaths` into a new path object and return its ID. +fn multi_subpath_path(s: &mut Session, subpaths: Vec) -> NodeId { + let id = rectangle(s); + *std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap() = PathData::new(subpaths); + id +} + +/// Run `command` on the current selection, check the path's surviving subpaths and that one Undo +/// restores the document and `before_selection` and one Redo repeats the edit, then check that a +/// second Clear removes the survivors. +fn delete_and_check(s: &mut Session, command: &str, id: NodeId, expected: &[SubPath], before_selection: &Selection) { + let before_doc = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let history = s.doc().unwrap().history.undo.len(); + s.execute(command, &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.node(id).unwrap().path_data().unwrap().subpaths, expected, "{command}"); + assert_eq!(st.selection.objects, [id], "{command}: a multi-subpath path keeps its object"); + assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty()); + assert_eq!(st.history.undo.len(), history + 1); + let after_doc = serde_json::to_value(&st.doc).unwrap(); + let after_selection = st.selection.clone(); + check_session(s).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before_doc); + assert_eq!(&s.doc().unwrap().selection, before_selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), after_doc); + assert_eq!(s.doc().unwrap().selection, after_selection); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none(), "{command}: the second Clear removes the survivors"); +} + +/// Selected anchors on many subpaths each remove only their own subpath's anchors; anchors named on +/// a subpath or index the path does not have remove nothing. +#[test] +fn deleting_anchors_on_many_subpaths_removes_each_subpaths_own_anchors() { + for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [1, 2], [2, 0], [2, 9], [4, 3], [5, 1], [7, 1]]})).unwrap(); + // Subpath 3 has no selected anchor and stays closed and unchanged. + let expected = [ + piece(&subpaths[0], &[2, 3]), + piece(&subpaths[1], &[3, 0, 1]), + piece(&subpaths[2], &[1, 2, 3]), + subpaths[3].clone(), + piece(&subpaths[4], &[0, 1, 2]), + piece(&subpaths[5], &[2, 3, 0]), + ]; + let before = s.doc().unwrap().selection.clone(); + delete_and_check(&mut s, command, id, &expected, &before); + } +} + +/// Selected segments on many subpaths each cut only their own subpath; a segment reference to a +/// subpath or segment the path does not have cuts nothing. +#[test] +fn deleting_segments_on_many_subpaths_cuts_each_subpaths_own_segments() { + for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1], [1, 3], [4, 0], [5, 1]]})).unwrap(); + // Undo prunes the restored selection (`Selection::prune`), which drops references to + // segments the path does not have, so it restores the selection as it is here. + let before = s.doc().unwrap().selection.clone(); + // `select.segments` refuses references to missing segments, so these are added directly. + let refs = s.doc_mut().unwrap().selection.segments.get_mut(&id).unwrap(); + refs.extend([(2, 9), (7, 0)]); + let expected = [ + piece(&subpaths[0], &[0, 1]), + piece(&subpaths[0], &[2, 3]), + piece(&subpaths[1], &[0, 1, 2, 3]), + subpaths[2].clone(), + subpaths[3].clone(), + piece(&subpaths[4], &[1, 2, 3]), + piece(&subpaths[5], &[2, 3, 0, 1]), + ]; + delete_and_check(&mut s, command, id, &expected, &before); + } +} + +/// One selected interior anchor on each of many subpaths removes exactly that anchor from each, +/// keeping the subpaths in their original order. +#[test] +fn deleting_one_anchor_on_each_of_many_subpaths_keeps_subpath_order() { + let mut s = session(); + let subpaths: Vec = (0..400).map(|i| row(i, false)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + let refs: Vec<[usize; 2]> = (0..400).filter(|i| i % 7 != 3).map(|i| [i, 1]).collect(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": refs})).unwrap(); + let expected: Vec = subpaths.iter().enumerate().map(|(i, sp)| if i % 7 == 3 { sp.clone() } else { piece(sp, &[2, 3]) }).collect(); + let before = s.doc().unwrap().selection.clone(); + delete_and_check(&mut s, "edit.clear", id, &expected, &before); +} + +/// Cut Path cuts each subpath of a multi-subpath path only at that subpath's selected anchors. +#[test] +fn cutting_at_anchors_on_many_subpaths_cuts_each_subpath_at_its_own_anchors() { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + let refs = [(0, 1), (1, 2), (2, 9), (4, 2), (5, 0), (5, 2), (7, 1)]; + s.execute("select.anchors", &json!({"id": id.0, "anchors": refs.map(|(a, b)| [a, b])})).unwrap(); + s.execute("path.cutAtAnchors", &json!({})).unwrap(); + let expected: Vec = subpaths + .iter() + .enumerate() + .flat_map(|(si, sp)| sp.cut_at(&refs.iter().filter(|r| r.0 == si).map(|r| r.1).collect::>())) + .collect(); + // Subpaths 0 and 4 become two pieces each, closed subpath 1 opens into one, closed subpath 5 + // becomes two, and subpaths 2 and 3 stay as they were. + assert_eq!(expected.len(), 9); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths, expected); + check_session(&s).unwrap(); +} + /// A connected remainder keeps automatic fitting; pinning is needed only for separate objects. #[test] fn deleting_one_segment_keeps_unplaced_gradients() { diff --git a/crates/engine/tests/it/ruler_guide_nodes.rs b/crates/engine/tests/it/ruler_guide_nodes.rs index 950da2f065..1a88377585 100644 --- a/crates/engine/tests/it/ruler_guide_nodes.rs +++ b/crates/engine/tests/it/ruler_guide_nodes.rs @@ -103,10 +103,12 @@ fn ruler_guides_are_not_art_to_select_paint_or_inspect() { let layer_row = s.execute("document.inspect", &json!({"ids": [layer.0]})).unwrap(); let listed = layer_row.to_string(); assert!(guides.iter().all(|g| !listed.contains(&format!("\"id\":{}", g.0))), "{listed}"); - // Unlock All does not reach a locked guide. + // Unlock All unlocks a guide locked on its own and selects it as a guide, not as art. Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(guides[0]).unwrap().locked = true; s.execute("object.unlockAll", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(guides[0]).unwrap().locked); + let st = s.doc().unwrap(); + assert!(!st.doc.node(guides[0]).unwrap().locked); + assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![guides[0]], vec![])); } #[test] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 8c4e95f4e6..ab39f467d2 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1038,7 +1038,7 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { /// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound /// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks, /// gradients, pattern definitions and tiles, images, type, symbol definitions and instances, -/// objects with art styles and objects bound to variables. +/// objects with art styles, objects bound to variables and live envelopes. fn counted() -> Vec<(&'static str, Document)> { vec![ ("sample", sample()), @@ -1053,6 +1053,7 @@ fn counted() -> Vec<(&'static str, Document)> { ("painted clips", painted_clips()), ("blend", blend_doc()), ("brushes", brushes_document()), + ("envelopes", envelopes_document()), ] } @@ -7109,6 +7110,124 @@ fn every_effect_a_graphic_style_holds_is_read_back() { assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}"); } +/// An embedded 8 × 8 image of a color ramp, `Photo`, 80 pt square at (20, 10) in `doc`, at half +/// opacity, with `effects`. +fn warped_photo(doc: &mut Document, effects: Vec) -> Node { + let ramp = image::RgbImage::from_fn(8, 8, |x, y| image::Rgb([(x * 32) as u8, (y * 32) as u8, 160])); + let mut photo = image_node(doc, ImageBlob::png(png_of(ramp.into())), (8, 8), Affine::new([10.0, 0.0, 0.0, 10.0, 20.0, 10.0]), None); + photo.name = Some("Photo".into()); + photo.opacity = 0.5; + photo.appearance = Appearance { effects, ..Appearance::default() }.into(); + photo +} + +/// A 200 × 100 document holding `photo`, made by [`warped_photo`]. +fn photo_doc(photo: impl FnOnce(&mut Document) -> Node) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let n = photo(&mut doc); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc +} + +/// An embedded image with one Warp is written with an art style of that Warp, its bent pieces as its +/// drawn look and the image itself on `%_` lines: it reads back as the image, its pixels, placement, +/// name and opacity as they were, with its Warp live and its settings kept. +#[test] +fn an_image_with_a_warp_reads_back_with_its_warp_live() { + // GUARDS: an image with a Warp was written as its drawn pieces alone, and read back without its Warp. + for (id, params) in [ + ("warp.arc", serde_json::json!({"bend": 40.0})), + ("warp.flag", serde_json::json!({"bend": -30.0, "horizontal": 10.0, "vertical": 0.0, "orientation": "vertical"})), + ] { + let doc = photo_doc(|d| warped_photo(d, vec![fx(id, params.clone())])); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{id}: {:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{id}: {:#?} {:?}", lines(&back), out.warnings); + let (mut want, mut got) = (vec![], vec![]); + pictures(&doc, doc.layers[0].children().unwrap(), "", &mut want); + pictures(&back, art, "", &mut got); + assert_eq!(got, want, "{id}"); + let [warp] = art[0].appearance.effects.as_slice() else { panic!("{id}: {:?}", art[0].appearance) }; + assert_eq!(warp.id, id); + let (w, g) = (vectorcraft_effects::merged_params(id, ¶ms), vectorcraft_effects::merged_params(&warp.id, &warp.params)); + for key in ["bend", "horizontal", "vertical"] { + assert!((g[key].as_f64().unwrap() - w[key].as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {g} for {w}"); + } + assert_eq!(g["orientation"], w["orientation"], "{id}"); + } +} + +/// An image whose style's Warp bends it another way than its drawn look keeps that look, with the +/// note, and the file as written opens with the image. +#[test] +fn an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look() { + // GUARDS: an image read with its Warp live whatever its drawn look showed. + let doc = photo_doc(|d| warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))])); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let (from, to) = (b"0.4 /Real (DeformValue)".as_slice(), b"-0.4 /Real (DeformValue)".as_slice()); + let at = program.windows(from.len()).position(|w| w == from).expect("the warp's bend"); + let mut other = program.clone(); + other.splice(at..at + from.len(), to.iter().copied()); + let (back, warnings) = crate::ai_alone(&other).unwrap(); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{art:#?}"); + assert!(matches!(art[0].kind, NodeKind::Group { .. }), "the drawn look: {:?}", art[0].kind); + assert!(art[0].appearance.effects.is_empty(), "{:?}", art[0].appearance); + assert!(warnings.iter().any(|w| w.contains("drawn look")), "{warnings:?}"); + let (back, _) = crate::ai_alone(&program).unwrap(); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Image(_))); +} + +/// An image on `%_` lines is the copy of a styled object, as other objects there are: with a style +/// the file doesn't define, its drawn look stands for it, named as it is, and the image doesn't come +/// in beside the look. +#[test] +fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() { + // GUARDS: an image whose samples were on `%_` lines came in as a second, visible object. + let doc = photo_doc(|d| warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))])); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let (from, to) = (b"1 (Anon 1) XW".as_slice(), b"1 (Unknown) XW".as_slice()); + let at = program.windows(from.len()).position(|w| w == from).expect("the image's style"); + let mut unknown = program.clone(); + unknown.splice(at..at + from.len(), to.iter().copied()); + let (back, _) = crate::ai_alone(&unknown).unwrap(); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{:#?}", lines(&back)); + assert!(matches!(art[0].kind, NodeKind::Group { .. }), "the drawn look: {:?}", art[0].kind); + assert_eq!(art[0].name.as_deref(), Some("Photo")); +} + +/// A linked image, an image with an opacity mask, and an image with a Warp and another effect are +/// written as their drawn art, with no art style. +#[test] +fn other_warped_images_are_written_as_their_drawn_art() { + let arc = || fx("warp.arc", serde_json::json!({"bend": 40.0})); + let linked = photo_doc(|d| { + let mut n = warped_photo(d, vec![arc()]); + if let NodeKind::Image(im) = &mut n.kind { + im.link = Some(vectorcraft_doc::LinkInfo::new("/art/photo.png")); + } + n + }); + let masked = photo_doc(|d| { + let mut n = warped_photo(d, vec![arc()]); + let white = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::WHITE), Paint::None, 0.0)); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, false))); + n + }); + let stacked = photo_doc(|d| warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))])); + for (what, doc) in [("linked", linked), ("masked", masked), ("stacked", stacked)] { + assert!(!program_shown(&doc).contains("1 (Anon"), "{what}"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert!(art.iter().all(|n| n.appearance.effects.is_empty()), "{what}: {art:#?}"); + } +} + /// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. #[test] fn appearances_written_as_their_drawn_look() { @@ -9055,3 +9174,446 @@ fn spread_gradients_past_the_stops_the_reader_reads_stay_gradients() { let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&swatch.gradient.stops.len()), "{} stops", swatch.gradient.stops.len()); } + +/// An envelope named `Bent` of `kind` around a red rectangle (40, 20)-(100, 80) and a blue one +/// (100, 20)-(160, 80), with Fidelity `fidelity`, Edit Contents `editing` and `options`. +fn envelope_node( + doc: &mut Document, + kind: vectorcraft_doc::live::EnvelopeKind, + fidelity: f64, + editing: bool, + options: vectorcraft_doc::live::EnvelopeOptions, +) -> Node { + let content = vec![ + Arc::new(square(doc, Rect::new(40.0, 20.0, 100.0, 80.0), Color::rgb(1.0, 0.0, 0.0))), + Arc::new(square(doc, Rect::new(100.0, 20.0, 160.0, 80.0), Color::rgb(0.0, 0.0, 1.0))), + ]; + let kind = NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing, options, frame: Box::default() }; + named(Node::new(doc.alloc_id(), kind), "Bent") +} + +/// A 200 × 100 document of envelope `kind` ([`envelope_node`]) alone. +fn envelope_doc( + kind: vectorcraft_doc::live::EnvelopeKind, + fidelity: f64, + editing: bool, + options: vectorcraft_doc::live::EnvelopeOptions, +) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let envelope = envelope_node(&mut doc, kind, fidelity, editing, options); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc +} + +/// A grid of `rows`×`cols` patches over (30, 10)-(170, 90), row by row from the top left, with its +/// sixth point moved. +fn envelope_points(rows: u32, cols: u32) -> Vec { + let mut points: Vec = (0..=rows) + .flat_map(|r| { + (0..=cols).map(move |c| kurbo::Point::new(30.0 + 140.0 * f64::from(c) / f64::from(cols), 10.0 + 80.0 * f64::from(r) / f64::from(rows))) + }) + .collect(); + points[5] += kurbo::Vec2::new(5.0, -8.0); + points +} + +/// The envelopes written live: warps (vertical, with distortions, and twisted), meshes (with +/// handles of their own, one handle uneven, and with the smooth ones), top objects (a curved top +/// side, and two segments to the top and bottom sides), with Edit Contents off and on. +fn live_envelope_kinds() -> Vec<(&'static str, vectorcraft_doc::live::EnvelopeKind)> { + use vectorcraft_doc::live::{EnvelopeKind, H_RIGHT, WARP_NET, smooth_handles}; + let warp = |style: &str, bend: f64, h: f64, v: f64, horizontal: bool| EnvelopeKind::Warp { + style: style.into(), + bend, + h, + v, + horizontal, + version: WARP_NET, + }; + let points = envelope_points(2, 3); + let mut handles = smooth_handles(2, 3, &points); + handles[5][H_RIGHT] += kurbo::Vec2::new(4.0, 6.0); + let mut curved = kurbo::BezPath::new(); + curved.move_to((30.0, 10.0)); + curved.curve_to((70.0, -5.0), (130.0, -5.0), (170.0, 10.0)); + curved.line_to((170.0, 90.0)); + curved.line_to((30.0, 90.0)); + curved.close_path(); + let mut six = kurbo::BezPath::new(); + six.move_to((30.0, 10.0)); + for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (100.0, 95.0), (30.0, 90.0)] { + six.line_to(p); + } + six.close_path(); + vec![ + ("arc", warp("arc", 50.0, -20.0, 30.0, false)), + ("twist", warp("twist", -35.0, 0.0, 0.0, true)), + ("mesh", EnvelopeKind::Mesh { rows: 2, cols: 3, points: points.clone(), handles }), + ("smooth mesh", EnvelopeKind::Mesh { rows: 2, cols: 3, points, handles: vec![] }), + ("curved top object", EnvelopeKind::TopObject { path: PathData::from_bezpath(&curved), corners: None }), + ("six-anchor top object", EnvelopeKind::TopObject { path: PathData::from_bezpath(&six), corners: None }), + ] +} + +/// `doc` written and read back from its editing data: the top object on its first layer, the +/// writer's warnings and the reader's notes. +fn envelope_back(doc: &Document) -> (Node, Vec, Vec) { + let (p, warnings) = private_data(doc, &opts()).unwrap(); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + assert_eq!(a.len(), 1, "{a:#?}"); + ((*a[0]).clone(), warnings, notes) +} + +/// Does reopened envelope kind `got` have the shape of `want`: a warp's style, bend, distortions +/// and orientation; a mesh's grid and the handles that draw (those inside the grid); the patch a top +/// object's outline makes? +fn same_envelope_kind(want: &vectorcraft_doc::live::EnvelopeKind, got: &vectorcraft_doc::live::EnvelopeKind) -> bool { + use vectorcraft_doc::live::{Coons, EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, smooth_handles}; + let near = |a: kurbo::Point, b: kurbo::Point| a.distance(b) < 1e-3; + match (want, got) { + ( + EnvelopeKind::Warp { style, bend, h, v, horizontal, version }, + EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal: hz, version: ver }, + ) => style == s && horizontal == hz && version == ver && [(bend, b), (h, dh), (v, dv)].iter().all(|(x, y)| (*x - *y).abs() < 1e-9), + (EnvelopeKind::Mesh { rows, cols, points, handles }, EnvelopeKind::Mesh { rows: r, cols: c, points: p, handles: hs }) => { + let own = if handles.is_empty() { smooth_handles(*rows, *cols, points) } else { handles.clone() }; + let inside = |i: usize, slot: usize| { + let (row, col) = (i / (*cols as usize + 1), i % (*cols as usize + 1)); + match slot { + s if s == H_LEFT => col > 0, + s if s == H_RIGHT => col < *cols as usize, + s if s == H_UP => row > 0, + s if s == H_DOWN => row < *rows as usize, + _ => false, + } + }; + (rows, cols) == (r, c) + && points.len() == p.len() + && points.iter().zip(p).all(|(a, b)| near(*a, *b)) + && own.iter().zip(hs).enumerate().all(|(i, (a, b))| (0..4).all(|s| !inside(i, s) || near(a[s].to_point(), b[s].to_point()))) + } + (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) => { + let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false }; + (0..=8).all(|j| { + (0..=8).all(|i| a.eval(f64::from(i) / 8.0, f64::from(j) / 8.0).distance(b.eval(f64::from(i) / 8.0, f64::from(j) / 8.0)) < 1e-2) + }) + } + _ => false, + } +} + +/// A PDF-compatible `.ai`'s editing data keeps a warp, a mesh and a top object envelope live, with +/// Edit Contents off and on: VectorCraft's reader opens each as the same envelope, its contents, +/// Fidelity, Edit Contents, Envelope Options and shape as saved, and its name, the drawn art saved +/// with it drawing as the envelope does. +#[test] +fn envelopes_are_read_back_live() { + use vectorcraft_doc::live::EnvelopeOptions; + for (name, kind) in live_envelope_kinds() { + for editing in [false, true] { + let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW); + let (back, warnings, notes) = envelope_back(&doc); + let what = format!("{name}, editing {editing}"); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}"); + let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") }; + assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}"); + assert!(same_envelope_kind(&kind, got), "{what}: {got:?}"); + assert_eq!(back.name.as_deref(), Some("Bent"), "{what}"); + let colors: Vec> = content.iter().map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}"); + // Saved again as it opened (a top object now with the corners the file gives it), it + // opens as the same envelope again. + let mut again = Document::new(200.0, 100.0); + let layer = again.layers[0].id; + again.insert(Some(layer), usize::MAX, back.clone()).unwrap(); + let (twice, warnings, notes) = envelope_back(&again); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}"); + let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") }; + assert!(same_envelope_kind(&kind, twice), "{what}, saved again: {twice:?}"); + } + } +} + +/// An envelope that is a symbol's art is written live in the symbol's definition, and the symbol +/// opens with it as a live envelope. +#[test] +fn an_envelope_in_a_symbol_is_read_back_live() { + use vectorcraft_doc::live::EnvelopeOptions; + let kinds = live_envelope_kinds(); + for (name, kind) in kinds { + let mut doc = Document::new(200.0, 100.0); + let art = envelope_node(&mut doc, kind.clone(), 50.0, false, EnvelopeOptions::NEW); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Bent".into(), xf: Affine::translate((10.0, 5.0)) }); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{name}: {warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let symbol = back.symbols.iter().find(|s| s.name == "Bent").unwrap_or_else(|| panic!("{name}: {:?}", back.symbols)); + let mut envelopes = vec![]; + symbol.art.walk(&mut |n| { + if let NodeKind::Envelope { kind, .. } = &n.kind { + envelopes.push((**kind).clone()); + } + }); + assert!(matches!(envelopes.as_slice(), [k] if same_envelope_kind(&kind, k)), "{name}: {envelopes:?} {notes:?}"); + } +} + +/// An envelope with an opacity mask comes back live with its mask, and the plugin's `XP` follows +/// the envelope's dictionary rather than its mask's art. +#[test] +fn an_envelope_with_an_opacity_mask_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut doc = Document::new(200.0, 100.0); + let mut envelope = envelope_node(&mut doc, kind.clone(), 50.0, false, EnvelopeOptions::NEW); + let gray = square(&mut doc, Rect::new(40.0, 20.0, 160.0, 80.0), Color::gray(0.5)); + let art = group(&mut doc, vec![gray], false); + envelope.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + let text = program(&doc); + let (xp, mask) = (text.find(") 1 0 0 XP").unwrap_or(0), text.find("(AI9_Mask)").unwrap_or(usize::MAX)); + assert!(mask < xp, "{text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(same_envelope_kind(&kind, got), "{got:?}"); + assert!(back.extras().mask.is_some(), "{back:#?}"); +} + +/// As many envelopes are written live as the reader checks in one file, and the ones after them +/// as their art, with a warning. +#[test] +fn envelopes_past_the_readers_checks_are_written_as_their_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let limit = crate::import::ENVELOPE_CHECKS; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for _ in 0..=limit { + let points = [(30.0, 10.0), (170.0, 0.0), (30.0, 90.0), (170.0, 90.0)].map(kurbo::Point::from).to_vec(); + let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }; + let envelope = envelope_node(&mut doc, kind, 50.0, false, EnvelopeOptions::NEW); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + } + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let a = back.layers[0].children().unwrap(); + let live = a.iter().filter(|n| matches!(n.kind, NodeKind::Envelope { .. })).count(); + assert_eq!((a.len(), live), (limit + 1, limit), "{notes:?}"); + assert!(!notes.iter().any(|n| n == crate::import::ENVELOPE_DRAWN), "{notes:?}"); +} + +/// Every Envelope Options switch and the ends of Fidelity come back as saved. +#[test] +fn envelope_options_and_fidelity_are_read_back() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, PreserveShape}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let new = EnvelopeOptions::NEW; + let all = [ + (new, 50.0), + (EnvelopeOptions { anti_alias: false, ..new }, 0.0), + (EnvelopeOptions { preserve_shape: PreserveShape::Transparency, ..new }, 100.0), + (EnvelopeOptions { distort_linear_gradients: true, distort_pattern_fills: true, ..new }, 30.0), + (EnvelopeOptions { distort_appearance: false, ..new }, 50.0), + ]; + for (options, fidelity) in all { + let (back, _, notes) = envelope_back(&envelope_doc(kind.clone(), fidelity, false, options)); + let NodeKind::Envelope { options: o, fidelity: f, .. } = &back.kind else { panic!("{options:?}: {back:#?} {notes:?}") }; + assert_eq!((*o, *f), (options, fidelity)); + } +} + +/// With Distort Appearance, an envelope whose contents map from their bounds with their strokes +/// saves that box's size, and opens mapping from the same box. +#[test] +fn an_envelope_mapping_from_its_contents_strokes_is_read_back() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, envelope_content_boxes, envelope_stroke_margin}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut envelope = envelope_node( + &mut doc, + EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }, + 50.0, + false, + EnvelopeOptions::NEW, + ); + let NodeKind::Envelope { content, options, .. } = &mut envelope.kind else { unreachable_envelope() }; + for c in content.iter_mut() { + let fill = Arc::make_mut(c).appearance.fill_paint(); + Arc::make_mut(c).appearance = Appearance::basic(fill, solid(Color::BLACK), 8.0).into(); + } + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY).unwrap(); + options.bounds_margin = envelope_stroke_margin(stroked, plain).unwrap(); + let margin = options.bounds_margin; + assert!(margin.iter().all(|m| *m > 0.0), "{margin:?}"); + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { options, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(options.bounds_margin.iter().zip(margin).all(|(a, b)| (a - b).abs() < 1e-4), "{:?} {margin:?}", options.bounds_margin); +} + +/// A test's stand-in for a node that must be an envelope. +fn unreachable_envelope() -> ! { + panic!("not an envelope") +} + +/// The record is written in the order the reader reads it: on `%_` lines the group holding the +/// grid, its dictionary with the options, the contents' block and the kind, then the distorted art +/// on plain lines and the plugin's `XP`. While the contents are edited, the group holds them and +/// the dictionary the grid. +#[test] +fn an_envelope_is_written_in_the_order_the_reader_reads_it() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + for editing in [false, true] { + let text = program(&envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW)); + let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("{s} missing: {text}")); + let (group, mesh, dictionary, block, key, style, xp) = ( + at("%_u\n"), + at("%_/Mesh X!"), + at("%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n"), + at("%_X=\n"), + at("%_ (AIDeformArtKey) ,\n"), + at("%_2 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\n"), + at("(Adobe Deform Plugin) 1 0 0 XP\n"), + ); + assert!(group < dictionary && dictionary < block && block < key && key < style && style < xp, "{text}"); + assert_eq!(block < mesh, editing, "{text}"); + assert!(text.contains(&format!("%_{} /Bool (AIDeformEditModeKey) ,\n", u8::from(editing))), "{text}"); + // The distorted art is one group on plain lines, named as the envelope, right before the `XP`. + let drawn = &text[style..xp]; + assert!(drawn.contains("\nu\n") && drawn.contains("(Bent) ; (AI10_ArtUID) ,"), "{drawn}"); + // The contents are on `%_` lines only: drawn once on plain lines, as the distorted art. + assert_eq!(text.matches("(Adobe Deform Plugin)").count(), 1, "{text}"); + } +} + +/// Envelopes the reader would open in another shape, or not at all, are written as their distorted +/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn +/// without its style's net, a mesh the reader would lay out the other way round, a top object +/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with +/// Distort Appearance off. Other live objects keep their own warning. +#[test] +fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let base = + || envelope_doc(EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }, 50.0, false, EnvelopeOptions::NEW); + let change = |f: &dyn Fn(&mut NodeKind)| { + let mut doc = base(); + let id = doc.layers[0].children().unwrap()[0].id; + f(&mut doc.node_mut(id).unwrap().kind); + doc + }; + let typed = change(&|k| { + if let NodeKind::Envelope { content, .. } = k { + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); + content.push(Arc::new(Node::new(vectorcraft_doc::NodeId(9_000), NodeKind::Text(Box::new(t))))); + } + }); + let turned = change(&|k| { + if let NodeKind::Envelope { frame, .. } = k { + **frame = Affine::rotate(0.3); + } + }); + let old_warp = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + **kind = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; + } + }); + // Columns down the page and rows across: the reader turns the grid round. + let sideways = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + let points = + (0..=3).flat_map(|r| (0..=2).map(move |c| kurbo::Point::new(30.0 + 70.0 * f64::from(c), 10.0 + 80.0 * f64::from(r) / 3.0))).collect(); + **kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] }; + } + }); + let mut uneven = kurbo::BezPath::new(); + uneven.move_to((30.0, 10.0)); + for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { + uneven.line_to(p); + } + uneven.close_path(); + let lopsided = change(&|k| { + if let NodeKind::Envelope { kind, .. } = k { + **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None }; + } + }); + let fractional = change(&|k| { + if let NodeKind::Envelope { fidelity, .. } = k { + *fidelity = 50.5; + } + }); + let margin_kept = change(&|k| { + if let NodeKind::Envelope { options, .. } = k { + options.distort_appearance = false; + options.bounds_margin = [0.5; 4]; + } + }); + let cases = [ + ("type", typed), + ("turned", turned), + ("old warp", old_warp), + ("sideways mesh", sideways), + ("lopsided top object", lopsided), + ("fractional fidelity", fractional), + ("margin kept", margin_kept), + ]; + for (what, doc) in cases { + let text = program(&doc); + assert!(!text.contains("AIDeformOptionsKey"), "{what}: {text}"); + let (back, warnings, _) = envelope_back(&doc); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::LIVE), "{what}: {warnings:?}"); + assert!(matches!(back.kind, NodeKind::Group { .. }), "{what}: {back:#?}"); + } + let blend = export_ai_editing(&blend_doc(), &opts()).unwrap(); + assert!(blend.warnings.iter().any(|w| w == writer::LIVE) && !blend.warnings.iter().any(|w| w == writer::ENVELOPES), "{:?}", blend.warnings); +} + +/// `points`, a grid of `rows + 1` rows of `cols + 1` points, as `cols + 1` rows of `rows + 1`. +fn transpose(points: Vec, rows: usize, cols: usize) -> Vec { + (0..=cols).flat_map(|c| (0..=rows).map(move |r| (r, c))).map(|(r, c)| points[r * (cols + 1) + c]).collect() +} + +/// An envelope with its contents in an opacity mask's art is written as its art there, and an +/// envelope in a group stays live. +#[test] +fn envelopes_in_masks_are_written_as_their_art_and_in_groups_stay_live() { + let doc = envelopes_document(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let mut live = 0; + back.walk(|n| live += usize::from(matches!(n.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 3, "{notes:?}"); +} + +/// A warp, a mesh envelope with its contents edited, a top object in a group, and an envelope in an +/// opacity mask's art. +fn envelopes_document() -> Document { + use vectorcraft_doc::live::EnvelopeOptions; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let kinds = live_envelope_kinds(); + let kind = |name: &str| kinds.iter().find(|(n, _)| *n == name).map(|(_, k)| k.clone()).unwrap(); + let warp = envelope_node(&mut doc, kind("arc"), 50.0, false, EnvelopeOptions::NEW); + let mesh = envelope_node(&mut doc, kind("mesh"), 50.0, true, EnvelopeOptions::NEW); + let top = envelope_node(&mut doc, kind("six-anchor top object"), 50.0, false, EnvelopeOptions::NEW); + let top = group(&mut doc, vec![top], false); + let mut masked = square(&mut doc, Rect::new(0.0, 0.0, 200.0, 100.0), Color::gray(0.5)); + let mask_art = envelope_node(&mut doc, kind("twist"), 50.0, false, EnvelopeOptions::NEW); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false))); + for n in [warp, mesh, top, masked] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index c78c3e6542..cd0455f10f 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -16,6 +16,7 @@ use crate::ai_ops::{self, unit}; use crate::ps; mod brushes; +mod envelopes; mod images; mod paints; mod styles; @@ -25,6 +26,8 @@ mod variables; #[cfg(test)] pub(super) use brushes::BRUSHES; +#[cfg(test)] +pub(super) use envelopes::ENVELOPES; pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; @@ -52,8 +55,7 @@ pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fil pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps"; pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; -pub(super) const LIVE: &str = - "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; +pub(super) const LIVE: &str = "blends, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; @@ -262,6 +264,12 @@ pub(super) struct Writer<'a> { brush_set: brushes::Brushes, /// Brushes may be written; false on a large canvas, where they are left out. pub(super) brushes_live: bool, + /// The object just written is an envelope's distorted art, which the plugin's `XP` follows + /// ([`Self::envelope`]). + deform: bool, + /// How many more envelopes may be written live: as many as the reader checks in one file + /// ([`crate::import::ENVELOPE_CHECKS`]). + envelopes_left: usize, } impl<'a> Writer<'a> { @@ -319,6 +327,8 @@ impl<'a> Writer<'a> { in_look: false, brush_set: brushes::Brushes::default(), brushes_live: true, + deform: false, + envelopes_left: crate::import::ENVELOPE_CHECKS, objects: 0, left_out: 0, definition_objects: 0, @@ -694,29 +704,16 @@ impl<'a> Writer<'a> { } self.end_group("U", xy); } - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => { - if !self.open_group("u") { - return; - } - self.warn(LIVE); - if self.nest < MAX_NEST { - let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - self.nest += 1; - for c in g.children().map(Vec::as_slice).unwrap_or_default() { - self.node(c); - } - self.nest -= 1; - } - self.end_group("U", xy); + NodeKind::Envelope { .. } if self.envelope(n, xy) => {} + NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES)), + NodeKind::Blend { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + self.drawn_art(n, xy, Some(LIVE)); } // Ruler guides are kept in the document's native copy only. NodeKind::RulerGuide { .. } => {} } + // Taken before the dictionary, whose opacity mask art may hold envelopes of its own. + let deform = std::mem::take(&mut self.deform); if self.body.len() > start { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); @@ -726,6 +723,29 @@ impl<'a> Writer<'a> { self.body.push_str(&b); } } + // An envelope's plugin `XP` follows its distorted art, after the art's state block. + if deform { + self.line(&envelopes::plugin_line()); + } + } + + /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. + fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>) { + if !self.open_group("u") { + return; + } + if let Some(w) = warning { + self.warn(w); + } + if self.nest < MAX_NEST { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.nest += 1; + for c in g.children().map(Vec::as_slice).unwrap_or_default() { + self.node(c); + } + self.nest -= 1; + } + self.end_group("U", xy); } /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs new file mode 100644 index 0000000000..dcd7274291 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -0,0 +1,395 @@ +//! Envelopes (see [`crate::ai_editing`] for the form), written in the form VectorCraft's `.ai` +//! reader opens as live envelopes again: on `%_` lines a group holding the grid that shapes the +//! envelope (while its contents are edited, the contents), then that group's `/ArtDictionary` with +//! Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity (`AIDeformFidelityKey`), Edit +//! Contents (`AIDeformEditModeKey`), a warp's style, bend, distortions and orientation +//! (`DeformStyle`, `DeformValue`, `DeformHoriz`, `DeformVert`, `Rotate`), with Distort Appearance +//! the size of the box the contents map from (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the +//! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the +//! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain +//! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written +//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another +//! shape is written as its distorted art. + +use std::fmt::Write; +use std::sync::Arc; + +use kurbo::{Point, Vec2}; +use vectorcraft_doc::live::{ + Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, + envelope_grid, envelope_stroke_margin, warp_grid, +}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, PathData}; + +use super::{MAX_NEST, Writer, Xy, hide}; +use crate::import::{DEFORM_PLUGIN, EnvelopeGridPoint as GridPoint, envelope_outline, envelope_upright, warp_number}; +use crate::ps; + +pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; + +/// The most rows or columns of an envelope's grid written (Make with Mesh's own limit, and the +/// reader's). +const MAX_GRID: usize = 50; +/// How far, as a share of the diagonal of a top object's patch, the patch on the outline written for +/// it may lie from its own at any of the places checked. +const TOP_OBJECT_TOLERANCE: f64 = 1e-6; +/// How far apart two bounds margins may be and still count as the same. +const MARGIN_SLACK: f32 = 1e-5; + +/// What an envelope's dictionary holds besides its contents: the entries before the contents' block, +/// each on a line of its own, the kind (`AIDeformStyle`) and the grid that shapes it (`rows`, `cols` +/// and its points with their handles, row by row from the top left, in the document), when it has +/// one. +struct Record { + keys: String, + style: u8, + grid: Option<(usize, usize, Vec)>, +} + +/// The `XP` that ends an envelope, after its distorted art. +pub(super) fn plugin_line() -> String { + format!("{} 1 0 0 XP", ps::bytes(DEFORM_PLUGIN.as_bytes())) +} + +/// Does `n`, or anything in it, hold type? +fn holds_type(n: &Node) -> bool { + matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|k| holds_type(k))) +} + +/// The record of envelope `n` (its parts given), or `None` where the reader would open it in +/// another shape than it has, or not as an envelope. +fn record( + n: &Node, + content: &[Arc], + kind: &EnvelopeKind, + fidelity: f64, + editing: bool, + options: &EnvelopeOptions, + frame: Affine, +) -> Option { + // The reader reads Fidelity as a whole number from 0 to 100, and bounds margins only with + // Distort Appearance on. + let whole = (0.0..=100.0).contains(&fidelity) && fidelity.fract() == 0.0; + let margin_kept = !options.distort_appearance && options.bounds_margin != [0.0; 4]; + if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept || content.iter().any(|c| holds_type(c)) { + return None; + } + let mut keys = String::new(); + let bit = |on: bool, at: u32| i64::from(on) << at; + let word = bit(options.distort_appearance, 0) + | bit(options.distort_pattern_fills, 1) + | bit(options.distort_linear_gradients, 2) + | bit(!options.anti_alias, 16) + | bit(options.preserve_shape == PreserveShape::ClippingMask, 17); + let _ = writeln!(keys, "{word} /Int (AIDeformOptionsKey) ,"); + let _ = writeln!(keys, "{} /Int (AIDeformFidelityKey) ,", fidelity as i64); + let _ = writeln!(keys, "{} /Bool (AIDeformEditModeKey) ,", u8::from(editing)); + let real = |keys: &mut String, v: f64, key: &str| { + ps::push_num_places(keys, v, 9); + let _ = writeln!(keys, " /Real ({key}) ,"); + }; + let (style, grid) = match kind { + EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => { + let fraction = |p: f64| (p.is_finite() && (-100.0..=100.0).contains(&p)).then_some(p / 100.0); + let (number, bend, h, v) = (warp_number(style)?, fraction(*bend)?, fraction(*h)?, fraction(*v)?); + if *version != WARP_NET { + return None; + } + let _ = writeln!(keys, "{number} /Int (DeformStyle) ,"); + real(&mut keys, h, "DeformHoriz"); + real(&mut keys, bend, "DeformValue"); + real(&mut keys, v, "DeformVert"); + let _ = writeln!(keys, "{} /Bool (Rotate) ,", u8::from(!*horizontal)); + // The grid of the warp's shape, for apps that read it. + (1, warp_grid(n).and_then(|m| as_read(&m)).and_then(|(rows, cols, pts)| sized(rows, cols).map(|(r, c)| (r, c, pts)))) + } + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let (r, c) = sized(*rows, *cols)?; + let mesh = envelope_grid(*rows, *cols, points, handles)?; + let pts = as_read(&mesh)?.2; + // The reader lays the grid out with its first row at the top and its first column at the + // left: a grid it would lay out another way round would map the contents another way. + if !reads_as_written(r, c, &pts) { + return None; + } + (2, Some((r, c, pts))) + } + EnvelopeKind::TopObject { path, corners } => (3, Some(top_object_grid(path, *corners)?)), + }; + if options.distort_appearance { + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + let margin = options.bounds_margin; + let same = |m: [f32; 4]| m.iter().zip(margin).all(|(a, b)| (a - b).abs() <= MARGIN_SLACK); + // The reader maps from the size written when it matches the contents' bounds with their + // strokes, and from their geometric bounds otherwise. + let size = if margin == [0.0; 4] { + plain + } else if stroked != plain && envelope_stroke_margin(stroked, plain).is_some_and(same) { + stroked + } else { + return None; + }; + real(&mut keys, size.height(), "AIDeformArtHeightKey"); + real(&mut keys, size.width(), "AIDeformArtWidthKey"); + } + Some(Record { keys, style, grid }) +} + +/// `rows` and `cols` as sizes, when the reader reads a grid of them. +fn sized(rows: u32, cols: u32) -> Option<(usize, usize)> { + let (r, c) = (usize::try_from(rows).ok()?, usize::try_from(cols).ok()?); + ((1..=MAX_GRID).contains(&r) && (1..=MAX_GRID).contains(&c)).then_some((r, c)) +} + +/// The points of grid `m` with their handles as the reader reads them back: the handles that point +/// out of the grid, which nothing draws with, are left at the point. `None` for a grid of points +/// or handles that aren't finite. +fn as_read(m: &GradientMesh) -> Option<(u32, u32, Vec)> { + let (rows, cols) = (m.rows as usize, m.cols as usize); + let finite = |v: Vec2| v.x.is_finite() && v.y.is_finite(); + let mut pts = Vec::with_capacity(m.points.len()); + for (i, q) in m.points.iter().enumerate() { + if !finite(q.p.to_vec2()) || !q.handles.iter().all(|h| finite(*h)) { + return None; + } + let (r, c) = (i / (cols + 1), i % (cols + 1)); + let mut h = q.handles; + for (slot, out) in [(H_LEFT, c == 0), (H_RIGHT, c == cols), (H_UP, r == 0), (H_DOWN, r == rows)] { + if out && let Some(h) = h.get_mut(slot) { + *h = Vec2::ZERO; + } + } + pts.push((q.p, h)); + } + Some((m.rows, m.cols, pts)) +} + +/// Does the reader lay out grid `pts` (`rows`×`cols` patches) as it is written? +fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool { + let points = + pts.iter().map(|(p, handles)| MeshPoint { p: *p, color: vectorcraft_color::Color::BLACK, opacity: 1.0, handles: *handles }).collect(); + let mesh = GradientMesh { rows: rows as u32, cols: cols as u32, points }; + envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts) +} + +/// The grid a top object with outline `path` and corners `corners` is written with: its edge runs +/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so +/// the top and bottom sides need as many segments as each other, as do the left and right sides; +/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's +/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked. +fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec)> { + let coons = Coons::from_top_object(path, corners)?; + // The outline, as the patch takes it: the largest subpath. + let mut sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?.clone(); + let count = sp.anchors.len(); + let empty = |i: usize| { + let c = sp.segment(i); + [c.p1, c.p2, c.p3].iter().all(|p| p.distance(c.p0) <= 1e-9) + }; + if !sp.closed || count < 4 || (0..count).any(empty) { + return None; + } + let fits = |c: &[usize; 4]| { + c.iter().all(|i| *i < count) && { + let gaps = (0..4).map(|k| (c[(k + 1) % 4] + count - c[k]) % count); + gaps.clone().all(|g| g > 0) && gaps.sum::() == count + } + }; + // Stored corners that fit keep the outline as drawn; otherwise it runs clockwise, and the + // corners are the anchors the patch's corners lie on. + let at = match corners.map(|c| c.map(|i| i as usize)).filter(fits) { + Some(c) => c, + None => { + if sp.signed_area() < 0.0 { + sp.reverse(); + } + let find = |q: Point| sp.anchors.iter().position(|a| a.p.distance(q) <= 1e-9); + let [tl, tr, br, bl] = coons.corners(); + let c = [find(tl)?, find(tr)?, find(br)?, find(bl)?]; + if !fits(&c) { + return None; + } + c + } + }; + let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count; + let (cols, rows) = (gap(0), gap(1)); + if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID { + return None; + } + let width = cols + 1; + let mut pts: Vec = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width]; + // Where the `k`th anchor from the top left corner goes, and the slots of the handles of the + // segment from it to the next anchor: its own, and the next anchor's back. + let place = |k: usize| -> (usize, usize, usize, usize) { + if k < cols { + (0, k, H_RIGHT, H_LEFT) + } else if k < cols + rows { + (k - cols, cols, H_DOWN, H_UP) + } else if k < 2 * cols + rows { + (rows, cols - (k - cols - rows), H_LEFT, H_RIGHT) + } else { + (rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN) + } + }; + for k in 0..count { + let i = (at[0] + k) % count; + let seg = sp.segment(i); + // A straight segment's patch spreads evenly along it, as a cubic's with its handles a third + // of the way does. + let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) }; + let (r, c, out, into) = place(k); + let (nr, nc, _, _) = place((k + 1) % count); + if let Some(q) = pts.get_mut(r * width + c) { + q.0 = seg.p0; + q.1[out] = p1 - seg.p0; + } + if let Some(q) = pts.get_mut(nr * width + nc) { + q.1[into] = p2 - seg.p3; + } + } + // The points inside, blended from the sides. + let edge = |r: usize, c: usize| pts.get(r * width + c).map_or(Point::ZERO, |q| q.0).to_vec2(); + let mut inside = vec![]; + for r in 1..rows { + for c in 1..cols { + let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64); + let p = (1.0 - v) * edge(0, c) + v * edge(rows, c) + (1.0 - u) * edge(r, 0) + u * edge(r, cols) + - ((1.0 - u) * (1.0 - v) * edge(0, 0) + u * (1.0 - v) * edge(0, cols) + (1.0 - u) * v * edge(rows, 0) + u * v * edge(rows, cols)); + inside.push((r * width + c, p.to_point())); + } + } + for (i, p) in inside { + if let Some(q) = pts.get_mut(i) { + q.0 = p; + } + } + if !pts.iter().all(|(p, h)| p.is_finite() && h.iter().all(|h| h.is_finite())) || !reads_as_written(rows, cols, &pts) { + return None; + } + // The top object the reader makes of the grid: its edge, with the corners of the grid. + let outline = envelope_outline(rows as u32, cols as u32, &pts)?; + let corners = [0, cols, cols + rows, 2 * cols + rows].map(|c| c as u32); + let read = Coons::from_top_object(&outline, Some(corners))?; + let [tl, tr, br, bl] = coons.corners(); + let reach = tl.distance(br).max(tr.distance(bl)).max(1.0) * TOP_OBJECT_TOLERANCE; + const STEPS: usize = 8; + let at = |i: usize| i as f64 / STEPS as f64; + let same = (0..=STEPS).all(|j| (0..=STEPS).all(|i| coons.eval(at(i), at(j)).distance(read.eval(at(i), at(j))) <= reach)); + same.then_some((rows, cols, pts)) +} + +impl Writer<'_> { + /// Envelope `n`, of transparency `xy`, as a live envelope (see the module's form), outside opacity + /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. False, + /// writing nothing, where it is written as its art instead: where the reader would open it in + /// another shape, or some of it is left out for the object limit ([`Self::take_objects`]). + pub(super) fn envelope(&mut self, n: &Node, xy: Xy) -> bool { + let NodeKind::Envelope { content, kind, fidelity, editing, options, frame } = &n.kind else { return false }; + if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.envelopes_left == 0 { + return false; + } + let Some(record) = record(n, content, kind, *fidelity, *editing, options, **frame) else { return false }; + let start = self.body.len(); + let (state, gs, charges, bound) = (self.state, self.gs, self.charges(), self.bound.len()); + self.nest += 1; + self.hidden += 1; + // The group before the dictionary: the grid, or the contents while they are edited. + let outer = std::mem::take(&mut self.body); + let opened = self.open_group("u"); + if opened { + if *editing { + self.contents(content); + } else { + self.grid(record.grid.as_ref()); + } + self.line("U"); + } + let before = std::mem::replace(&mut self.body, outer); + hide(&before, &mut self.body); + self.body.push_str("%_/ArtDictionary :\n"); + hide(&record.keys, &mut self.body); + self.body.push_str("%_X=\n"); + // The dictionary's art starts unlocked, shown and opaque, as an opacity mask's does. + let outer = std::mem::take(&mut self.body); + let at_block = self.state; + (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); + if *editing { + self.grid(record.grid.as_ref()); + } else if self.open_group("u") { + self.contents(content); + self.end_group("U", Xy::PLAIN); + } + let block = std::mem::replace(&mut self.body, outer); + hide(&block, &mut self.body); + (self.state, self.gs, self.block_end) = (at_block, None, usize::MAX); + let _ = writeln!(self.body, "%_X+\n%_ (AIDeformArtKey) ,\n%_{} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW", record.style); + self.hidden -= 1; + self.nest -= 1; + // The distorted art, which takes the envelope's lock, visibility, transparency, name and + // opacity mask. + self.state(n.locked, !n.visible); + self.drawn_art(n, xy, None); + if !opened || self.left_out_since(charges) { + let lines = self.body.split_off(start); + self.give_back(charges, &lines); + self.bound.truncate(bound); + (self.state, self.gs, self.block_end) = (state, gs, usize::MAX); + return false; + } + self.deform = true; + self.envelopes_left -= 1; + true + } + + /// An envelope's contents. + fn contents(&mut self, content: &[Arc]) { + for c in content { + self.node(c); + } + } + + /// An envelope's grid, when it has one, as a gray gradient mesh (`/Mesh X!` … `/End X!`): its + /// size (`[cols rows] /Size`), then each patch's place (`[col row] /P`), its four corners + /// (`/N`: gray, opacity, the point, the handle towards the next corner and a weight, the handle + /// towards the previous one and a weight, in art space), from its top left one down, across and + /// up, and `/E`. One object. + fn grid(&mut self, grid: Option<&(usize, usize, Vec)>) { + let Some((rows, cols, pts)) = grid else { return }; + if !self.take_objects(1) { + return; + } + let (rows, cols, width) = (*rows, *cols, cols + 1); + let to_art = self.to_art; + let s = &mut self.body; + let _ = writeln!(s, "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[{cols} {rows}] /Size X#\n/Data X#\n/DeviceGray /CS X#"); + let num = |s: &mut String, v: f64| { + s.push(' '); + ps::push_num(s, v); + }; + for c in 0..cols { + for r in 0..rows { + let _ = writeln!(s, "[{c} {r}] /P X#"); + // Each corner with the slots of its handles towards the next corner and the previous one. + let corners = [((r, c), H_DOWN, H_RIGHT), ((r + 1, c), H_RIGHT, H_UP), ((r + 1, c + 1), H_UP, H_LEFT), ((r, c + 1), H_LEFT, H_DOWN)]; + for ((pr, pc), next, prev) in corners { + let (p, h) = pts.get(pr * width + pc).copied().unwrap_or((Point::ZERO, [Vec2::ZERO; 4])); + let at = |v: Vec2| to_art * (p + v); + s.push_str("[0 1"); + for q in [at(Vec2::ZERO), at(h[next])] { + num(s, q.x); + num(s, q.y); + } + s.push_str(" 1"); + let q = at(h[prev]); + num(s, q.x); + num(s, q.y); + s.push_str(" 1] /N X#\n"); + } + s.push_str("/E X#\n"); + } + } + s.push_str("/End X!\n"); + } +} diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 29eb2bf910..fe3e68ce24 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -41,7 +41,8 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { /// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), /// by id: paths and compound paths with points and groups, with live effects, linked to a graphic -/// style or (paths and compound paths) with a brush on a stroke, without Opacity & Mask Define +/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp +/// ([`warped_image`]), without Opacity & Mask Define /// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. @@ -86,10 +87,22 @@ fn candidate(n: &Node) -> bool { NodeKind::Path { path, guide: false, clipping: false, .. } => has_points(path), NodeKind::Compound { .. } => has_points(&compound_path(n)), NodeKind::Group { children, clip: false } => !n.is_compound_shape() && children.iter().all(|c| plain_member(c)), + NodeKind::Image(_) => warped_image(n), _ => false, } } +/// Is `n` an embedded image without an opacity mask whose appearance is one Warp, with no fills or +/// strokes that paint and no effects on them: an image the reader reads with its Warp live +/// ([`crate::import::read_art_style`]'s `image_warp`)? +fn warped_image(n: &Node) -> bool { + let NodeKind::Image(im) = &n.kind else { return false }; + im.link.is_none() + && n.extras().mask.is_none() + && matches!(n.appearance.effects.as_slice(), [e] if e.id.starts_with("warp.")) + && n.appearance.items.iter().all(|i| i.paint().is_none() && i.effects().is_empty()) +} + /// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`] /// leaves out what paints nothing)? fn brushed(n: &Node) -> bool { @@ -666,7 +679,7 @@ impl Writer<'_> { return Some(name); } let brushed = brushed(orig); - if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) { + if (orig.appearance.effects.is_empty() && !brushed) || !(own_style_holds(&orig.appearance) || warped_image(orig)) { return None; } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); @@ -704,7 +717,8 @@ impl Writer<'_> { /// glows or feathers, the object's transparency /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or /// compound path's points painted with nothing, or with its gradient fill when its style has - /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + /// one; a group with its members; an image with its pixels), its name and opacity mask; then + /// `1 (name) XW`. /// The look's group and the object itself are counted first ([`Self::take_objects`]): without /// room for them, nothing is written, and the object counts neither as written with its graphic /// style nor as written with a brush on its stroke. @@ -712,7 +726,7 @@ impl Writer<'_> { let own = match &orig.kind { NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), - NodeKind::Group { .. } => 1, + NodeKind::Group { .. } | NodeKind::Image(_) => 1, _ => 0, }; if !self.take_objects(1 + own) { @@ -752,13 +766,14 @@ impl Writer<'_> { let saved = std::mem::take(&mut self.body); self.hidden += 1; let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); - // A path's objects were kept for it above; it counts them itself. + // A path's or an image's objects were kept for it above; it counts them itself. if !matches!(orig.kind, NodeKind::Group { .. }) { self.objects -= own; } match &orig.kind { NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), + NodeKind::Image(im) => self.image(&self.doc.shown_image(orig, im), xy), NodeKind::Group { children, .. } => { self.line("u"); for c in children { @@ -847,7 +862,8 @@ fn object_rule(n: &Node) -> FillRule { /// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without /// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those -/// are written only in the Graphic Styles panel's styles. +/// are written only in the Graphic Styles panel's styles, apart from a Warp on an image +/// ([`warped_image`]). fn own_style_holds(ap: &Appearance) -> bool { let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty(); let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p)); @@ -870,13 +886,16 @@ fn panel_reads(g: &GraphicStyle, text: &str) -> bool { /// Does the reader read object `n` written with style text `text` as it is: a path or compound /// path with a style it reads on any object, filled by the non-zero rule when the style fills it, /// or a group with no fills or strokes of its own, with effects that move the outline only where the -/// reader reads them ([`crate::import::style_moves_with`])? +/// reader reads them ([`crate::import::style_moves_with`]), or an image ([`warped_image`]) with a +/// style of one Warp? The reader takes such an image's Warp where it draws as its drawn look. fn object_reads(n: &Node, text: &str) -> bool { let section = known_style("x", text); let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; let group = matches!(n.kind, NodeKind::Group { .. }); - if !read.simple + let image = matches!(n.kind, NodeKind::Image(_)); + if (image && !(read.image_warp && warped_image(n))) + || (!image && !read.simple) || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) || !crate::import::style_moves_with(section.as_bytes(), "x", n) diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 2cc4bed521..bdb3b9cf50 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -82,12 +82,15 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +#[cfg(test)] +pub(crate) use ai::ENVELOPE_DRAWN; pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; pub(crate) use ai::{ - BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, - read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, + BRUSH_MANAGER, BrushesRead, DEFORM_PLUGIN, EnvelopeGridPoint, MAX_CHECKED as ENVELOPE_CHECKS, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, + MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, envelope_outline, envelope_upright, read_art_style, read_brush_object, + read_style_list, style_moves_with, symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 2d6c4a533d..a3de7560e3 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -36,9 +36,9 @@ use crate::ps; pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object}; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; -#[cfg(test)] pub(crate) use envelope::MAX_CHECKED; pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN; +pub(crate) use envelope::{GridPoint as EnvelopeGridPoint, PLUGIN as DEFORM_PLUGIN, outline as envelope_outline, upright as envelope_upright}; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub use obj::{NameEncoding, Names}; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index edb9bba3e3..741ebfabdf 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -41,7 +41,7 @@ use super::obj::Obj; use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compare_scale, differ, differ_in_colour, differ_on_black}; /// The plugin that writes envelopes. -pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use +pub(crate) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use /// The note for an envelope that keeps its drawn art. pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; /// The note for an envelope of type, which keeps its drawn art with its type hidden (unless the @@ -366,12 +366,12 @@ fn only_mesh(n: &Node) -> Option<&GradientMesh> { } /// A grid point and its handles. -type GridPoint = (Point, [Vec2; 4]); +pub(crate) type GridPoint = (Point, [Vec2; 4]); /// The points of mesh `m` as a `rows`×`cols` grid with its first row at the top and its first /// column at the left (y down), as an envelope's mesh lays them out; `None` for a grid out of range /// or with points that aren't finite. -fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { +pub(crate) fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { let (mut rows, mut cols) = (m.rows, m.cols); if rows == 0 || cols == 0 || rows > MAX_GRID || cols > MAX_GRID { return None; @@ -451,7 +451,7 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { } /// The outline of an upright grid: its edge, clockwise from the top left corner. -fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { +pub(crate) fn outline(rows: u32, cols: u32, pts: &[GridPoint]) -> Option { let at = |r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).copied(); // Each edge: from a point by one of its handles to the next point by one of its handles. type Edge = ((u32, u32), usize, (u32, u32), usize); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 3721ce7960..4b2936f56e 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -483,6 +483,9 @@ struct Reader<'a> { /// a global process colour, so these make swatches only for names no colour operator defines. stop_spots: Vec, raster: Option, + /// The samples of the image being read are on `%_` lines (`%_%%BeginData`): it is the copy of + /// a styled object there, as the other objects on those lines are. + image_hidden: bool, /// The file `XG` names for the image after it (`XF`), until an image is read. image_link: Option, /// The placed file being read (`%AI5_BeginPlace` … `~`). @@ -736,6 +739,7 @@ impl<'a> Reader<'a> { spots: Vec::new(), stop_spots: Vec::new(), raster: None, + image_hidden: false, image_link: None, place: None, foreign_data: None, @@ -846,7 +850,7 @@ impl<'a> Reader<'a> { self.op(w, hidden)?; } Tok::Comment(c) => self.comment(c, hidden)?, - Tok::Data(d) => self.image(d)?, + Tok::Data(d) => self.image(d, hidden)?, Tok::Preview(d) => { if let Some(p) = self.place.as_mut() { p.preview = preview_png(d); diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 3d859bf74e..7e0f91e993 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -370,15 +370,18 @@ impl<'a> Reader<'a> { // ---- images ---------------------------------------------------------------------------- - /// The samples after `XI`, its operands on the stack. - pub(super) fn image(&mut self, data: &[u8]) -> Result<(), String> { + /// The samples after `XI`, its operands on the stack; `hidden` when they are on `%_` lines. + pub(super) fn image(&mut self, data: &[u8], hidden: bool) -> Result<(), String> { let vals = self.operands(); // Operands that came before an `XI` on their own line. let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) { Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, _ => vals, }; - self.image_from(vals, data, false) + self.image_hidden = hidden; + let read = self.image_from(vals, data, false); + self.image_hidden = false; + read } /// The matrix and the numbers of an image's operands (`[a b c d tx ty] llx lly urx ury w h bits @@ -713,7 +716,8 @@ impl<'a> Reader<'a> { /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the - /// image, and `colorize` as the color that colorizes it. + /// image, and `colorize` as the color that colorizes it. It is on `%_` lines when its samples + /// are ([`Self::image`]). fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32), colorize: Option) -> Result<(), String> { // The matrix is an art-space matrix (y up) applied to the pixels with their rows running // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead @@ -735,7 +739,8 @@ impl<'a> Reader<'a> { let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; - self.add_image_colorized(im, mask, colorize, false) + // An image on `%_` lines is the copy of a styled object, as the other objects there are. + self.add_image_colorized(im, mask, colorize, self.image_hidden) } /// An image that isn't read is left out, with `warning`; in a symbol's art, the symbol isn't diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9f6a1dd6af..f79e7b9108 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -748,6 +748,7 @@ impl<'a> Reader<'a> { } let space = self.space(); self.note_brushes_kept(); + let page = self.page_in_doc(); let Some(f) = self.frames.last_mut() else { return }; let n = f.children.len(); let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { @@ -780,7 +781,7 @@ impl<'a> Reader<'a> { look }; // A style read here: the object, painted as it says. - if let Some(mut appearance) = self.styles.get(&style).and_then(|st| { + let appearance = self.styles.get(&style).and_then(|st| { styled( object, look, @@ -796,13 +797,32 @@ impl<'a> Reader<'a> { space, }, ) - }) { - // Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up). - let k = space.determinant().abs().sqrt(); - if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 { - appearance.scale_strokes(k); - appearance.scale_effects(k, vectorcraft_effects::scale_effect); + .map(|mut appearance| { + // Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up). + let k = space.determinant().abs().sqrt(); + if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 { + appearance.scale_strokes(k); + appearance.scale_effects(k, vectorcraft_effects::scale_effect); + } + appearance + }) + }); + // An image with a Warp is the object only where its live warp draws as its drawn look, and + // while the import has a check left for it; otherwise its drawn look stays. + let appearance = match appearance { + Some(ap) if matches!(object.kind, NodeKind::Image(_)) && ap.effects.iter().any(|e| e.id.starts_with("warp.")) => { + let alike = self.envelope_checks > 0 && { + self.envelope_checks -= 1; + let layer = self.doc.alloc_id(); + let shared = crate::import::ai::envelope::Shared { patterns: &self.doc.patterns, images: &self.doc.images }; + let shown = !hidden && object.visible && look.visible; + image_warp_alike(object, &ap, look, &self.hidden_ids, hidden, layer, &shared, page.filter(|_| shown), &mut self.envelope_spent) + }; + alike.then_some(ap) } + other => other, + }; + if let Some(appearance) = appearance { let Some(mut object) = f.children.pop() else { return }; let Some(look) = f.children.pop() else { return }; let left_out = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible); @@ -1116,6 +1136,41 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool { }) } +/// Is `object` an image without an opacity mask, and `st` one Warp on it with nothing else that +/// paints ([`crate::import::ai::style::ArtStyle::image_warp`])? Such an image is read with its Warp +/// live where that draws as its drawn look ([`image_warp_alike`]). +fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool { + matches!(object.kind, NodeKind::Image(_)) && object.extras().mask.is_none() && st.image_warp +} + +/// Does image `object` with `appearance` (its style's Warp) draw as `look`, its drawn look, compared +/// over all that the warp draws ([`crate::import::ai::envelope::warped_alike`]) on `layer`, a layer +/// id no other node has? Both are drawn shown, opaque and in the Normal mode. Unless the look is on +/// `%_` lines itself (`hidden`), the copies in it that are on `%_` lines (`hidden_ids`) are left out. +#[allow(clippy::too_many_arguments)] +fn image_warp_alike( + object: &Node, + appearance: &Appearance, + look: &Node, + hidden_ids: &HashSet, + hidden: bool, + layer: NodeId, + shared: &crate::import::ai::envelope::Shared<'_>, + page: Option, + spent: &mut crate::import::ai::envelope::Spent, +) -> bool { + let mut live = object.clone(); + live.appearance = appearance.clone().into(); + let mut plain = look.clone(); + if !hidden { + strip(&mut plain, hidden_ids); + } + for n in [&mut live, &mut plain] { + (n.visible, n.opacity, n.blend) = (true, 1.0, BlendMode::Normal); + } + crate::import::ai::envelope::warped_alike(&live, &plain, layer, shared, page, spent) +} + /// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. const PATTERN_KEY: &str = "\u{1}pattern "; @@ -1128,11 +1183,13 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p let slot = slot_of(object.name.as_deref()).is_some(); // Several fills and strokes, on a path or a compound path. let stacked = !slot && !st.simple && st.stacked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + // One Warp on an image, checked against its drawn look by the caller ([`image_warp_alike`]). + let image_warp = !st.simple && image_warps(object, st); // A symbol instance draws its symbol's art: it keeps its drawn look. if !st.plain || matches!(object.kind, NodeKind::SymbolInstance { .. }) // A look with no outline in it to keep takes a variable-width stroke as the object's own. - || (!slot && !st.simple && !stacked && (!st.uneven || draws(look, &HashSet::new()))) + || (!slot && !st.simple && !stacked && !image_warp && (!st.uneven || draws(look, &HashSet::new()))) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) || (st.pathfinder && (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. }))) @@ -1266,6 +1323,9 @@ pub(crate) struct StyleRead { pub appearance: Appearance, /// An object other than type is read with it ([`crate::import::ai::style::ArtStyle::simple`]). pub simple: bool, + /// An image without an opacity mask is read with it where its live warp draws as its drawn + /// look ([`crate::import::ai::style::ArtStyle::image_warp`]). + pub image_warp: bool, /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. pub pathfinder: bool, /// The opacity, blending mode, isolation and knockout its blend filter gives. @@ -1310,7 +1370,7 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P s.brush = Some(brush); } } - Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) + Some(StyleRead { appearance, simple: st.simple, image_warp: st.image_warp, pathfinder: st.pathfinder, transparency: st.transparency }) } /// Is art style `name` of `section` read on `object` as far as an effect that moves the outline @@ -1519,6 +1579,79 @@ mod tests { assert!(styled(&Node::new(NodeId(4), NodeKind::Image(Box::new(image))), &look, st, &paints).is_none()); } + /// The style `Fx`: `effects` (each a filter's name and its dictionary's keys) on the whole + /// object, over a fill and a stroke that `paints` turns on (`0 1`: the fill paints blue). + fn effects_style(effects: &[(&str, &str)], paints: &str) -> crate::import::ai::style::ArtStyle { + let filters: String = effects + .iter() + .map(|(name, keys)| { + format!("/BasicFilter :\n({name}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n") + }) + .collect(); + let text = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + {filters}/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n{paints} Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ); + crate::import::ai::style::art_styles(text.as_bytes()).remove("Fx").unwrap_or_default() + } + + /// An image takes a style of one Warp, under fills and strokes that paint nothing, and no + /// other style with a geometry effect: those keep its drawn look. A path, and an image with an + /// opacity mask, keep their drawn look with the Warp too. + #[test] + fn an_image_takes_a_style_of_one_warp_alone() { + // GUARDS: an image with a Warp in its art style came in as its drawn look. + let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files + let (gradients, patterns, names, v7_patterns, bending) = + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + let paints = StylePaints { + gradients: &gradients, + patterns: &patterns, + names: &names, + v7_patterns: &v7_patterns, + brushes: &[], + kept: &[], + bending: &bending, + space: Affine::IDENTITY, + }; + let look = Node::group(NodeId(1), vec![square(2, Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))]); + let image = + ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let image = Node::new(NodeId(4), NodeKind::Image(Box::new(image))); + let st = effects_style(&[warp], "0 0"); + assert!(st.image_warp && !st.simple, "{st:?}"); + let ap = styled(&image, &look, &st, &paints).expect("an image takes one Warp"); + let ids: Vec<&str> = ap.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["warp.arc"]); + assert!(ap.items.iter().all(|i| i.paint().is_none()), "{ap:?}"); + // Not on a path, nor on an image with an opacity mask. + assert!(styled(&square(3, Paint::None), &look, &st, &paints).is_none()); + let mut masked = image.clone(); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(7), vec![]), true))); + assert!(styled(&masked, &look, &st, &paints).is_none()); + // A Warp over a fill that paints, a Warp with another effect, and the other geometry effects. + let still = ("Adobe Punk and Bloat", "0 /Real (d_factor) ,\n"); + let others: [(&[(&str, &str)], &str); 8] = [ + (&[warp], "0 1"), + (&[warp, still], "0 0"), + (&[still, warp], "0 0"), + (&[("Adobe Twirl", "45 /Real (angle) ,\n")], "0 0"), + (&[("Adobe Free Distort", "")], "0 0"), + (&[("Adobe Punk and Bloat", "-50 /Real (d_factor) ,\n")], "0 0"), + (&[("Adobe Zigzag", "3 /Real (amount) ,\n1 /Real (absoluteness) ,\n")], "0 0"), + (&[("Adobe Transform", "5 /Real (moveH_Pts) ,\n0.5 /Real (rotate_Radians) ,\n0 /Bool (transformPatterns) ,\n")], "0 0"), + ]; + for (effects, painted) in others { + let st = effects_style(effects, painted); + assert!(!st.image_warp, "{effects:?} {painted}"); + assert!(styled(&image, &look, &st, &paints).is_none(), "{effects:?} {painted}"); + } + } + #[test] fn data_stops_at_its_end_or_a_marker_of_the_section() { let section = Some(("BeginTextDocument", "EndTextDocument")); diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 482c172386..d27a297925 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -193,6 +193,10 @@ pub(super) struct ArtStyle { /// the effects any object takes that don't move the outline: what a path or compound path is /// read with when each gradient is placed as its drawn look has it (`read::ops`' `styled`). pub stacked: bool, + /// It is one Warp on the whole object, under fills and strokes that paint nothing and have no + /// effects of their own: what an image is read with when its live warp draws as its drawn look + /// (`read::ops`' `styled`). + pub image_warp: bool, /// Its fills and strokes, bottom first. pub items: Vec, /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. @@ -1778,6 +1782,10 @@ fn style(filters: &[Filter]) -> ArtStyle { && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. })) && st.looks.iter().all(|l| l.effects.is_empty()) && st.effects.iter().all(any_object_takes); + st.image_warp = st.plain + && matches!(st.effects.as_slice(), [e] if e.id.starts_with("warp.")) + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(p) | StyleItem::Stroke { paint: p, .. } if p.is_none())) + && st.looks.iter().all(|l| l.effects.is_empty()); st } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 3c0ac39a05..9b86f38350 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7730,6 +7730,55 @@ fn a_top_object_envelope_opens_live_with_its_outline() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A top object whose saved grid has three patches across, its top edge one curve split into +/// thirds, maps its contents along that one curve. In this synthetic file the drawn art is drawn by +/// VectorCraft's own map of the unsplit curve, so the envelope opens live only when the split side +/// maps the same way, and the contents' first quarter ends a quarter of the way along the curve. +#[test] +fn a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live() { + use kurbo::{CubicBez, ParamCurve}; + use vectorcraft_doc::live::EnvelopeKind; + // In the document: the top runs from (40, 20) to (160, 20), its parameter fast at the start. + let top = CubicBez::new((40.0, 20.0), (112.0, -16.0), (154.0, 2.0), (160.0, 20.0)); + let thirds = [top.subsegment(0.0..1.0 / 3.0), top.subsegment(1.0 / 3.0..2.0 / 3.0), top.subsegment(2.0 / 3.0..1.0)]; + // Art space has y up: row 1 is the top. The bottom row and the columns are straight. + let up = |p: Point| (p.x, 100.0 - p.y); + let at = move |(i, j): (usize, usize)| { + if j == 1 { up(if i == 0 { top.p0 } else { thirds[i - 1].p3 }) } else { (40.0 + 40.0 * i as f64, 20.0) } + }; + let handle = move |p: (usize, usize), q: (usize, usize)| match (p, q) { + ((i, 1), (k, 1)) if k == i + 1 => up(thirds[i].p1), + ((i, 1), (k, 1)) if k + 1 == i => up(thirds[k].p2), + _ => { + let ((x, y), (u, v)) = (at(p), at(q)); + (x + (u - x) / 3.0, y + (v - y) / 3.0) + } + }; + let shape = mesh_text(3, 1, &at, &handle, None); + // The contents: two red bars at the box's left and right ends, drawn by the unsplit curve. + let mut bp = kurbo::BezPath::new(); + bp.move_to(top.p0); + bp.curve_to(top.p1, top.p2, top.p3); + bp.line_to((160.0, 80.0)); + bp.line_to((40.0, 80.0)); + bp.close_path(); + let one_curve = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp), corners: Some([0, 1, 2, 3]) }; + let src = Rect::new(40.0, 20.0, 160.0, 80.0); + let bars = [Rect::new(40.0, 20.0, 70.0, 80.0), Rect::new(130.0, 20.0, 160.0, 80.0)]; + let content: String = bars.iter().map(|b| format!("{RED}{}f\n", rect_path(b.x0, b.y0, b.x1, b.y1))).collect(); + let drawn: String = bars.iter().map(|b| drawn_box(&one_curve, src, *b)).collect(); + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &content, 3, &drawn))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let (_, kind, ..) = live_envelope(&a[0]); + let EnvelopeKind::TopObject { path, .. } = kind else { panic!("{kind:?}") }; + assert_eq!(path.anchor_count(), 8, "{path:?}"); + let map = vectorcraft_doc::live::envelope_mapper(kind, src); + let (p, want) = (map(Point::new(70.0, 20.0)), top.eval(0.25)); + assert!((p - want).hypot() < 0.02, "{p:?}, want {want:?}"); +} + /// Envelope Options come from the options word, Fidelity from its own key. #[test] fn an_envelope_reads_its_options_and_fidelity() { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index e8ffa4b278..0ec561dd31 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -55,8 +55,99 @@ const MAX_DEVICE_N: usize = 32; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; const TOO_LARGE: &str = "the artwork is too large for EPS: what follows 256 MB of PostScript was left out"; /// The most PostScript the page body may grow to: symbols and patterns that hold one another -/// expand into every instance, so nesting depth alone doesn't bound the output. +/// expand into every instance, so nesting depth alone doesn't bound the output. An image placed +/// more than once counts its data once. const MAX_BODY: usize = 256 << 20; +/// Most compressed bytes in one string of an image's samples at Level 2 (under PostScript's +/// 65,535-byte string limit). +const IMAGE_CHUNK: usize = 48 << 10; +/// The start of the procedure that reads an image's data from the lines after it. +const INLINE_SOURCE: &str = "{ /VCsrc currentfile /ASCII85Decode filter def\n"; + +/// An image as written: what sets up its color, its image dictionary and its compressed samples. +/// Placed once, it is written where it is drawn, its data following the operators that read it. +/// Placed more than once, its samples are written once in the setup as `VCim` (a reusable +/// stream at Level 3, an array of strings at Level 2) and each placement reads them from there. +struct ImageCode { + /// The color space or ink operators, each line ending in a line break. + prelude: String, + /// `<< /ImageType … /ImageMatrix […]` with its mask colors: the dictionary up to its data source. + dict: String, + /// The decoding filter of the samples: `/FlateDecode` or `/RunLengthDecode`. + filter: &'static str, + /// `image` or `imagemask`. + op: &'static str, + /// The compressed samples in ASCII85, as lines with the end marker. + ascii: String, + /// The compressed samples, kept at Level 2, where a reused image's samples are written as + /// strings of at most [`IMAGE_CHUNK`] bytes. + data: Vec, + /// How many placements in the page draw it. + uses: usize, + /// The length of [`ImageCode::push_inline`]'s text. + inline_len: usize, + /// The length of [`ImageCode::reference`]'s text. + ref_len: usize, +} + +impl ImageCode { + /// The operators and data that draw the image where it is placed. + fn push_inline(&self, s: &mut String) { + s.push_str(&self.prelude); + s.push_str(INLINE_SOURCE); + s.push_str(&self.dict); + s.push_str(&self.inline_read()); + s.push_str(&self.ascii); + } + + /// The end of the procedure [`ImageCode::push_inline`] writes. The procedure is read whole + /// before it runs: the image data follows it, and flushing the ASCII85 filter afterwards reads + /// past its end marker. + fn inline_read(&self) -> String { + format!("\n/DataSource VCsrc {} filter >> {}\nVCsrc flushfile }} exec\n", self.filter, self.op) + } + + /// The operators that draw the image `VCim` holds, for an image placed more than once. + fn reference(&self, i: usize, level: Level) -> String { + match level { + // The stream is read again from its start. + Level::Three => { + format!("{}VCim{i} resetfile\n{}\n/DataSource VCim{i} {} filter >> {}\n", self.prelude, self.dict, self.filter, self.op) + } + // A procedure gives the strings in turn, then an empty string: the end of the data. + Level::Two => format!( + "{}/VCi 0 def\n{}\n/DataSource {{VCi VCim{i} length lt {{VCim{i} VCi get /VCi VCi 1 add def}} {{()}} ifelse}} {} filter >> {}\n", + self.prelude, self.dict, self.filter, self.op + ), + } + } + + /// The setup's definition of `VCim`: the image's compressed samples, once. + fn resource(&self, i: usize, level: Level) -> String { + let mut s = String::with_capacity(self.ascii.len() + 200); + match level { + Level::Three => { + // The stream reads all of its data when it is made, so the data follows the + // procedure that makes it. + let intent = if self.op == "imagemask" { 1 } else { 0 }; + let _ = writeln!( + s, + "{{ /VCsrc currentfile /ASCII85Decode filter def\n/VCim{i} VCsrc << /Intent {intent} /AsyncRead false /CloseSource false >> /ReusableStreamDecode filter def\nVCsrc flushfile }} exec" + ); + s.push_str(&self.ascii); + } + Level::Two => { + let _ = writeln!(s, "/VCim{i} ["); + for chunk in self.data.chunks(IMAGE_CHUNK) { + s.push_str("<~"); + s.push_str(&ps::ascii85(chunk)); + } + s.push_str("] def\n"); + } + } + s + } +} /// What the walk wrote: definitions for the setup, the page, the spot colours it uses (name and /// CMYK equivalent) and the warnings. @@ -157,10 +248,18 @@ pub(crate) struct Scene<'a> { device_n: Vec, warnings: Vec, brushes: Option>, - /// Image keys → what draws them (`None`: left out). - images: HashMap>>, + /// Image keys → the index in `codes` of what draws them (`None`: left out). + images: HashMap>, /// The source key and solid paint together identify a colorized encoding. - colorized_images: HashMap<(String, String), Option>>, + colorized_images: HashMap<(String, String), Option>, + /// The encoded images. + codes: Vec, + /// Where in `body` each placement of an encoded image goes (in order) and the index of its code. + placements: Vec<(usize, usize)>, + /// What the placements in `placements` add to `body`'s length when the page is finished. + pending: usize, + /// The most the page body may grow to ([`MAX_BODY`]). + max_body: usize, /// Symbols and brush art being written inside one another. nest: u32, } @@ -181,10 +280,21 @@ impl<'a> Scene<'a> { brushes: None, images: HashMap::new(), colorized_images: HashMap::new(), + codes: vec![], + placements: vec![], + pending: 0, + max_body: MAX_BODY, nest: 0, } } + /// This scene with a page body of at most `max` bytes. + #[cfg(test)] + pub fn with_max_body(mut self, max: usize) -> Self { + self.max_body = max; + self + } + pub fn run(mut self) -> Page { let doc = self.doc; for l in &doc.layers { @@ -199,7 +309,27 @@ impl<'a> Scene<'a> { self.page() } - fn page(self) -> Page { + fn page(mut self) -> Page { + if !self.placements.is_empty() { + let level = self.o.level; + let mut body = String::with_capacity(self.body.len().saturating_add(self.pending)); + let mut at = 0; + for &(offset, i) in &self.placements { + body.push_str(self.body.get(at..offset).unwrap_or_default()); + at = offset; + let Some(code) = self.codes.get(i) else { continue }; + if code.uses > 1 { + body.push_str(&code.reference(i, level)); + } else { + code.push_inline(&mut body); + } + } + body.push_str(self.body.get(at..).unwrap_or_default()); + self.body = body; + for (i, code) in self.codes.iter().enumerate().filter(|(_, c)| c.uses > 1) { + self.setup.push_str(&code.resource(i, level)); + } + } let custom = self .spots .iter() @@ -337,7 +467,7 @@ impl<'a> Scene<'a> { if let NodeKind::Layer { template: true, .. } = n.kind { return; } - if self.body.len() >= MAX_BODY { + if self.size() >= self.max_body { self.warn(TOO_LARGE); return; } @@ -347,7 +477,7 @@ impl<'a> Scene<'a> { _ => {} } self.note_losses(n); - let was = self.body.len(); + let (was, placed) = (self.body.len(), self.placements.len()); match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().for_each(|c| self.node(c, false)); @@ -402,12 +532,29 @@ impl<'a> Scene<'a> { // Ruler guides draw on screen only. NodeKind::RulerGuide { .. } => {} } - if self.body.len() > MAX_BODY { + if self.size() > self.max_body { self.body.truncate(was); + self.unplace(placed); self.warn(TOO_LARGE); } } + /// How long the page body will be: what is written and what its image placements add. + fn size(&self) -> usize { + self.body.len().saturating_add(self.pending) + } + + /// Take back the image placements after the first `n`, last first. + fn unplace(&mut self, n: usize) { + while self.placements.len() > n { + let Some((_, i)) = self.placements.pop() else { break }; + let Some(code) = self.codes.get_mut(i) else { continue }; + code.uses = code.uses.saturating_sub(1); + let cost = if code.uses == 0 { code.inline_len } else { code.ref_len }; + self.pending = self.pending.saturating_sub(cost); + } + } + /// A clipping group: the art inside its first child's outline. The clipping path's fills /// paint behind the art (clipped) and its strokes over it (not clipped). fn clip_group(&mut self, children: &[Arc]) { @@ -1229,7 +1376,7 @@ impl<'a> Scene<'a> { }; let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height()); if let Some(image) = image { - self.place_image(&image, place); + self.place_inline(&image, place); } } @@ -1241,18 +1388,29 @@ impl<'a> Scene<'a> { return; } let encoded = match self.images.get(key) { - Some(e) => e.clone(), + Some(e) => *e, None => { - let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new); - self.images.insert(key.to_string(), e.clone()); + let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(|c| self.add_code(c)); + self.images.insert(key.to_string(), e); e } }; - if let Some(e) = encoded { - self.place_image(&e, place); + if let Some(i) = encoded { + self.place_image(i, place); } } + /// Keep `code` for the placements that draw it: its index in `codes`. + fn add_code(&mut self, mut code: ImageCode) -> usize { + let i = self.codes.len(); + if self.o.level == Level::Three { + code.data = vec![]; + } + code.ref_len = code.reference(i, self.o.level).len(); + self.codes.push(code); + i + } + /// Gray coverage in the selected process or Separation ink. Binary coverage is a stencil; /// continuous coverage leaves zero pixels untouched at Level 3 and writes them as paper at Level 2. fn colorized_image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, paint: &Paint) { @@ -1262,19 +1420,19 @@ impl<'a> Scene<'a> { } let cache = (key.to_string(), format!("{paint:?}")); let encoded = match self.colorized_images.get(&cache) { - Some(e) => e.clone(), + Some(e) => *e, None => { - let e = self.encode_colorized(blob, paint).map(Arc::new); - self.colorized_images.insert(cache, e.clone()); + let e = self.encode_colorized(blob, paint).map(|c| self.add_code(c)); + self.colorized_images.insert(cache, e); e } }; - if let Some(e) = encoded { - self.place_image(&e, place); + if let Some(i) = encoded { + self.place_image(i, place); } } - fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option { + fn encode_colorized(&mut self, blob: Option<&ImageBlob>, paint: &Paint) -> Option { let Some((w, h, coverage)) = blob.and_then(ImageBlob::coverage) else { self.warn(UNDECODED); return None; @@ -1355,17 +1513,17 @@ impl<'a> Scene<'a> { ("/RunLengthDecode", rl) } }; - let mut out = String::new(); + let mut prelude = String::new(); if binary { // imagemask takes its current ink from the graphics state. - out.push_str(&self.ink_code(ink)); + prelude.push_str(&self.ink_code(ink)); } else { match ink { Ink::Spot { space, .. } => { - let _ = writeln!(out, "VCsp{space} setcolorspace"); + let _ = writeln!(prelude, "VCsp{space} setcolorspace"); } Ink::Process(space, ..) => { - let _ = writeln!(out, "{} setcolorspace", space.name()); + let _ = writeln!(prelude, "{} setcolorspace", space.name()); } } } @@ -1374,19 +1532,16 @@ impl<'a> Scene<'a> { Ink::Process(space, ..) => space.n(), }; let decode = if binary { "1 0".to_string() } else { "0 1 ".repeat(n).trim_end().to_string() }; - let _ = write!( - out, - "{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]", + let mut dict = format!( + "<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent {} /Decode [{decode}] /ImageMatrix [{w} 0 0 {h} 0 0]", if keyed { 4 } else { 1 }, if binary { 1 } else { 8 }, ); if keyed { let mask: Vec = key.iter().take(n).map(|v| format!("{v} {v}")).collect(); - let _ = write!(out, " /MaskColor [{}]", mask.join(" ")); + let _ = write!(dict, " /MaskColor [{}]", mask.join(" ")); } - let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> {}\nVCsrc flushfile }} exec", if binary { "imagemask" } else { "image" }); - out.push_str(&ps::ascii85(&encoded)); - Some(out) + Some(image_code(prelude, dict, filter, if binary { "imagemask" } else { "image" }, encoded)) } /// Ink-setting operators in the cached stencil body, built with the same path as solid fills. @@ -1409,18 +1564,40 @@ impl<'a> Scene<'a> { Some(img.to_rgba8()) } - /// Draw an image of [`Self::encode_image`] on the unit square mapped by `place`. - fn place_image(&mut self, encoded: &str, place: Affine) { + /// Draw the image of `codes[i]` on the unit square mapped by `place`. Its operators go in when + /// the page is finished ([`Self::page`]), once it is known how many placements draw it. + fn place_image(&mut self, i: usize, place: Affine) { if !invertible(place) { return; } - if self.body.len().saturating_add(encoded.len()).saturating_add(64) > MAX_BODY { + let Some(code) = self.codes.get(i) else { return }; + let cost = if code.uses == 0 { code.inline_len } else { code.ref_len }; + if self.size().saturating_add(cost).saturating_add(64) > self.max_body { self.warn(TOO_LARGE); return; } self.line("q"); let _ = writeln!(self.body, "{} cm", ps::matrix(place)); - self.body.push_str(encoded); + self.placements.push((self.body.len(), i)); + self.pending = self.pending.saturating_add(cost); + if let Some(code) = self.codes.get_mut(i) { + code.uses += 1; + } + self.line("Q"); + } + + /// Draw `code`, an image drawn nowhere else, on the unit square mapped by `place`. + fn place_inline(&mut self, code: &ImageCode, place: Affine) { + if !invertible(place) { + return; + } + if self.size().saturating_add(code.inline_len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + self.line("q"); + let _ = writeln!(self.body, "{} cm", ps::matrix(place)); + code.push_inline(&mut self.body); self.line("Q"); } @@ -1428,7 +1605,7 @@ impl<'a> Scene<'a> { /// them on the unit square: in the process colour space, transparent pixels masked out at /// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over /// white. `None` for an empty image. - fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option { + fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option { let space = if self.cmyk { Space::Cmyk } else { Space::Rgb }; let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new(); let cms = self.cms.clone(); @@ -1447,7 +1624,7 @@ impl<'a> Scene<'a> { /// Straight CMYK pixels with opacity (five bytes a pixel, `w` × `h`, rows from the top), drawn /// as [`Self::encode_image`] draws its pixels and keeping their ink amounts: partly /// transparent pixels lay down that share of each ink. - fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option { + fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option { self.encode_samples(cmyka.as_chunks::<5>().0, w, h, Space::Cmyk, |p: &[u8; 5], al| { [0, 1, 2, 3].map(|i| ((u32::from(p[i]) * al + 127) / 255) as u8) }) @@ -1463,7 +1640,7 @@ impl<'a> Scene<'a> { h: u32, space: Space, mut sample: impl FnMut(&[u8; N], u32) -> [u8; 4], - ) -> Option { + ) -> Option { if w == 0 || h == 0 { return None; } @@ -1503,27 +1680,29 @@ impl<'a> Scene<'a> { ("/RunLengthDecode", rl) } }; - let mut out = String::with_capacity(encoded.len() * 5 / 4 + 400); - let _ = writeln!(out, "{} setcolorspace", space.name()); + let prelude = format!("{} setcolorspace\n", space.name()); let decode = "0 1 ".repeat(n); - let _ = write!( - out, - "{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent 8 /Decode [{}] /ImageMatrix [{w} 0 0 {h} 0 0]", + let mut dict = format!( + "<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent 8 /Decode [{}] /ImageMatrix [{w} 0 0 {h} 0 0]", if keyed { 4 } else { 1 }, decode.trim_end() ); if keyed { let mask: Vec = key.iter().take(n).map(|v| format!("{v} {v}")).collect(); - let _ = write!(out, " /MaskColor [{}]", mask.join(" ")); + let _ = write!(dict, " /MaskColor [{}]", mask.join(" ")); } - // The procedure is read whole before it runs: the image data follows it, and flushing the - // ASCII85 filter afterwards reads past its end marker. - let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> image\nVCsrc flushfile }} exec"); - out.push_str(&ps::ascii85(&encoded)); - Some(out) + Some(image_code(prelude, dict, filter, "image", encoded)) } } +/// An image of `prelude`, `dict`, `filter` and `op` (see [`ImageCode`]) with compressed samples +/// `data`, not yet placed. +fn image_code(prelude: String, dict: String, filter: &'static str, op: &'static str, data: Vec) -> ImageCode { + let mut code = ImageCode { prelude, dict, filter, op, ascii: ps::ascii85(&data), data, uses: 0, inline_len: 0, ref_len: 0 }; + code.inline_len = code.prelude.len() + INLINE_SOURCE.len() + code.dict.len() + code.inline_read().len() + code.ascii.len(); + code +} + /// `a` to `b` at `t`. fn mix(a: &[f32], b: &[f32], t: f32) -> Vec { a.iter().zip(b).map(|(x, y)| x + (y - x) * t).collect() diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 13bdbb80e2..0d5649ead5 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -82,6 +82,17 @@ fn checked_page(ps: &str) -> String { } } '{' | '[' => stack.push(c), + // An ASCII85 string: its digits are data. + '<' if chars.peek() == Some(&'~') => { + let mut last = ' '; + loop { + let c = chars.next().expect("ASCII85 string closes"); + if last == '~' && c == '>' { + break; + } + last = c; + } + } '<' if chars.peek() == Some(&'<') => { chars.next(); stack.push('<'); @@ -679,15 +690,20 @@ fn png_doc() -> Document { for (x, _, p) in img.enumerate_pixels_mut() { *p = if x < 2 { image::Rgba([0, 200, 0, 255]) } else { image::Rgba([0, 0, 0, 0]) }; } + image_doc(&img, 10.0) +} + +/// A 200 × 200 pt document holding `img` as a PNG, its pixels `scale` points wide, at (10, 10). +fn image_doc(img: &image::RgbaImage, scale: f64) -> Document { let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut d = Document::new(200.0, 200.0); d.images.insert("pic".into(), ImageBlob::new("image/png", png)); let im = ImageObject { key: "pic".into(), - width: 4, - height: 2, - xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0), + width: img.width(), + height: img.height(), + xf: Affine::translate((10.0, 10.0)) * Affine::scale(scale), link: None, placement: Default::default(), pixels: None, @@ -988,6 +1004,219 @@ fn uncolorized_image_stays_on_original_eps_path() { assert_eq!(page_of(&color, &opts(&color)), original); } +/// A copy of the first layer's object `i`, moved by `xf`, added on top. +fn place_again(d: &mut Document, i: usize, xf: Affine) { + let mut n = (*d.layers[0].children().unwrap()[i]).clone(); + n.id = d.alloc_id(); + if let NodeKind::Image(im) = &mut n.kind { + im.xf = xf * im.xf; + } + d.insert(d.default_layer(), usize::MAX, n).unwrap(); +} + +/// Pixels of noise, opaque. +fn noise(w: u32, h: u32) -> image::RgbaImage { + let mut seed = 1u32; + image::RgbaImage::from_fn(w, h, |_, _| { + let mut px = [255u8; 4]; + for v in &mut px[..3] { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + *v = (seed >> 16) as u8; + } + image::Rgba(px) + }) +} + +/// The pixels of each image an EPS file's page draws, read back from the page. +fn imported_images(bytes: &[u8]) -> Vec> { + fn walk(n: &Node, d: &Document, out: &mut Vec>) { + if let NodeKind::Image(im) = &n.kind { + out.push(image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8().into_raw()); + } + for c in n.children().into_iter().flatten() { + walk(c, d, out); + } + } + let r = import_with(bytes, false).unwrap(); + assert!(!r.preview, "{:?}", r.warnings); + let mut out = vec![]; + for l in &r.document.layers { + walk(l, &r.document, &mut out); + } + out +} + +/// The samples of an image placed more than once, from the setup that defines them: inflated at +/// Level 3, each string run-length decoded in turn at Level 2. +fn reused_samples(ps: &str, level: Level) -> Vec { + let setup = &ps[ps.find("%%BeginSetup").unwrap()..ps.find("%%EndSetup").unwrap()]; + match level { + Level::Three => ps::inflate(&image_data(setup), 1 << 28).unwrap(), + Level::Two => { + let mut data = vec![]; + for s in setup.split("<~").skip(1) { + let chunk = ps::ascii85_decode(&s[..s.find("~>").unwrap() + 2]).unwrap(); + assert!(chunk.len() <= 65_535, "a string holds at most 65,535 bytes: {}", chunk.len()); + data.extend(chunk); + } + unpack(&data) + } + } +} + +/// The samples of a single image written where it is drawn. +fn inline_samples(ps: &str, level: Level) -> Vec { + match level { + Level::Three => ps::inflate(&image_data(ps), 1 << 28).unwrap(), + Level::Two => unpack(&image_data(ps)), + } +} + +/// How many placements draw the image the setup defines as `VCim`. +fn reuses(ps: &str, i: usize, level: Level) -> usize { + match level { + Level::Three => ps.matches(&format!("VCim{i} resetfile\n")).count(), + Level::Two => ps.matches(&format!("{{VCi VCim{i} length lt")).count(), + } +} + +#[test] +fn an_image_placed_twice_writes_its_samples_once_at_both_levels() { + let single = png_doc(); + let mut d = single.clone(); + place_again(&mut d, 0, Affine::translate((60.0, 80.0)) * Affine::rotate(0.5)); + for level in Level::ALL { + let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap(); + let one_ps = text(&one); + assert!(!one_ps.contains("VCim"), "an image placed once is written where it is drawn: {one_ps}"); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + assert_eq!(reuses(&ps, 0, level), 2, "{ps}"); + assert!(!ps.contains("VCim1"), "{ps}"); + let body = &ps[ps.find("%%EndSetup").unwrap()..]; + assert!(!body.contains("currentfile") && !body.contains("~>"), "no samples in the page: {ps}"); + match level { + Level::Three => { + assert_eq!(ps.matches("/ReusableStreamDecode filter def").count(), 1, "{ps}"); + assert!(ps.contains("/Intent 0 /AsyncRead false /CloseSource false"), "{ps}"); + assert!(body.contains("/DataSource VCim0 /FlateDecode filter >> image\n"), "{ps}"); + } + Level::Two => { + assert!(ps.contains("/VCim0 [\n<~") && !ps.contains("ReusableStreamDecode"), "{ps}"); + assert!(body.contains("/VCi 0 def\n") && body.contains("/RunLengthDecode filter >> image\n"), "{ps}"); + } + } + assert_eq!(reused_samples(&ps, level), inline_samples(&one_ps, level)); + assert_eq!(out.warnings, one.warnings); + // Read back, each placement draws the image the single placement draws. + let once = imported_images(&one.bytes); + assert_eq!(once.len(), 1); + assert_eq!(imported_images(&out.bytes), vec![once[0].clone(); 2], "{level:?}"); + } +} + +/// An image in an envelope is drawn as clipped pieces, all from one copy of its samples, which the +/// image placed beside the envelope also draws. +#[test] +fn an_envelopes_image_pieces_draw_one_copy_of_its_samples() { + let mut d = png_doc(); + let mut content = (*d.layers[0].children().unwrap()[0]).clone(); + content.id = d.alloc_id(); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; + let envelope = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content: vec![Arc::new(content)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }, + ); + d.insert(d.default_layer(), usize::MAX, envelope).unwrap(); + for level in Level::ALL { + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + // The plain image and 200 pieces: 10 × 10 cells of two triangles. + assert_eq!(reuses(&ps, 0, level), 201, "{ps}"); + assert!(!ps.contains("VCim1"), "{ps}"); + assert_eq!(ps.matches(if level == Level::Three { "/ReusableStreamDecode" } else { "<~" }).count(), 1, "{ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("too large")), "{:?}", out.warnings); + } +} + +#[test] +fn each_ink_of_a_colorized_image_placed_twice_is_written_once() { + let first = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }; + let mut d = colorized_doc(vec![0, 255, 0], Some(first)); + let mut n = (*d.layers[0].children().unwrap()[0]).clone(); + n.id = d.alloc_id(); + n.extras_mut().colorize = Some(Box::new(Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Ink".into()), tint: 0.5 })); + d.insert(d.default_layer(), usize::MAX, n).unwrap(); + place_again(&mut d, 0, Affine::translate((0.0, 50.0))); + place_again(&mut d, 1, Affine::translate((0.0, 100.0))); + for level in Level::ALL { + let ps = page_of(&d, &EpsOptions { level, ..opts(&d) }); + assert_eq!((reuses(&ps, 0, level), reuses(&ps, 1, level)), (2, 2), "{ps}"); + assert_eq!(ps.matches("VCsp0 setcolorspace 1 setcolor\n").count(), 2, "{ps}"); + assert_eq!(ps.matches("VCsp0 setcolorspace 0.5 setcolor\n").count(), 2, "{ps}"); + assert_eq!(ps.matches("imagemask").count(), 4, "{ps}"); + if level == Level::Three { + assert_eq!(ps.matches("/Intent 1 ").count(), 2, "image masks: {ps}"); + } + } +} + +/// Samples longer than PostScript's longest string: one stream at Level 3, several strings at +/// Level 2, each placement reading all of them. +#[test] +fn samples_longer_than_a_string_are_reused_at_both_levels() { + let single = image_doc(&noise(160, 160), 1.0); + let mut d = single.clone(); + place_again(&mut d, 0, Affine::translate((20.0, 0.0))); + for level in Level::ALL { + let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap(); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + checked_page(&ps); + assert_eq!(reuses(&ps, 0, level), 2); + if level == Level::Two { + assert_eq!(ps.matches("<~").count(), 2, "160 × 160 × 3 run-length bytes take two strings"); + } + let samples = reused_samples(&ps, level); + assert_eq!(samples.len(), 160 * 160 * 3); + assert_eq!(samples, inline_samples(&text(&one), level)); + let once = imported_images(&one.bytes); + assert_eq!(imported_images(&out.bytes), vec![once[0].clone(); 2], "{level:?}"); + } +} + +/// The limit on the page body counts the samples of an image placed many times once. Art taken +/// back for passing the limit takes its image placements with it. +#[test] +fn the_page_limit_counts_a_repeated_images_samples_once() { + let single = image_doc(&noise(40, 40), 1.0); + let o = opts(&single); + let inline = crate::scene::Scene::new(&single, &o).run().body.len(); + let mut d = single.clone(); + for k in 1..20 { + place_again(&mut d, 0, Affine::translate((f64::from(k), 0.0))); + } + let page = crate::scene::Scene::new(&d, &o).with_max_body(inline * 3).run(); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); + assert_eq!(reuses(&page.body, 0, Level::Three), 20); + assert!(page.setup.contains("/VCim0 VCsrc")); + // Past the limit, the placements that don't fit are left out with a warning. + let page = crate::scene::Scene::new(&d, &o).with_max_body(inline + 1000).run(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings); + let placed = reuses(&page.body, 0, Level::Three); + assert!((2..20).contains(&placed), "{placed}"); + assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len()); +} + #[test] fn run_length_and_ascii85_round_trip() { let cycle: Vec = (0..=255u8).cycle().take(1000).collect(); diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 59a8f30660..ac7dc63add 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -165,6 +165,26 @@ impl Anchor { pub fn reversed(&self) -> Self { Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind } } + /// Make the anchor smooth between the points `prev` and `next` (its own point for a missing + /// one): handles in line with them, each a third of the way to that side's point. A smooth + /// anchor with both handles keeps them. False, with no change, when the two points give no + /// direction. [`SubPath::smooth_anchor`] calls it with the anchor's neighbors. + pub fn smooth_between(&mut self, prev: Option, next: Option) -> bool { + if self.kind == AnchorKind::Smooth && self.has_in() && self.has_out() { + return true; + } + let (pp, nn) = (prev.unwrap_or(self.p), next.unwrap_or(self.p)); + let dir = nn - pp; + let l = dir.hypot(); + if l <= 1e-9 { + return false; + } + let u = dir / l; + self.h_in = self.p - u * (self.p.distance(pp) / 3.0); + self.h_out = self.p + u * (self.p.distance(nn) / 3.0); + self.kind = AnchorKind::Smooth; + true + } /// Remove both handles (convert to a sharp corner). pub fn retract(&mut self) { self.h_in = self.p; @@ -398,21 +418,7 @@ impl SubPath { }; let at = |j: Option| j.and_then(|j| self.anchors.get(j)).map(|a| a.p); let (prev, next) = (at(prev), at(next)); - let Some(a) = self.anchors.get_mut(i) else { return false }; - if a.kind == AnchorKind::Smooth && a.has_in() && a.has_out() { - return true; - } - let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p)); - let dir = nn - pp; - let l = dir.hypot(); - if l <= 1e-9 { - return false; - } - let u = dir / l; - a.h_in = a.p - u * (a.p.distance(pp) / 3.0); - a.h_out = a.p + u * (a.p.distance(nn) / 3.0); - a.kind = AnchorKind::Smooth; - true + self.anchors.get_mut(i).is_some_and(|a| a.smooth_between(prev, next)) } /// Cut at the anchors `at`: each becomes the end of one open piece and the start of the next, /// its handle off each piece retracted (the shape doesn't change). A closed subpath opens at @@ -688,7 +694,8 @@ fn box_gap(c: &CubicBez, p: Point) -> Option { const MEASURE_LIMIT: f64 = 1e9; /// The length of `seg` to `accuracy`; 0 for a segment whose control points are not finite or past -/// [`MEASURE_LIMIT`]. +/// [`MEASURE_LIMIT`], and 0 when the measurement is not finite (kurbo gives NaN for a quadratic +/// segment whose three points coincide). pub fn measured_arclen(seg: &PathSeg, accuracy: f64) -> f64 { let ok = |p: Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MEASURE_LIMIT && p.y.abs() <= MEASURE_LIMIT; let fits = match seg { @@ -696,7 +703,11 @@ pub fn measured_arclen(seg: &PathSeg, accuracy: f64) -> f64 { PathSeg::Quad(q) => ok(q.p0) && ok(q.p1) && ok(q.p2), PathSeg::Cubic(c) => ok(c.p0) && ok(c.p1) && ok(c.p2) && ok(c.p3), }; - if fits { kurbo::ParamCurveArclen::arclen(seg, accuracy) } else { 0.0 } + if !fits { + return 0.0; + } + let len = kurbo::ParamCurveArclen::arclen(seg, accuracy); + if len.is_finite() { len } else { 0.0 } } /// The largest x or y, in size, of a segment's control points for which [`segment_bounds`] @@ -1056,6 +1067,19 @@ mod tests { assert!((square().length() - 40.0).abs() < 1e-6); } + /// A quadratic segment whose three points coincide measures 0, not NaN; quadratic segments + /// with only two points together, and an ordinary one, keep kurbo's length. + #[test] + fn measured_arclen_of_a_quad_whose_points_coincide_is_zero() { + let quad = |a: (f64, f64), b: (f64, f64), c: (f64, f64)| PathSeg::Quad(kurbo::QuadBez::new(a, b, c)); + assert_eq!(measured_arclen(&quad((5.0, 5.0), (5.0, 5.0), (5.0, 5.0)), 1e-6), 0.0); + assert_eq!(measured_arclen(&quad((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), 1e-3), 0.0); + assert!((measured_arclen(&quad((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)), 1e-6) - 10.0).abs() < 1e-6); + assert!((measured_arclen(&quad((0.0, 0.0), (10.0, 0.0), (0.0, 0.0)), 1e-6) - 10.0).abs() < 1e-6); + let ordinary = quad((0.0, 0.0), (10.0, 0.0), (10.0, 10.0)); + assert_eq!(measured_arclen(&ordinary, 1e-6), kurbo::ParamCurveArclen::arclen(&ordinary, 1e-6)); + } + #[test] fn serde_roundtrip() { let p = square(); diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs index 4ef9b82105..94ee226d57 100644 --- a/crates/js/src/builtins/extensions.rs +++ b/crates/js/src/builtins/extensions.rs @@ -388,18 +388,33 @@ fn last_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult JsResult { - let line = interp.script_frames().last().map_or(0, |(code, pc, _)| code.line_column(pc.saturating_sub(1)).0); + let line = interp.script_frames().last().map_or(0, |(code, pc, _)| place(interp.locator.as_ref(), code, pc).1); Ok(Value::Number(f64::from(line))) } +/// Where the instruction before `pc` in `code` came from: the file the realm's locator names +/// (`None` without one) and the line there, or the line in the compiled source without a locator. +fn place(locator: Option<&crate::realm::SourceLocator>, code: &Code, pc: usize) -> (Option, u32) { + let line = code.line_column(pc.saturating_sub(1)).0; + match locator { + Some(locate) => { + let (file, line) = locate(code.source.tag, line); + (Some(file), line) + } + None => (None, line), + } +} + /// The most frames `$.stack` lists, and the most characters of a function's name it shows. const STACK_FRAMES: usize = 64; const STACK_NAME_UNITS: usize = 64; -/// The script's frames, outermost first, one a line: the function's name and the line it is at. +/// The script's frames, outermost first, one a line: the function's name and the line it is at, +/// as `name:line`, or as `name@file:line` with the file the realm's locator names. fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { let frames: Vec<(Rc, usize, bool)> = interp.script_frames().map(|(c, pc, f)| (c.clone(), pc, f)).collect(); let skip = frames.len().saturating_sub(STACK_FRAMES); + let locator = interp.locator.clone(); let mut text = String::new(); if skip > 0 { text.push_str("…\n"); @@ -407,8 +422,10 @@ fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult for (code, pc, function) in frames.iter().skip(skip) { interp.use_fuel()?; let name = if *function { function_name(code) } else { "[global]".to_string() }; - let line = code.line_column(pc.saturating_sub(1)).0; - text.push_str(&format!("{name}:{line}\n")); + match place(locator.as_ref(), code, *pc) { + (Some(file), line) => text.push_str(&format!("{name}@{file}:{line}\n")), + (None, line) => text.push_str(&format!("{name}:{line}\n")), + } } let mut out = Builder::new(); out.push_str(interp, &text)?; diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 8336643e4d..4807856bf5 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -219,6 +219,32 @@ fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() { assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned"); } +#[test] +fn the_line_and_the_stack_give_the_place_the_source_locator_maps_each_frame_to() { + let mut r = realm(); + let items = [("line", ScriptItem::Line), ("stack", ScriptItem::Stack)].into_iter().map(|(n, i)| (n.to_string(), HostItem::Script(i))).collect(); + r.define_host_object("h", items).unwrap(); + let asked: Rc>> = Rc::default(); + let seen = asked.clone(); + r.set_source_locator(Rc::new(move |tag, line| { + seen.borrow_mut().push((tag, line)); + let file = if tag == UNKNOWN_SOURCE { "[eval]".to_string() } else { format!("f{tag}") }; + (file, line + 100) + })); + let source = "function inner() {\n return h.stack;\n}\n\ninner()"; + let stack = text(&mut r, source); + let tag = r.script_tag(); + assert_eq!(stack, format!("[global]@f{tag}:105\ninner@f{tag}:102\n")); + assert_eq!(*asked.borrow(), [(tag, 5), (tag, 2)], "each frame's tag and its line in the compiled source"); + assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(103.0)); + let tag = r.script_tag(); + let stack = text(&mut r, "\neval('\\n\\nh.stack')"); + assert_eq!(stack, format!("[global]@f{}:102\n[global]@[eval]:103\n", tag + 1), "code eval compiles has no tag of its own"); + let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0] + ',' + s[1]"; + let tag = r.script_tag() + 1; + assert_eq!(text(&mut r, deep), format!("66,…,d@f{tag}:101"), "the frames past 64 are still left out"); +} + #[test] fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() { let mut r = Realm::new(); diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index fbb5e4fc90..fb980843e8 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -74,6 +74,21 @@ pub enum HostError { /// A function the host gives a script, with its arguments converted. pub type HostFunction = Rc) -> Result>; +/// The place in the script's code that reads a [`HostItem::SiteGetter`] property: the source tag +/// of that code, as [`crate::Realm::error_tag`] gives one, and the line and column there, counted +/// from 1. A function an earlier script defined keeps its script's tag; code that `eval` or +/// `Function` compiled has [`crate::UNKNOWN_SOURCE`]. A read with none of the script's code +/// running has [`crate::UNKNOWN_SOURCE`] and line and column 0. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CallSite { + pub source: u32, + pub line: u32, + pub column: u32, +} + +/// A getter the host gives a script that is told where the code reading it is. +pub type SiteFunction = Rc Result>; + /// How one argument of a [`HostItem::Converted`] function is read. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Arg { @@ -96,6 +111,8 @@ pub(crate) enum ArgRead { Text, /// One by one as the list says; those past its end are not read. Each(Rc<[Arg]>), + /// None: the function is given the [`CallSite`] of the code that calls it, as three numbers. + Site, } /// A member of an object the host adds to the global environment. @@ -114,6 +131,9 @@ pub enum HostItem { /// A property whose value the function gives each time it is read, frozen as /// [`HostItem::Value`] gives its value. FrozenGetter(HostFunction), + /// A property whose value the function gives each time it is read, given the [`CallSite`] of + /// the code that reads it. + SiteGetter(SiteFunction), /// A property whose value `get` gives each time it is read, and that `set` is given each value /// assigned to it, as its one argument; an error from `set` is thrown to the script. Property { @@ -357,7 +377,10 @@ impl Interp { /// Calls host function `f`: reads its arguments as `how` says, runs it, and gives its result to /// the script. pub(crate) fn call_data(&mut self, f: &crate::heap::DataFn, name: &Rc, args: &[Value], how: &ArgRead) -> JsResult { - let data = self.read_args(name, args, how)?; + let data = match how { + ArgRead::Site => site_data(self.call_site()), + _ => self.read_args(name, args, how)?, + }; // Only the script's own code, in a run that can pause, can wait for a result. let can_wait = self.in_slice && self.reentry == 0; self.can_wait.set(can_wait); @@ -375,6 +398,18 @@ impl Interp { } } + /// Where the innermost frame of the script's code is: its source's tag and the line and column + /// of the instruction it is running. + fn call_site(&self) -> CallSite { + match self.script_frames().last() { + Some((code, pc, _)) => { + let (line, column) = code.line_column(pc.saturating_sub(1)); + CallSite { source: code.source.tag, line, column } + } + None => CallSite { source: crate::UNKNOWN_SOURCE, line: 0, column: 0 }, + } + } + /// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the /// source it gives as global code, as an indirect `eval` does, giving that code's completion /// value. A result that is not a string is given to the script as it is. `end` is called once @@ -442,7 +477,7 @@ impl Interp { for (i, a) in args.iter().enumerate() { reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); let d = match how { - ArgRead::Data | ArgRead::Frozen => self.read_data(a, &mut reading), + ArgRead::Data | ArgRead::Frozen | ArgRead::Site => self.read_data(a, &mut reading), ArgRead::Text => self.read_text(a, &mut reading), ArgRead::Each(list) => match list.get(i) { Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined), @@ -531,6 +566,10 @@ impl Interp { HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, HostItem::FrozenGetter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Frozen)), set: None, flags: ENUMERABLE }, + HostItem::SiteGetter(f) => { + let get: HostFunction = Rc::new(move |data| f(read_site(&data))); + Prop::Accessor { get: Some(self.host_function(get, &full, ArgRead::Site)), set: None, flags: ENUMERABLE } + } HostItem::Property { get, set } => Prop::Accessor { get: Some(self.host_function(get, &full, ArgRead::Data)), set: Some(self.host_function(set, &full, ArgRead::Data)), @@ -577,3 +616,18 @@ enum Slot { Index(usize), Name(String), } + +/// `site` as the three numbers [`ArgRead::Site`] gives its function. +fn site_data(site: CallSite) -> Vec { + vec![Data::Number(f64::from(site.source)), Data::Number(f64::from(site.line)), Data::Number(f64::from(site.column))] +} + +/// The [`CallSite`] that [`site_data`] gave as `data`. +fn read_site(data: &[Data]) -> CallSite { + let at = |i: usize, missing: u32| match data.get(i) { + // Each number is a `u32` that `site_data` gave, so the conversion is exact. + Some(Data::Number(n)) => *n as u32, + _ => missing, + }; + CallSite { source: at(0, crate::UNKNOWN_SOURCE), line: at(1, 0), column: at(2, 0) } +} diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index de252c6c64..700efddfb2 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -271,6 +271,41 @@ fn a_frozen_getter_gives_a_frozen_value_each_time() { assert_eq!(r.run(source).unwrap(), JsValue::String("true,true,true,1,false,false".into())); } +/// A site getter is told the source tag, line and column of the code that reads it: a function an +/// earlier script or an evaluator's source defined keeps that source's tag, and code that `eval` +/// compiled has none. +#[test] +fn a_site_getter_is_told_where_the_code_that_reads_it_is() { + let mut r = realm(10_000_000); + let seen: Rc>> = Rc::default(); + let log = seen.clone(); + let site: SiteFunction = Rc::new(move |s| { + log.borrow_mut().push(s); + Ok(Data::Number(f64::from(s.line))) + }); + let source: HostFunction = Rc::new(|_| Ok(Data::String("\n\nfunction inEval() { return host.site; }".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("site".into(), HostItem::SiteGetter(site)), ("load".into(), load)]).unwrap(); + r.run("function early() {\n return host.site;\n}\nhost.load();").unwrap(); + let first = r.script_tag(); + assert_eq!(r.run("\n\n\n\nhost.site + early() + inEval() + eval('host.site')").unwrap(), JsValue::Number(5.0 + 2.0 + 3.0 + 1.0)); + let second = r.script_tag(); + let sites = seen.borrow().clone(); + assert_eq!( + sites, + vec![ + CallSite { source: second, line: 5, column: 1 }, + CallSite { source: first, line: 2, column: 11 }, + CallSite { source: first + 1, line: 3, column: 28 }, + CallSite { source: UNKNOWN_SOURCE, line: 1, column: 1 }, + ] + ); + seen.borrow_mut().clear(); + // A getter a built-in calls is at the place of the code that called the built-in. + assert_eq!(r.run("\nObject.getOwnPropertyDescriptor(host, 'site').get.call(host)").unwrap(), JsValue::Number(2.0)); + assert_eq!(seen.borrow().first().map(|s| s.source), Some(r.script_tag())); +} + #[test] fn a_text_function_takes_each_argument_as_string_gives_it() { let mut r = realm(1_000_000); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 22a365b615..edc5282fe5 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -232,6 +232,8 @@ pub(crate) struct Interp { pub(crate) evaluated: u32, /// The tag of the source the last run's error was raised in. pub(crate) error_tag: u32, + /// Where `$.line` and `$.stack` find the file and line of a compiled line. + pub(crate) locator: Option, /// The mark of the host error ([`crate::HostError::Marked`]) whose exception or limit is /// propagating. pub(crate) thrown_mark: Option, @@ -469,6 +471,7 @@ impl Interp { source_tag: crate::realm::UNKNOWN_SOURCE, evaluated: 0, error_tag: crate::realm::UNKNOWN_SOURCE, + locator: None, thrown_mark: None, finally_marks: Vec::new(), finally_sites: Vec::new(), diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index cad290f1af..1561d71dfe 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -80,10 +80,10 @@ pub use ast::{ }; pub use builtins::extensions::Localization; pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; -pub use host::{Arg, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem}; +pub use host::{Arg, CallSite, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem, SiteFunction}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; -pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE}; +pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, SourceLocator, UNKNOWN_SOURCE}; pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; /// How a script is read. diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 077f3b19b6..4146ae891b 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -63,6 +63,10 @@ pub enum LimitKind { /// evaluator's: code that `eval` or `Function` compiled, or an error with no place. pub const UNKNOWN_SOURCE: u32 = u32::MAX; +/// The file and line that line `line` of the source with tag `tag` ([`Realm::error_tag`]) came +/// from, as `$.line` and `$.stack` give them ([`Realm::set_source_locator`]). +pub type SourceLocator = std::rc::Rc (String, u32)>; + /// The source tag after `tag`. Once the tags are used up, every later source gets /// [`UNKNOWN_SOURCE`], so no two sources share a tag. pub(crate) fn next_tag(tag: u32) -> u32 { @@ -395,6 +399,13 @@ impl Realm { self.interp.interrupt = interrupt.0.clone(); } + /// Has `$.line` give the line `locate` maps the running line to, and `$.stack` give each + /// frame's line that way, after the file `locate` names: `function@file:line`. Without a + /// locator they give the line of the compiled source, and `$.stack` names no file. + pub fn set_source_locator(&mut self, locate: SourceLocator) { + self.interp.locator = Some(locate); + } + /// Which source the last error's `line` and `column` are in: 0 for the script last given to /// [`Realm::run`] or [`Realm::start`], `n` for the `n`th source a /// [`crate::HostItem::Evaluator`] gave since then, and [`UNKNOWN_SOURCE`] otherwise, which diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 8d83162a50..f49c94eb46 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -7,6 +7,7 @@ //! each new node, name, reference or output unit is charged to the heap before it is made. use std::collections::{HashMap, HashSet}; +use std::hash::Hash; use std::rc::Rc; use crate::LimitKind; @@ -18,7 +19,7 @@ use crate::heap::{ XmlListData, XmlName, XmlNode, XmlNs, key, string_bytes, }; use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; -use crate::string_builtins::{UNITS_PER_FUEL, flat_charged}; +use crate::string_builtins::{UNITS_PER_FUEL, flat_charged, push_held, reserve_units}; use crate::value::{Str, Value, index_key}; fn parser_units(s: &str) -> Vec { @@ -295,26 +296,40 @@ fn name_bytes(n: &XmlName) -> usize { string_bytes(n.local.len()) + n.uri.as_ref().map_or(0, |u| string_bytes(u.len())) + n.prefix.as_ref().map_or(0, |p| string_bytes(p.len())) } -/// The namespace `stack` declares for `uri`, nearest first, skipping prefixes redeclared nearer. -/// `prefer` picks that prefix when several match; `need_prefix` rejects the default namespace. -fn ns_lookup(stack: &[XmlNs], uri: &Key, prefer: Option<&Key>, need_prefix: bool) -> Option { - let mut seen: HashSet<&[u16]> = HashSet::new(); - let mut best = None; - for ns in stack.iter().rev() { - let Some(p) = &ns.prefix else { continue }; - if !seen.insert(p) { - continue; - } - if ns.uri == *uri && !(need_prefix && p.is_empty()) { - if prefer == Some(p) { - return Some(ns.clone()); - } - if best.is_none() { - best = Some(ns.clone()); - } - } +/// Inserts `item` into `set`, first holding an upper estimate of the bytes of the set's new table +/// when it must grow. Returns whether `item` was new. +fn insert_held(interp: &mut Interp, held: &mut HeldBytes, set: &mut HashSet, item: T) -> JsResult { + if set.len() == set.capacity() && !set.contains(&item) { + let more = set.capacity().max(4); + held.add(interp, more.saturating_mul(size_of::() + 1).saturating_mul(16) / 7)?; + set.reserve(more); } - best + Ok(set.insert(item)) +} + +/// Makes room in `list` for `more` items, first holding the bytes of its new capacity when it must +/// grow. The capacity at least doubles, as a push's would. +fn reserve_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, more: usize) -> JsResult<()> { + if list.capacity() - list.len() < more { + let cap = list.len().saturating_add(more).max(list.capacity().saturating_mul(2)); + held.add(interp, (cap - list.capacity()).saturating_mul(size_of::()))?; + list.reserve_exact(cap - list.len()); + } + Ok(()) +} + +/// The number `n` of a prefix written `ns` in decimal without leading zeros, as +/// [`Interp::fresh_prefix`] makes them. +fn prefix_number(prefix: &[u16]) -> Option { + let digits = prefix.strip_prefix(&[u16::from(b'n'), u16::from(b's')])?; + let (&first, _) = digits.split_first()?; + if digits.len() > 9 || (first == u16::from(b'0') && digits.len() > 1) { + return None; + } + digits.iter().try_fold(0usize, |n, &c| { + let d = c.checked_sub(u16::from(b'0')).filter(|d| *d < 10)?; + Some(n * 10 + usize::from(d)) + }) } fn prefix_in_scope<'a>(stack: &'a [XmlNs], prefix: &[u16]) -> Option<&'a XmlNs> { @@ -345,18 +360,6 @@ fn units_are(units: &[u16], text: &str) -> bool { units.iter().copied().eq(text.encode_utf16()) } -/// A prefix no entry of `stack` uses. -fn fresh_prefix(stack: &[XmlNs]) -> Key { - let mut n = 0u32; - loop { - let candidate = key(&format!("ns{n}")); - if prefix_in_scope(stack, &candidate).is_none() { - return candidate; - } - n += 1; - } -} - /// One open element while serializing. struct OutFrame { node: ObjId, @@ -579,13 +582,16 @@ impl Interp { // ---- names ---- - fn name_from_string(&mut self, s: &[u16]) -> JsResult { + /// The name `s` gives: an attribute name after `@`, which is copied after its units are + /// reserved, and otherwise an element name that shares `s`. + fn name_from_string(&mut self, s: &Key) -> JsResult { if let Some((&0x40, rest)) = s.split_first() { let uri = if rest == STAR { None } else { Some(Rc::from(Vec::new())) }; + reserve_units(self, rest.len())?; return Ok(NameRef { uri, local: Rc::from(rest), attribute: true }); } - let uri = if s == STAR { None } else { Some(self.xml_default_uri()?) }; - Ok(NameRef { uri, local: Rc::from(s), attribute: false }) + let uri = if **s == *STAR { None } else { Some(self.xml_default_uri()?) }; + Ok(NameRef { uri, local: s.clone(), attribute: false }) } /// ToXMLName, with a canonical array index read as a position (ECMA-357 section 10.6). @@ -593,7 +599,7 @@ impl Interp { match k { Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 && *n < 4_294_967_295.0 => Ok(Access::Index(*n as u32)), Value::String(s) => { - let flat = s.flat(); + let flat = flat_charged(self, s)?; if let Some(i) = canonical_index(&flat) { return Ok(Access::Index(i)); } @@ -603,7 +609,8 @@ impl Interp { if let Some(Class::QName { name, attribute, .. }) = self.heap.object(*o).map(|ob| &ob.class) { return Ok(Access::Name(NameRef { uri: name.uri.clone(), local: name.local.clone(), attribute: *attribute })); } - let s = self.to_str(k)?.flat(); + let s = self.to_str(k)?; + let s = flat_charged(self, &s)?; if let Some(i) = canonical_index(&s) { return Ok(Access::Index(i)); } @@ -787,31 +794,33 @@ impl Interp { if n.attribute { let count = self.xml_node(x)?.attrs.len(); self.xml_take_fuel(count)?; - let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); - let mut kept = Vec::with_capacity(attrs.len()); - for a in attrs { + let mut kept = 0; + for k in 0..count { + let Some(a) = self.xml_node(x)?.attrs.get(k).copied() else { break }; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit { self.xml_node_mut(a)?.parent = None; } else { - kept.push(a); + self.xml_node_mut(x)?.attrs.swap(kept, k); + kept += 1; } } - self.xml_node_mut(x)?.attrs = kept; + self.xml_node_mut(x)?.attrs.truncate(kept); return Ok(()); } let count = self.children_len(x)?; self.xml_take_fuel(count)?; - let children = std::mem::take(&mut self.xml_node_mut(x)?.children); - let mut kept = Vec::with_capacity(children.len()); - for c in children { + let mut kept = 0; + for k in 0..count { + let Some(c) = self.child_at(x, k)? else { break }; if n.matches_child(self.xml_node(c)?) { self.xml_node_mut(c)?.parent = None; } else { - kept.push(c); + self.xml_node_mut(x)?.children.swap(kept, k); + kept += 1; } } - self.xml_node_mut(x)?.children = kept; + self.xml_node_mut(x)?.children.truncate(kept); Ok(()) } @@ -939,7 +948,7 @@ impl Interp { self.use_fuel()?; cur = self.xml_node(a)?.parent; } - let height = self.node_height(v)?; + let height = self.rooted(&[Value::Object(x), Value::Object(v)], |i| i.node_height(v))?; if depth.saturating_add(height) > self.options.max_depth { return Err(Abrupt::Limit(LimitKind::Heap)); } @@ -971,7 +980,9 @@ impl Interp { /// The levels of the tree at `root`, counting it. fn node_height(&mut self, root: ObjId) -> JsResult { - let mut stack = vec![(root, 0usize)]; + let mut held = HeldBytes::new(self); + let mut stack = Vec::new(); + push_held(self, &mut held, &mut stack, (root, 0usize))?; let mut height = 1; while let Some(&(node, next)) = stack.last() { self.use_fuel()?; @@ -980,7 +991,7 @@ impl Interp { if let Some(top) = stack.last_mut() { top.1 += 1; } - stack.push((c, 0)); + push_held(self, &mut held, &mut stack, (c, 0))?; height = height.max(stack.len()); } None => { @@ -1049,19 +1060,22 @@ impl Interp { } /// Takes the fuel and heap to put every item of `l` under `x` and returns the nodes to place, - /// from [`Self::child_for_item`]. The tree does not change here, so a limit leaves it as it - /// was. - fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult> { + /// from [`Self::child_for_item`], with the hold on the list of them, which the caller keeps + /// until they are placed. The tree does not change here, so a limit leaves it as it was. + fn reserve_list_insert(&mut self, x: ObjId, l: ObjId) -> JsResult<(Vec, HeldBytes)> { + let count = self.list_len(l)?; + let roots = [Value::Object(x), Value::Object(l)]; + let mut held = HeldBytes::new(self); + self.rooted(&roots, |i| held.add(i, size_of::().saturating_mul(count)))?; let mut items = self.xml_list(l)?.items.clone(); let bytes = XML_REF_BYTES.saturating_mul(items.len()); - let roots = [Value::Object(x), Value::Object(l)]; self.rooted(&roots, |i| i.xml_charge(bytes))?; let len = self.children_len(x)?; for (j, item) in items.iter_mut().enumerate() { *item = self.child_for_item(*item, &roots)?; self.place_fuel(len + j, *item)?; } - Ok(items) + Ok((items, held)) } /// Puts `items` at `at` in order, after [`Self::reserve_list_insert`]. @@ -1100,7 +1114,7 @@ impl Interp { self.insert_node(x, at, *v)?; } Content::List(l) => { - let items = self.reserve_list_insert(x, *l)?; + let (items, _held) = self.reserve_list_insert(x, *l)?; self.place_children(x, at, &items)?; } } @@ -1152,7 +1166,7 @@ impl Interp { } Content::List(l) => { self.check_attach(x, c)?; - let items = self.reserve_list_insert(x, *l)?; + let (items, _held) = self.reserve_list_insert(x, *l)?; let len = self.children_len(x)?; self.xml_text_fuel(len)?; if at < len { @@ -1229,7 +1243,8 @@ impl Interp { s.push_units(self, &[SPACE])?; } let Some(item) = self.list_at(*l, i)? else { break }; - let text = self.node_to_str(item)?.flat(); + let text = self.node_to_str(item)?; + let text = flat_charged(self, &text)?; s.push_units(self, &text)?; } let Value::String(s) = s.finish(self) else { return Err(Abrupt::Internal("not a string".into())) }; @@ -1240,9 +1255,9 @@ impl Interp { let mut found = None; let count = self.xml_node(x)?.attrs.len(); self.xml_take_fuel(count)?; - let attrs = std::mem::take(&mut self.xml_node_mut(x)?.attrs); - let mut kept = Vec::with_capacity(attrs.len()); - for a in attrs { + let mut kept = 0; + for k in 0..count { + let Some(a) = self.xml_node(x)?.attrs.get(k).copied() else { break }; let hit = self.xml_node(a)?.name.as_ref().is_some_and(|q| n.matches(q)); if hit && found.is_some() { self.xml_node_mut(a)?.parent = None; @@ -1250,10 +1265,11 @@ impl Interp { if hit { found = Some(a); } - kept.push(a); + self.xml_node_mut(x)?.attrs.swap(kept, k); + kept += 1; } } - self.xml_node_mut(x)?.attrs = kept; + self.xml_node_mut(x)?.attrs.truncate(kept); if let Some(a) = found { self.xml_node_mut(a)?.value = value; return Ok(()); @@ -1280,11 +1296,13 @@ impl Interp { } let len = self.children_len(x)?; self.xml_take_fuel(len)?; + self.xml_keep(x); + let mut held = HeldBytes::new(self); let mut matches = Vec::new(); for k in 0..len { let Some(child) = self.child_at(x, k)? else { break }; if n.matches_child(self.xml_node(child)?) { - matches.push(child); + push_held(self, &mut held, &mut matches, child)?; } } match c { @@ -1378,6 +1396,8 @@ impl Interp { /// The steps of [`Self::resolve_value`], setting `created` to the first element they add. fn resolve_value_adding(&mut self, x: ObjId, created: &mut Option) -> JsResult> { + self.xml_keep(x); + let mut held = HeldBytes::new(self); let mut chain = Vec::new(); let mut cur = x; let base = loop { @@ -1391,7 +1411,7 @@ impl Interp { if *name.local == *STAR { return Ok(None); } - chain.push(l); + push_held(self, &mut held, &mut chain, l)?; cur = target; }; let mut base = base; @@ -1615,6 +1635,9 @@ impl Interp { Some(parent) => self.index_in_parent(parent, item)?.map(|q| (parent, q)), None => None, }; + let count = self.list_len(c)?; + let mut held = HeldBytes::new(self); + self.rooted(&[Value::Object(x), Value::Object(c)], |i| held.add(i, size_of::().saturating_mul(count)))?; let mut items = self.xml_list(c)?.items.clone(); if place.is_some() { let roots = [Value::Object(x), Value::Object(c)]; @@ -1682,6 +1705,8 @@ impl Interp { // ---- copies ---- fn copy_shallow(&mut self, src: ObjId, parent: Option) -> JsResult { + let declared = self.xml_node(src)?.ns.len(); + self.xml_charge(XML_NS_BYTES.saturating_mul(declared))?; let node = self.xml_node(src)?; let copy = XmlNode { kind: node.kind, @@ -1692,11 +1717,11 @@ impl Interp { children: Vec::new(), ns: node.ns.clone(), }; - let attrs = node.attrs.clone(); - self.xml_charge(XML_NS_BYTES.saturating_mul(copy.ns.len()))?; let c = self.new_xml_node(copy)?; self.xml_keep(c); - for a in attrs { + let mut k = 0; + while let Some(a) = self.xml_node(src)?.attrs.get(k).copied() { + k += 1; self.use_fuel()?; let n = self.xml_node(a)?; let attr = XmlNode { @@ -1721,7 +1746,9 @@ impl Interp { let top = i.copy_shallow(root, None)?; i.xml_keep(top); let mark = i.temp_roots.len(); - let mut stack = vec![(root, top, 0usize)]; + let mut held = HeldBytes::new(i); + let mut stack = Vec::new(); + push_held(i, &mut held, &mut stack, (root, top, 0usize))?; while let Some(&(src, dst, next)) = stack.last() { i.use_fuel()?; i.temp_roots.truncate(mark); @@ -1733,7 +1760,7 @@ impl Interp { let c = i.copy_shallow(child, Some(dst))?; i.xml_charge_link(c)?; i.xml_node_mut(dst)?.children.push(c); - stack.push((child, c, 0)); + push_held(i, &mut held, &mut stack, (child, c, 0))?; } None => { stack.pop(); @@ -1778,9 +1805,18 @@ impl Interp { // ---- comparison ---- + /// The namespace, local name and value of the attribute `id`, flattening a value that is a + /// rope with [`flat_charged`]. + fn attr_key(&mut self, id: ObjId) -> JsResult<(Option, Key, Key)> { + let n = self.xml_node(id)?; + let name = n.name.as_ref().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; + let (uri, local, value) = (name.uri.clone(), name.local.clone(), n.value.clone()); + Ok((uri, local, flat_charged(self, &value)?)) + } + fn same_shallow(&mut self, a: ObjId, b: ObjId) -> JsResult { let (x, y) = (self.xml_node(a)?, self.xml_node(b)?); - if x.kind != y.kind || x.attrs.len() != y.attrs.len() || x.children.len() != y.children.len() || x.value.flat() != y.value.flat() { + if x.kind != y.kind || x.attrs.len() != y.attrs.len() || x.children.len() != y.children.len() || x.value.len() != y.value.len() { return Ok(false); } match (&x.name, &y.name) { @@ -1788,26 +1824,25 @@ impl Interp { (Some(p), Some(q)) if p.local == q.local && p.uri == q.uri => {} _ => return Ok(false), } - if x.attrs.is_empty() { + let (xv, yv) = (x.value.clone(), y.value.clone()); + if flat_charged(self, &xv)? != flat_charged(self, &yv)? { + return Ok(false); + } + let count = self.xml_node(a)?.attrs.len(); + if count == 0 { return Ok(true); } - let count = x.attrs.len(); self.xml_take_fuel(count)?; let mut held = HeldBytes::new(self); held.add(self, ATTR_COUNT_BYTES.saturating_mul(count))?; - let (x, y) = (self.xml_node(a)?, self.xml_node(b)?); - let (xa, ya) = (x.attrs.clone(), y.attrs.clone()); let mut counts: HashMap<(Option, Key, Key), usize> = HashMap::new(); - let attr_key = |i: &Self, id: ObjId| -> JsResult<(Option, Key, Key)> { - let n = i.xml_node(id)?; - let name = n.name.as_ref().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; - Ok((name.uri.clone(), name.local.clone(), n.value.flat())) - }; - for a in xa { - *counts.entry(attr_key(self, a)?).or_default() += 1; + for k in 0..count { + let Some(id) = self.xml_node(a)?.attrs.get(k).copied() else { break }; + *counts.entry(self.attr_key(id)?).or_default() += 1; } - for b in ya { - match counts.get_mut(&attr_key(self, b)?) { + for k in 0..count { + let Some(id) = self.xml_node(b)?.attrs.get(k).copied() else { break }; + match counts.get_mut(&self.attr_key(id)?) { Some(n) if *n > 0 => *n -= 1, _ => return Ok(false), } @@ -1884,7 +1919,8 @@ impl Interp { let (xk, yk) = (self.xml_kind(x)?, self.xml_kind(y)?); let textual = |k| matches!(k, XmlKind::Text | XmlKind::Attribute); if (textual(xk) && self.node_simple(y)?) || (textual(yk) && self.node_simple(x)?) { - return Ok(Some(self.node_to_str(x)?.flat() == self.node_to_str(y)?.flat())); + let (s, other) = (self.node_to_str(x)?, self.node_to_str(y)?); + return Ok(Some(flat_charged(self, &s)? == flat_charged(self, &other)?)); } return self.node_equals(x, y).map(Some); } @@ -1900,9 +1936,9 @@ impl Interp { if let Xv::Node(n) = self.classify(x) && self.node_simple(n)? { - let s = self.node_to_str(n)?.flat(); - let other = self.to_str(y)?.flat(); - return Ok(Some(s == other)); + let s = self.node_to_str(n)?; + let other = self.to_str(y)?; + return Ok(Some(flat_charged(self, &s)? == flat_charged(self, &other)?)); } } Ok(None) @@ -1951,7 +1987,9 @@ impl Interp { /// The nodes under `x` matching `n`, in document order ([[Descendants]], section 9.1.1.8). pub(crate) fn node_descendants(&mut self, x: ObjId, n: &NameRef, out: ObjId) -> JsResult<()> { - let mut stack = vec![(x, 0usize)]; + let mut held = HeldBytes::new(self); + let mut stack = Vec::new(); + push_held(self, &mut held, &mut stack, (x, 0usize))?; if n.attribute { self.collect_matching(x, n, out)?; } @@ -1969,14 +2007,16 @@ impl Interp { } else if n.matches_child(self.xml_node(child)?) { self.list_push(out, child)?; } - stack.push((child, 0)); + push_held(self, &mut held, &mut stack, (child, 0))?; } Ok(()) } /// Merges adjacent text and removes empty text under `root` (ECMA-357 section 13.4.4.26). pub(crate) fn node_normalize(&mut self, root: ObjId) -> JsResult<()> { - let mut stack = vec![(root, 0usize)]; + let mut held = HeldBytes::new(self); + let mut stack = Vec::new(); + push_held(self, &mut held, &mut stack, (root, 0usize))?; while let Some(&(x, mut i)) = stack.last() { self.use_fuel()?; let Some(child) = self.child_at(x, i)? else { @@ -2010,7 +2050,7 @@ impl Interp { frame.1 = i + 1; } if kind == XmlKind::Element { - stack.push((child, 0)); + push_held(self, &mut held, &mut stack, (child, 0))?; } } Ok(()) @@ -2032,7 +2072,8 @@ impl Interp { let Some(c) = self.child_at(x, i)? else { break }; let node = self.xml_node(c)?; if !matches!(node.kind, XmlKind::Comment | XmlKind::Pi) { - let text = node.value.flat(); + let text = node.value.clone(); + let text = flat_charged(self, &text)?; out.push_units(self, &text)?; } } @@ -2069,7 +2110,8 @@ impl Interp { self.use_fuel()?; let Some(item) = self.list_at(l, i)? else { break }; if !matches!(self.xml_kind(item)?, XmlKind::Comment | XmlKind::Pi) { - let text = self.node_to_str(item)?.flat(); + let text = self.node_to_str(item)?; + let text = flat_charged(self, &text)?; out.push_units(self, &text)?; } } @@ -2107,6 +2149,46 @@ impl Interp { out.push_units(self, text.get(from..).unwrap_or_default()) } + /// The namespace `stack` declares for `uri`, nearest first, skipping prefixes redeclared nearer. + /// `prefer` picks that prefix when several match; `need_prefix` rejects the default namespace. + /// The set of prefixes it has passed is held against the heap limit. + fn ns_lookup(&mut self, stack: &[XmlNs], uri: &Key, prefer: Option<&Key>, need_prefix: bool) -> JsResult> { + let mut held = HeldBytes::new(self); + let mut seen: HashSet<&[u16]> = HashSet::new(); + let mut best = None; + for ns in stack.iter().rev() { + let Some(p) = &ns.prefix else { continue }; + if !insert_held(self, &mut held, &mut seen, &p[..])? { + continue; + } + if ns.uri == *uri && !(need_prefix && p.is_empty()) { + if prefer == Some(p) { + return Ok(Some(ns.clone())); + } + if best.is_none() { + best = Some(ns.clone()); + } + } + } + Ok(best) + } + + /// The first of `ns0`, `ns1`, ... that no entry of `stack` uses. One of the first + /// `stack.len() + 1` is free, so one pass over `stack` marks the used ones. + fn fresh_prefix(&mut self, stack: &[XmlNs]) -> JsResult { + self.xml_take_fuel(stack.len() + 1)?; + let mut held = HeldBytes::new(self); + held.add(self, stack.len() + 1)?; + let mut used = vec![false; stack.len() + 1]; + for ns in stack { + if let Some(slot) = ns.prefix.as_deref().and_then(prefix_number).and_then(|n| used.get_mut(n)) { + *slot = true; + } + } + let n = used.iter().position(|u| !u).unwrap_or(stack.len()); + Ok(key(&format!("ns{n}"))) + } + /// Picks the prefix of a name in `scope`, adding a declaration to `scope` when none is there. fn out_prefix(&mut self, name: &XmlName, scope: &mut Vec, attribute: bool) -> JsResult { self.xml_take_fuel(scope.len() + 1)?; @@ -2122,13 +2204,13 @@ impl Interp { if *uri == *key(XML_URI) { return Ok(key("xml")); } - if let Some(ns) = ns_lookup(scope, &uri, name.prefix.as_ref(), attribute) { + if let Some(ns) = self.ns_lookup(scope, &uri, name.prefix.as_ref(), attribute)? { return Ok(ns.prefix.unwrap_or(empty)); } let prefix = match &name.prefix { Some(p) if !(attribute && p.is_empty()) && prefix_in_scope(scope, p).is_none() => p.clone(), _ if !attribute && prefix_in_scope(scope, &empty).is_none() => empty, - _ => fresh_prefix(scope), + _ => self.fresh_prefix(scope)?, }; self.xml_charge(XML_NS_BYTES)?; scope.push(XmlNs { prefix: Some(prefix.clone()), uri }); @@ -2143,8 +2225,7 @@ impl Interp { out.push_units(self, local) } - /// Writes `x`; an element with children is left open on `stack`. - #[allow(clippy::too_many_arguments)] + /// Writes `x`, and returns the frame of an element with children, which is left open. fn write_enter( &mut self, out: &mut Builder, @@ -2153,11 +2234,11 @@ impl Interp { settings: &Settings, pretty: bool, scope: &mut Vec, - stack: &mut Vec, - ) -> JsResult<()> { + ) -> JsResult> { self.use_fuel()?; let node = self.xml_node(x)?; - let (kind, value) = (node.kind, node.value.flat()); + let (kind, value) = (node.kind, node.value.clone()); + let value = flat_charged(self, &value)?; self.xml_text_fuel(value.len())?; if pretty { out.push_repeated(self, &[SPACE], indent)?; @@ -2165,13 +2246,18 @@ impl Interp { match kind { XmlKind::Text => { let trimmed: &[u16] = if pretty { trim_xml_space(&value) } else { &value }; - return self.write_escaped(out, trimmed, false); + self.write_escaped(out, trimmed, false)?; + return Ok(None); + } + XmlKind::Attribute => { + self.write_escaped(out, &value, true)?; + return Ok(None); } - XmlKind::Attribute => return self.write_escaped(out, &value, true), XmlKind::Comment => { out.push_str(self, ""); + out.push_str(self, "-->")?; + return Ok(None); } XmlKind::Pi => { let target = self.xml_node(x)?.name.as_ref().map(|n| n.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); @@ -2179,47 +2265,46 @@ impl Interp { out.push_units(self, &target)?; out.push_units(self, &[SPACE])?; out.push_units(self, &value)?; - return out.push_str(self, "?>"); + out.push_str(self, "?>")?; + return Ok(None); } XmlKind::Element => {} } let ns_mark = scope.len(); let node = self.xml_node(x)?; - let declared = node.ns.clone(); - let attrs = node.attrs.clone(); + let declared = node.ns.len(); let name = node.name.clone().ok_or_else(|| Abrupt::Internal("element without a name".into()))?; - self.xml_take_fuel(declared.len().saturating_mul(scope.len() + 1))?; - let mut unnamed = Vec::new(); - for ns in declared { - if ns.prefix.is_none() { - unnamed.push(ns); - } else if !scope.iter().any(|a| a.uri == ns.uri && a.prefix == ns.prefix) { - self.xml_charge(XML_NS_BYTES)?; - scope.push(ns); - } - } - for ns in unnamed { - if !scope.iter().any(|a| a.uri == ns.uri) { - let prefix = fresh_prefix(scope); - self.xml_charge(XML_NS_BYTES)?; - scope.push(XmlNs { prefix: Some(prefix), uri: ns.uri }); + self.xml_take_fuel(declared.saturating_mul(scope.len() + 1))?; + for unnamed in [false, true] { + let mut k = 0; + while let Some(ns) = self.xml_node(x)?.ns.get(k).cloned() { + k += 1; + if ns.prefix.is_none() != unnamed { + continue; + } + if !unnamed && !scope.iter().any(|a| a.uri == ns.uri && a.prefix == ns.prefix) { + self.xml_charge(XML_NS_BYTES)?; + scope.push(ns); + } else if unnamed && !scope.iter().any(|a| a.uri == ns.uri) { + let prefix = self.fresh_prefix(scope)?; + self.xml_charge(XML_NS_BYTES)?; + scope.push(XmlNs { prefix: Some(prefix), uri: ns.uri }); + } } } let prefix = self.out_prefix(&name, scope, false)?; - let mut attr_prefixes = Vec::new(); - for &a in &attrs { - self.use_fuel()?; - let an = self.xml_node(a)?.name.clone().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; - attr_prefixes.push(self.out_prefix(&an, scope, true)?); - } out.push_units(self, &[LT])?; self.write_name(out, &prefix, &name.local)?; - for (&a, attr_prefix) in attrs.iter().zip(&attr_prefixes) { - let an = self.xml_node(a)?; - let local = an.name.as_ref().map(|q| q.local.clone()).unwrap_or_else(|| Rc::from(Vec::new())); - let value = an.value.flat(); + let mut k = 0; + while let Some(a) = self.xml_node(x)?.attrs.get(k).copied() { + k += 1; + self.use_fuel()?; + let an = self.xml_node(a)?.name.clone().ok_or_else(|| Abrupt::Internal("attribute without a name".into()))?; + let attr_prefix = self.out_prefix(&an, scope, true)?; + let value = self.xml_node(a)?.value.clone(); + let value = flat_charged(self, &value)?; out.push_units(self, &[SPACE])?; - self.write_name(out, attr_prefix, &local)?; + self.write_name(out, &attr_prefix, &an.local)?; out.push_units(self, &[EQ, DQUOTE])?; self.write_escaped(out, &value, true)?; out.push_units(self, &[DQUOTE])?; @@ -2239,7 +2324,8 @@ impl Interp { let count = self.children_len(x)?; if count == 0 { scope.truncate(ns_mark); - return out.push_str(self, "/>"); + out.push_str(self, "/>")?; + return Ok(None); } out.push_units(self, &[GT])?; let first_is_text = match self.child_at(x, 0)? { @@ -2248,14 +2334,17 @@ impl Interp { }; let pretty_children = pretty && (count > 1 || !first_is_text); let child_indent = if pretty_children { indent.saturating_add(u64::from(settings.indent)) } else { 0 }; - stack.push(OutFrame { node: x, next: 0, indent, child_indent, pretty_children, ns_mark, prefix, local: name.local }); - Ok(()) + Ok(Some(OutFrame { node: x, next: 0, indent, child_indent, pretty_children, ns_mark, prefix, local: name.local })) } + /// Writes the tree at `root`. Its open elements are held against the heap limit. fn write_node(&mut self, out: &mut Builder, root: ObjId, indent: u64, settings: &Settings, pretty: bool) -> JsResult<()> { let mut scope: Vec = Vec::new(); + let mut held = HeldBytes::new(self); let mut stack: Vec = Vec::new(); - self.write_enter(out, root, indent, settings, pretty, &mut scope, &mut stack)?; + if let Some(frame) = self.write_enter(out, root, indent, settings, pretty, &mut scope)? { + push_held(self, &mut held, &mut stack, frame)?; + } while let Some(frame) = stack.last_mut() { let (node, next, child_indent, pretty_children) = (frame.node, frame.next, frame.child_indent, frame.pretty_children); frame.next += 1; @@ -2264,7 +2353,9 @@ impl Interp { if pretty_children { out.push_units(self, &[LF])?; } - self.write_enter(out, child, child_indent, settings, pretty && pretty_children, &mut scope, &mut stack)?; + if let Some(frame) = self.write_enter(out, child, child_indent, settings, pretty && pretty_children, &mut scope)? { + push_held(self, &mut held, &mut stack, frame)?; + } } None => { let Some(frame) = stack.pop() else { break }; @@ -2311,8 +2402,11 @@ impl Interp { self.throw(ErrorType::Syntax, message) } - fn decode_into(&mut self, src: &[u16], from: usize, to: usize, out: &mut Vec, attribute: bool) -> JsResult<()> { + /// Decodes `src[from..to]` onto `out`, holding the room it takes in `out` with `held` first. The + /// decoded text is never longer than the source. + fn decode_into(&mut self, src: &[u16], from: usize, to: usize, out: &mut Vec, attribute: bool, held: &mut HeldBytes) -> JsResult<()> { self.xml_text_fuel(to.saturating_sub(from))?; + reserve_held(self, held, out, to.saturating_sub(from))?; let mut pos = from; while pos < to { let Some(&c) = src.get(pos) else { break }; @@ -2427,7 +2521,16 @@ impl Interp { Ok(()) } - fn flush_text(&mut self, text: &mut Vec, ignore_whitespace: bool, open: &[ObjId], roots: &mut Vec) -> JsResult<()> { + /// Makes a text node of `text`, unless it is empty or ignored white space. Taking the buffer + /// releases `text_held`, its hold. + fn flush_text( + &mut self, + text: &mut Vec, + text_held: &mut HeldBytes, + ignore_whitespace: bool, + open: &[ObjId], + roots: &mut Vec, + ) -> JsResult<()> { if text.is_empty() { return Ok(()); } @@ -2435,16 +2538,22 @@ impl Interp { text.clear(); return Ok(()); } - let t = self.new_text_node(Str::from_units(std::mem::take(text)), None)?; + let value = Str::from_units(std::mem::take(text)); + *text_held = HeldBytes::new(self); + let t = self.new_text_node(value, None)?; self.place_parsed(t, open, roots) } + /// Parses the open tag at `src[pos]` and returns the offset after it. An element left open is + /// pushed onto `open` and its name onto `names`, both held with `stack_held`. The attributes, + /// sets and copies it works with are held until it returns. fn parse_open_tag( &mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>, + stack_held: &mut HeldBytes, roots: &mut Vec, ) -> JsResult { if open.len() >= self.options.max_depth { @@ -2455,6 +2564,7 @@ impl Interp { let raw_name = src.get(name_start..name_end).unwrap_or_default(); let (elem_prefix, elem_local) = self.split_name(raw_name)?; let mut at = name_end; + let mut held = HeldBytes::new(self); let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); let closes = loop { let before = at; @@ -2492,27 +2602,31 @@ impl Interp { return Err(self.syntax("'<' cannot appear in an attribute value")); } let mut value = Vec::new(); - self.decode_into(src, value_start, value_end, &mut value, true)?; - raw_attrs.push((at, end, value)); + self.decode_into(src, value_start, value_end, &mut value, true, &mut held)?; + push_held(self, &mut held, &mut raw_attrs, (at, end, value))?; at = value_end + 1; } } }; self.xml_charge(XML_NS_BYTES.saturating_mul(raw_attrs.len()))?; - let mut ns: Vec = Vec::new(); - let mut seen_decl: HashSet> = HashSet::new(); + let xmlns = key("xmlns"); + let declares = |raw: &[u16]| raw == &xmlns[..] || raw.strip_prefix(&xmlns[..]).is_some_and(|rest| rest.first() == Some(&COLON)); + let decls = raw_attrs.iter().filter(|(start, end, _)| declares(src.get(*start..*end).unwrap_or_default())).count(); + let mut ns: Vec = Vec::with_capacity(decls); + let mut seen_decl: HashSet<&[u16]> = HashSet::new(); for (start, end, value) in &raw_attrs { let raw = src.get(*start..*end).unwrap_or_default(); - if raw == key("xmlns").as_ref() { + if raw == &xmlns[..] { if !ns_binding_allowed(&[], value) { return Err(self.syntax(RESERVED_NS_BINDING)); } - if !seen_decl.insert(Vec::new()) { + if !insert_held(self, &mut held, &mut seen_decl, &[] as &[u16])? { return Err(self.syntax("a namespace is declared twice")); } + self.xml_charge(string_bytes(0).saturating_add(string_bytes(value.len())))?; ns.push(XmlNs { prefix: Some(Rc::from(Vec::new())), uri: Rc::from(value.as_slice()) }); } else if let (Some(p), local) = self.split_name(raw)? - && p == key("xmlns").as_ref() + && p == &xmlns[..] { if value.is_empty() { return Err(self.syntax("a namespace prefix cannot be declared with an empty URI")); @@ -2520,9 +2634,10 @@ impl Interp { if !ns_binding_allowed(local, value) { return Err(self.syntax(RESERVED_NS_BINDING)); } - if !seen_decl.insert(local.to_vec()) { + if !insert_held(self, &mut held, &mut seen_decl, local)? { return Err(self.syntax("a namespace is declared twice")); } + self.xml_charge(string_bytes(local.len()).saturating_add(string_bytes(value.len())))?; ns.push(XmlNs { prefix: Some(Rc::from(local)), uri: Rc::from(value.as_slice()) }); } } @@ -2547,7 +2662,8 @@ impl Interp { ns, })?; self.place_parsed(element, open, roots)?; - let mut unique: HashSet<(Key, Vec)> = HashSet::new(); + let mut unique: HashSet<(Key, &[u16])> = HashSet::new(); + let mut own: Option> = None; for (start, end, value) in raw_attrs { self.use_fuel()?; let raw = src.get(start..end).unwrap_or_default(); @@ -2557,15 +2673,19 @@ impl Interp { } let (auri, stored_prefix): (Key, Key) = match prefix { Some(p) => { - let own = self.xml_node(element)?.ns.clone(); - match self.resolve_prefix(p, &own, open)? { + if own.is_none() { + let count = self.xml_node(element)?.ns.len(); + held.add(self, size_of::().saturating_mul(count))?; + own = Some(self.xml_node(element)?.ns.clone()); + } + match self.resolve_prefix(p, own.as_deref().unwrap_or_default(), open)? { Some(u) => (u, Rc::from(p)), None => return Err(self.syntax("an XML name uses an undeclared namespace prefix")), } } None => (Rc::from(Vec::new()), Rc::from(Vec::new())), }; - if !unique.insert((auri.clone(), local.to_vec())) { + if !insert_held(self, &mut held, &mut unique, (auri.clone(), local))? { return Err(self.type_error("an XML element has two attributes with the same name")); } let a = self.new_xml_node(XmlNode { @@ -2581,8 +2701,8 @@ impl Interp { self.xml_node_mut(element)?.attrs.push(a); } if !closes { - open.push(element); - names.push((name_start, name_end)); + push_held(self, stack_held, open, element)?; + push_held(self, stack_held, names, (name_start, name_end))?; } Ok(at) } @@ -2605,10 +2725,14 @@ impl Interp { } /// Parses XML text into nodes, kept reachable through the temporary roots until the caller's - /// scope ends. + /// scope ends. The open elements and their names, the text being gathered and each tag's + /// attributes are held as they grow. The allowance held from the source's length covers the + /// copy a value's units take as it becomes a string, before the node that holds it is charged. pub(crate) fn xml_parse(&mut self, source: &[u16]) -> JsResult> { let mut held = HeldBytes::new(self); held.add(self, string_bytes(source.len()).saturating_mul(2))?; + let mut stack_held = HeldBytes::new(self); + let mut text_held = HeldBytes::new(self); let settings = self.xml_settings()?; let mut src = source; let offset_start = skip_xml_space(src, 0); @@ -2638,7 +2762,7 @@ impl Interp { self.xml_text_fuel(end - pos)?; return Err(self.syntax("']]>' cannot appear in XML text")); } - self.decode_into(src, pos, end, &mut text, false)?; + self.decode_into(src, pos, end, &mut text, false, &mut text_held)?; pos = end; continue; } @@ -2650,11 +2774,13 @@ impl Interp { }; self.xml_text_fuel(end - pos)?; self.check_units(src, pos + 9, end)?; - text.extend_from_slice(src.get(pos + 9..end).unwrap_or_default()); + let section = src.get(pos + 9..end).unwrap_or_default(); + reserve_held(self, &mut text_held, &mut text, section.len())?; + text.extend_from_slice(section); pos = end + 3; continue; } - self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; + self.flush_text(&mut text, &mut text_held, settings.ignore_whitespace, &open, &mut roots)?; if rest.starts_with(&parser_units("'; \ + var g = ''; isXMLName(g);"; + for ctor in ["XML", "XMLList"] { + let rope = least_spare(text, &format!("new {ctor}(s); 1")); + let flat = least_spare(text, &format!("new {ctor}(g); 1")); + if rope < flat + 32768 { + wrong.push(format!("new {ctor} needs {rope} spare for a rope and {flat} for flat text")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm(); diff --git a/crates/js/src/xpath.rs b/crates/js/src/xpath.rs index 30707e4de0..6ff84f1508 100644 --- a/crates/js/src/xpath.rs +++ b/crates/js/src/xpath.rs @@ -1472,13 +1472,14 @@ impl<'p> Ev<'p> { let xml = key("xml"); let mut at = 0u32; let mut has_xml = false; - for ns in scope_of(interp, Some(*x))? { - let Some(prefix) = ns.prefix else { continue }; + let scope = scope_of(interp, Some(*x))?; + for ns in &scope.list { + let Some(prefix) = ns.prefix.clone() else { continue }; if prefix.is_empty() && ns.uri.is_empty() { continue; } has_xml |= prefix == xml; - self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri })?; + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri.clone() })?; at += 1; } if !has_xml { @@ -1867,7 +1868,7 @@ impl<'p> Ev<'p> { prefix } } - Some(XNode::Node(x)) => node_name(interp, f, x)?, + Some(XNode::Node(x)) => return node_name(interp, f, x), }; Ok(Text::plain(interp, units)) } @@ -2179,7 +2180,7 @@ impl<'p> Ev<'p> { let uri = if units_are(p, "xml") { key(XML_URI) } else { - match scope.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { + match scope.list.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { Some(ns) if !ns.uri.is_empty() => ns.uri.clone(), _ => { let shown = String::from_utf16_lossy(p); @@ -2213,24 +2214,26 @@ impl<'p> Ev<'p> { } } -fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult { +fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult { let node = interp.xml_node(x)?; let (kind, parent) = (node.kind, node.parent); - let Some(name) = node.name.clone() else { return Ok(empty()) }; - match (f, kind) { - (_, XmlKind::Text | XmlKind::Comment) => Ok(empty()), - (Func::NamespaceUri, XmlKind::Pi) => Ok(empty()), - (Func::NamespaceUri, _) => Ok(Rc::from(name_uri(&name))), - (Func::LocalName, _) | (_, XmlKind::Pi) => Ok(name.local), - _ => qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), - } + let Some(name) = node.name.clone() else { return Ok(Text::plain(interp, empty())) }; + let units = match (f, kind) { + (_, XmlKind::Text | XmlKind::Comment) => empty(), + (Func::NamespaceUri, XmlKind::Pi) => empty(), + (Func::NamespaceUri, _) => Rc::from(name_uri(&name)), + (Func::LocalName, _) | (_, XmlKind::Pi) => name.local, + _ => return qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), + }; + Ok(Text::plain(interp, units)) } -/// An element's or attribute's name as a QName, with a prefix declared for its namespace. -fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &XmlName, attribute: bool) -> JsResult { +/// An element's or attribute's name as a QName, with a prefix declared for its namespace. The units +/// of a prefixed name are held against the heap limit while the text lives. +fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &XmlName, attribute: bool) -> JsResult { let uri = name_uri(name); if uri.is_empty() { - return Ok(name.local.clone()); + return Ok(Text::plain(interp, name.local.clone())); } let prefix = if units_are(uri, XML_URI) { key("xml") @@ -2240,17 +2243,18 @@ fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &X _ => { let scope = scope_of(interp, if attribute { parent } else { Some(x) })?; let usable = |ns: &XmlNs| *ns.uri == *uri && ns.prefix.as_ref().is_some_and(|p| !(attribute && p.is_empty())); - scope.into_iter().find(usable).and_then(|ns| ns.prefix).unwrap_or_else(empty) + scope.list.iter().find(|ns| usable(ns)).and_then(|ns| ns.prefix.clone()).unwrap_or_else(empty) } } }; if prefix.is_empty() { - return Ok(name.local.clone()); + return Ok(Text::plain(interp, name.local.clone())); } - let mut units = prefix.to_vec(); - units.push(COLON); - units.extend_from_slice(&name.local); - Ok(Rc::from(units)) + let mut buf = TextBuf::new(interp); + buf.push(interp, &prefix)?; + buf.push(interp, &[COLON])?; + buf.push(interp, &name.local)?; + Ok(buf.finish()) } /// The first index where `needle` occurs in `hay`. Every position tried takes fuel. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index ffb6fcb93e..6529cf175d 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -45,10 +45,62 @@ fn text_of(result: &Value) -> String { result["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect() } -fn tmp(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir.join(name) +/// A test's own folder under the temp dir, `vectorcraft-mcp--`, made empty when created +/// and removed with everything in it when dropped, also when the test panics. A removal that fails +/// (a file still open) is ignored. +struct TempFolder(std::path::PathBuf); + +impl TempFolder { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + /// The path of `name` in the folder. + fn file(&self, name: &str) -> std::path::PathBuf { + self.0.join(name) + } +} + +impl std::ops::Deref for TempFolder { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempFolder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A `TempFolder` starts empty, and dropping it removes it with the files and folders in it, also +/// when the test that holds it panics. +#[test] +fn temp_folders_start_empty_and_are_removed_when_dropped() { + let leftover = std::env::temp_dir().join(format!("vectorcraft-mcp-drop-check-{}", std::process::id())); + std::fs::create_dir_all(&leftover).unwrap(); + std::fs::write(leftover.join("old.txt"), b"old").unwrap(); + let dir = TempFolder::new("drop-check"); + assert_eq!(std::fs::read_dir(&*dir).unwrap().count(), 0, "a leftover folder is emptied"); + std::fs::write(dir.file("a.txt"), b"a").unwrap(); + std::fs::create_dir(dir.file("sub")).unwrap(); + std::fs::write(dir.file("sub").join("b.txt"), b"b").unwrap(); + let path = dir.to_path_buf(); + drop(dir); + assert!(!path.exists(), "{} is removed", path.display()); + + let panicked = std::panic::catch_unwind(|| { + let dir = TempFolder::new("panic-check"); + std::fs::write(dir.file("a.txt"), b"a").unwrap(); + panic!("the test fails with {} in use", dir.display()); + }); + assert!(panicked.is_err()); + let path = std::env::temp_dir().join(format!("vectorcraft-mcp-panic-check-{}", std::process::id())); + assert!(!path.exists(), "{} is removed after a panic", path.display()); } // ---------- framing ---------- @@ -286,6 +338,7 @@ fn headless_command_list_includes_ui_commands() { #[test] fn headless_end_to_end() { + let dir = TempFolder::new("headless-end-to-end"); let mut s = server(); rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION})); s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); @@ -334,7 +387,7 @@ fn headless_end_to_end() { assert_eq!(call(&mut s, 44, "doc_inspect", json!({"ids": [999_999]}))["isError"], true); // Screenshot returns image content (base64 PNG) and text. - let shot_path = tmp("shot.png"); + let shot_path = dir.file("shot.png"); let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()})); assert_eq!(r["isError"], false, "{r}"); let img = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image content"); @@ -344,14 +397,14 @@ fn headless_end_to_end() { assert_eq!(std::fs::read(&shot_path).unwrap(), png); // Export SVG. - let svg_path = tmp("out.svg"); + let svg_path = dir.file("out.svg"); let r = call(&mut s, 6, "export", json!({"path": svg_path.to_str().unwrap()})); assert_eq!(r["isError"], false, "{r}"); let svg = std::fs::read_to_string(&svg_path).unwrap(); assert!(svg.contains(" image::Rgb([220, 30, 30]), (35..55, 5..35) => image::Rgb([30, 60, 200]), @@ -441,8 +495,9 @@ fn trace_and_style_prompt_steps_run_in_order() { /// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White. #[test] fn screenshot_and_render_preview_draw_the_trace_view_with_trace_views() { + let dir = TempFolder::new("trace-views"); let mut s = server(); - let png = tmp("trace-views.png"); + let png = dir.file("trace-views.png"); image::RgbImage::from_fn(100, 100, |x, y| { let (dx, dy) = (f64::from(x) + 0.5 - 50.0, f64::from(y) + 0.5 - 50.0); if dx * dx + dy * dy <= 900.0 { image::Rgb([0, 0, 0]) } else { image::Rgb([170, 210, 255]) } @@ -490,6 +545,7 @@ fn screenshot_and_render_preview_draw_the_trace_view_with_trace_views() { #[test] fn headless_path_gesture_undo() { + let dir = TempFolder::new("path-gesture-undo"); let mut s = server(); let count = |s: &mut Server| -> u64 { let r = call(s, 90, "inspect_document", json!({})); @@ -543,9 +599,9 @@ fn headless_path_gesture_undo() { assert_eq!(r["isError"], false, "{r}"); let r = call(&mut s, 11, "select_tool", json!({"tool": "pen"})); assert_eq!(r["isError"], false, "{r}"); - let r = call(&mut s, 12, "export", json!({"path": tmp("x.png").to_str().unwrap(), "scale": 0.5})); + let r = call(&mut s, 12, "export", json!({"path": dir.file("x.png").to_str().unwrap(), "scale": 0.5})); assert_eq!(r["isError"], false, "{r}"); - assert_eq!(&std::fs::read(tmp("x.png")).unwrap()[..4], b"\x89PNG"); + assert_eq!(&std::fs::read(dir.file("x.png")).unwrap()[..4], b"\x89PNG"); } /// A hover's reply comes once the tool's highlight is ready, also when the tool finds it on another @@ -1074,8 +1130,9 @@ fn relative_to_cwd(p: &std::path::Path) -> Option { #[test] fn headless_file_tools_take_relative_paths() { + let dir = TempFolder::new("relative-paths"); let mut s = server(); - let file = tmp("relative.vectorcraft"); + let file = dir.file("relative.vectorcraft"); let Some(rel) = relative_to_cwd(&file) else { return }; let rel = rel.to_str().unwrap(); assert!(std::path::Path::new(rel).is_relative()); @@ -1547,13 +1604,14 @@ fn text_effect_pathfinder_transform_graph_and_wrap_tools() { #[test] fn oversized_screenshot_and_png_export_are_errors() { // 16383 pt at the maximum scale (16) is 262 128 px a side: an error result, not a crashed server. + let dir = TempFolder::new("oversized-export"); let mut s = server(); let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 16383, "height": 16383}})); assert_eq!(r["isError"], false, "{r}"); let r = call(&mut s, 2, "screenshot", json!({"scale": 16})); assert_eq!(r["isError"], true, "{r}"); assert!(text_of(&r).contains("pixels"), "{r}"); - let path = tmp("huge.png"); + let path = dir.file("huge.png"); let r = call(&mut s, 3, "export", json!({"path": path.to_str().unwrap(), "scale": 16})); assert_eq!(r["isError"], true, "{r}"); assert!(!path.exists()); diff --git a/crates/mcp/src/tests/concurrency.rs b/crates/mcp/src/tests/concurrency.rs index d291d1e9fe..8e244b4eb0 100644 --- a/crates/mcp/src/tests/concurrency.rs +++ b/crates/mcp/src/tests/concurrency.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; use vectorcraft_engine::cancel; +use super::TempFolder; use crate::{Backend, Server}; /// A backend whose `engine.execute` runs until cancelled (or for `limit`), like a long export. @@ -318,8 +319,8 @@ impl Backend for Gated { #[test] fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_session_usable() { - let path = vectorcraft_testkit::temp_dir("mcp-cancel").join("out.pdf"); - std::fs::remove_file(&path).ok(); + let dir = TempFolder::new("cancel"); + let path = dir.file("out.pdf"); let (read, mut to_server) = std::io::pipe().unwrap(); let (tx, replies) = mpsc::channel(); let (started_tx, started) = mpsc::channel(); diff --git a/crates/mcp/src/tests/conventions.rs b/crates/mcp/src/tests/conventions.rs index 0b7352bf4a..3618f9214f 100644 --- a/crates/mcp/src/tests/conventions.rs +++ b/crates/mcp/src/tests/conventions.rs @@ -313,6 +313,84 @@ fn window_captures_take_max_side() { assert!(r["content"][0]["text"].as_str().unwrap().contains("`scale`"), "{r}"); } +/// The path an app from before `ui.screenshot {data}` was sent, with the mode of the folder holding +/// it on unix (`None` elsewhere). +type SeenPaths = std::sync::Arc)>>>; + +/// An app from before `ui.screenshot {data}`: it writes `png` (nothing when `None`) to the `path` +/// it is sent, keeps the path in `seen` and answers `reply`. A capture without a path is answered +/// with its size alone. +struct OldWindow { + png: Option>, + reply: Result, + seen: SeenPaths, +} +impl Backend for OldWindow { + fn call(&mut self, method: &str, p: Value) -> Result { + if method != "ui.screenshot" { + return Err(format!("unexpected {method}")); + } + // Without a path it ignores `data` and sends the size alone. + let Some(path) = p["path"].as_str().map(str::to_owned) else { return Ok(json!({"width": 2, "height": 1})) }; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt as _; + std::path::Path::new(&path).parent().and_then(|d| std::fs::metadata(d).ok()).map(|m| m.permissions().mode() & 0o777) + }; + #[cfg(not(unix))] + let mode = None; + self.seen.lock().unwrap().push((path.clone(), mode)); + if let Some(png) = &self.png { + std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?; + } + self.reply.clone() + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "old window".into() + } +} + +/// An app without `ui.screenshot {data}` writes the window capture to a file in a new folder of its +/// own inside the system's temporary folder (owner-only on unix), never to a fixed name in the +/// shared folder itself. The folder is removed after the capture is read, and also when the app +/// fails after writing it or the file can't be read. +#[test] +fn an_old_app_captures_the_window_in_a_new_private_folder() { + let temp = std::env::temp_dir(); + let png = b"window capture".to_vec(); + let size = json!({"width": 2, "height": 1}); + let mut folders = Vec::new(); + for (written, reply, ok) in [ + (Some(png.clone()), Ok(size.clone()), true), + (Some(png.clone()), Ok(size.clone()), true), + (Some(png.clone()), Err("the capture failed".to_string()), false), + (None, Ok(size.clone()), false), + ] { + let seen = SeenPaths::default(); + let mut s = Server::new(Box::new(OldWindow { png: written, reply, seen: seen.clone() })); + let r = call(&mut s, "screenshot", json!({"window": true})); + assert_eq!(r["isError"], !ok, "{r}"); + if ok { + assert_eq!(vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap(), png, "{r}"); + } + let seen = seen.lock().unwrap().clone(); + let [(path, mode)] = seen.as_slice() else { panic!("one capture: {seen:?}") }; + let dir = std::path::Path::new(path).parent().unwrap().to_path_buf(); + assert_eq!(dir.parent(), Some(temp.as_path()), "{path} is in a folder of its own inside {}", temp.display()); + assert!(!dir.exists(), "{} is removed", dir.display()); + if cfg!(unix) { + assert_eq!(*mode, Some(0o700), "{path}"); + } + folders.push(dir); + } + folders.sort(); + folders.dedup(); + assert_eq!(folders.len(), 4, "each capture has a new folder: {folders:?}"); +} + /// Each tool with another name for the same call names it in its description. #[test] fn alias_tools_name_their_pair() { diff --git a/crates/mcp/src/tests/exportas.rs b/crates/mcp/src/tests/exportas.rs index bc6e422d70..f46956bab8 100644 --- a/crates/mcp/src/tests/exportas.rs +++ b/crates/mcp/src/tests/exportas.rs @@ -3,6 +3,7 @@ use serde_json::{Value, json}; +use super::TempFolder; use crate::{Backend, Headless, call_tool}; fn text(r: &crate::ToolResult) -> Value { @@ -48,9 +49,7 @@ fn an_option_no_format_takes_is_an_error() { /// write anything. #[test] fn a_save_or_screens_option_no_format_takes_is_an_error() { - let dir = std::env::temp_dir().join(format!("vc-mcp-save-options-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let dir = TempFolder::new("save-options"); let mut h = Headless::new(); h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap(); let pdf = dir.join("a.pdf").to_string_lossy().into_owned(); @@ -71,9 +70,8 @@ fn a_save_or_screens_option_no_format_takes_is_an_error() { let e = h.call("engine.execute", json!({"command": "file.exportForScreens", "params": params})).unwrap_err(); assert!(e.contains(named), "file.exportForScreens: {e}"); } - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing was written"); + assert_eq!(std::fs::read_dir(&*dir).unwrap().count(), 0, "nothing was written"); h.call("app.save", json!({"path": pdf})).unwrap(); - let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -97,8 +95,7 @@ fn raster_export_uses_document_resolution_unless_overridden() { #[test] fn export_with_a_range_writes_one_file_per_artboard() { let mut h = three_boards(); - let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-exportas-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let dir = TempFolder::new("exportas"); let path = dir.join("icon.png").to_string_lossy().to_string(); let args = json!({"path": path, "range": "1, 3", "options": {"useArtboards": true, "ppi": 144, "background": "white"}}); let r = text(&call_tool(&mut h, "export", &args)); @@ -120,5 +117,4 @@ fn export_with_a_range_writes_one_file_per_artboard() { let r = text(&call_tool(&mut h, "export", &json!({"format": format, "range": "1, 3"}))); assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{format}: {r}"); } - let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 39bcde8d2e..3a89588ecd 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -5,12 +5,12 @@ use std::io::{BufRead, BufReader, Write}; use serde_json::{Value, json}; +use super::TempFolder; use crate::{Backend, Headless, Remote, Server, call_tool, tool_definitions}; -fn tmp(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir.join(name).to_string_lossy().to_string() +/// The path of `name` in `dir`, as a string. +fn tmp(dir: &TempFolder, name: &str) -> String { + dir.file(name).to_string_lossy().to_string() } fn text(r: &crate::ToolResult) -> Value { @@ -29,22 +29,23 @@ fn two_boards() -> Headless { #[test] fn headless_opens_pdf_ai_and_templates() { + let dir = TempFolder::new("fileio-open"); let mut h = two_boards(); let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap(); let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); for name in ["x.pdf", "x.ai"] { - let path = tmp(name); + let path = tmp(&dir, name); std::fs::write(&path, &pdf).unwrap(); let r = text(&call_tool(&mut h, "open_file", &json!({"path": path}))); assert_eq!(r["title"], name); assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page"); } - let ait = tmp("x.ait"); + let ait = tmp(&dir, "x.ait"); std::fs::write(&ait, &pdf).unwrap(); let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait}))); assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); - let saved = h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tmp("t.vectorcraft")}})).unwrap(); - let tpl = tmp("t.vctemplate"); + let saved = h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tmp(&dir, "t.vectorcraft")}})).unwrap(); + let tpl = tmp(&dir, "t.vctemplate"); assert_eq!(saved["path"], tpl.as_str(), "saved with the template's extension: {saved}"); let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl}))); assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); @@ -147,10 +148,11 @@ fn the_raster_scale_leaves_other_formats_alone() { #[test] fn exporting_native_keeps_the_document_path() { + let dir = TempFolder::new("fileio-keep-path"); let mut h = two_boards(); - let doc = tmp("keep.vectorcraft"); + let doc = tmp(&dir, "keep.vectorcraft"); h.call("app.save", json!({"path": doc})).unwrap(); - let copy = tmp("copy.vectorcraft"); + let copy = tmp(&dir, "copy.vectorcraft"); let r = text(&call_tool(&mut h, "export", &json!({"path": copy}))); assert_eq!(r["format"], "vectorcraft"); assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str())); @@ -167,9 +169,9 @@ fn exporting_native_keeps_the_document_path() { assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str())); } -/// The bytes `save_file` writes to `name` with `args`. -fn saved_with(h: &mut Headless, name: &str, mut args: Value) -> Vec { - let path = tmp(name); +/// The bytes `save_file` writes to `name` in `dir` with `args`. +fn saved_with(h: &mut Headless, dir: &TempFolder, name: &str, mut args: Value) -> Vec { + let path = tmp(dir, name); args["path"] = json!(path); let r = text(&call_tool(h, "save_file", &args)); assert_eq!(r["format"], "vectorcraft", "{r}"); @@ -178,22 +180,23 @@ fn saved_with(h: &mut Headless, name: &str, mut args: Value) -> Vec { #[test] fn save_file_takes_the_native_save_options() { + let dir = TempFolder::new("fileio-save-options"); let mut h = two_boards(); - let gz = saved_with(&mut h, "gz.vectorcraft", json!({"compress": true})); + let gz = saved_with(&mut h, &dir, "gz.vectorcraft", json!({"compress": true})); assert_eq!(gz[..2], [0x1f, 0x8b], "gzipped"); - let plain = saved_with(&mut h, "plain.vectorcraft", json!({"compress": false})); + let plain = saved_with(&mut h, &dir, "plain.vectorcraft", json!({"compress": false})); assert_eq!(plain[0], b'{', "plain JSON"); let nodes = h.session.doc().unwrap().doc.node_count(); for name in ["gz.vectorcraft", "plain.vectorcraft"] { let mut back = Headless::new(); - text(&call_tool(&mut back, "open_file", &json!({"path": tmp(name)}))); + text(&call_tool(&mut back, "open_file", &json!({"path": tmp(&dir, name)}))); assert_eq!(back.session.doc().unwrap().doc.node_count(), nodes, "{name} reopens"); } - let opt = saved_with(&mut h, "opt.vectorcraft", json!({"options": {"compress": true}})); + let opt = saved_with(&mut h, &dir, "opt.vectorcraft", json!({"options": {"compress": true}})); assert_eq!(opt[..2], [0x1f, 0x8b], "`options` carries them too"); - let v3: Value = serde_json::from_slice(&saved_with(&mut h, "v3.vectorcraft", json!({"version": 3, "preview": null}))).unwrap(); + let v3: Value = serde_json::from_slice(&saved_with(&mut h, &dir, "v3.vectorcraft", json!({"version": 3, "preview": null}))).unwrap(); assert_eq!(v3["version"], 3); - let shown = saved_with(&mut h, "preview.vectorcraft", json!({"preview": true})); + let shown = saved_with(&mut h, &dir, "preview.vectorcraft", json!({"preview": true})); assert!(vectorcraft_format::preview(&shown).is_some(), "a preview"); assert!(vectorcraft_format::preview(&plain).is_none(), "off by default"); // `precision` rounds the path coordinates of that save only. @@ -201,12 +204,12 @@ fn save_file_takes_the_native_save_options() { p.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 100}})).unwrap(); p.call("engine.execute", json!({"command": "path.create", "params": {"anchors": [{"x": 10.123456, "y": 20.654321}, {"x": 30.5, "y": 40.25}]}})) .unwrap(); - let exact = String::from_utf8(saved_with(&mut p, "exact.vectorcraft", json!({"compress": false}))).unwrap(); + let exact = String::from_utf8(saved_with(&mut p, &dir, "exact.vectorcraft", json!({"compress": false}))).unwrap(); assert!(exact.contains("10.123456") && exact.contains("20.654321"), "{exact}"); - let rounded = String::from_utf8(saved_with(&mut p, "rounded.vectorcraft", json!({"compress": false, "precision": 1}))).unwrap(); + let rounded = String::from_utf8(saved_with(&mut p, &dir, "rounded.vectorcraft", json!({"compress": false, "precision": 1}))).unwrap(); assert!(!rounded.contains("10.123456") && !rounded.contains("20.654321") && rounded.contains("20.7"), "{rounded}"); // Version 2 files can't be compressed: the engine's error comes back. - let r = call_tool(&mut h, "save_file", &json!({"path": tmp("v2.vectorcraft"), "version": 2, "compress": true})); + let r = call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, "v2.vectorcraft"), "version": 2, "compress": true})); assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("can't be compressed"), "{r:?}"); // The options are in the schema; a misspelled one is still refused. @@ -214,9 +217,9 @@ fn save_file_takes_the_native_save_options() { let call = |s: &mut Server, args: Value| { s.handle(json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "save_file", "arguments": args}})).unwrap() }; - let r = call(&mut s, json!({"path": tmp("ok.vectorcraft"), "compress": true})); + let r = call(&mut s, json!({"path": tmp(&dir, "ok.vectorcraft"), "compress": true})); assert_eq!(r["result"]["isError"], false, "{r}"); - let r = call(&mut s, json!({"path": tmp("typo.vectorcraft"), "compresss": true})); + let r = call(&mut s, json!({"path": tmp(&dir, "typo.vectorcraft"), "compresss": true})); assert_eq!(r["error"]["code"], -32602, "{r}"); assert!(r["error"]["message"].as_str().unwrap().contains("unknown argument `compresss`"), "{r}"); } @@ -225,12 +228,13 @@ fn save_file_takes_the_native_save_options() { /// that disagrees with the same key in `options` is an error. #[test] fn save_file_warns_about_options_it_does_not_use() { + let dir = TempFolder::new("fileio-unused-options"); let mut h = two_boards(); let warnings = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect() }; let ignored = |w: &[String], key: &str| w.iter().any(|w| w.starts_with(&format!("`{key}` was not used"))); // SVG reads none of them, at the top level or in `options`. - let path = tmp("ignored.svg"); + let path = tmp(&dir, "ignored.svg"); let r = text(&call_tool(&mut h, "save_file", &json!({"path": path, "compress": true, "version": 3, "options": {"preview": true}}))); assert_eq!(r["format"], "svg", "{r}"); let w = warnings(&r); @@ -239,16 +243,15 @@ fn save_file_warns_about_options_it_does_not_use() { assert!(w.iter().any(|w| w.contains("SVG")), "names the format: {w:?}"); assert!(std::fs::read(&path).unwrap().starts_with(b"<"), "plain SVG"); // A .ai file reads `compress` but not `version`. - let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp("ignored.ai"), "compress": false, "version": 3}))); + let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, "ignored.ai"), "compress": false, "version": 3}))); let w = warnings(&r); assert!(ignored(&w, "version") && !ignored(&w, "compress"), "{w:?}"); // `precision` is read by native files only. - let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp("ignored-precision.svg"), "precision": 2}))); + let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, "ignored-precision.svg"), "precision": 2}))); assert!(ignored(&warnings(&r), "precision"), "{r}"); // A top-level value that disagrees with `options` is refused, and nothing is written. - let path = tmp("both.vectorcraft"); - let _ = std::fs::remove_file(&path); + let path = tmp(&dir, "both.vectorcraft"); let r = call_tool(&mut h, "save_file", &json!({"path": path, "compress": true, "options": {"compress": false}})); let e = r.content[0]["text"].as_str().unwrap(); assert!(r.is_error && e.contains("`compress: true` and `options.compress: false` disagree"), "{r:?}"); @@ -257,7 +260,7 @@ fn save_file_warns_about_options_it_does_not_use() { for mut args in [json!({"compress": true, "options": {"compress": true}}), json!({"compress": false, "version": 3, "preview": true, "precision": 4})] { - args["path"] = json!(tmp("quiet.vectorcraft")); + args["path"] = json!(tmp(&dir, "quiet.vectorcraft")); let r = text(&call_tool(&mut h, "save_file", &args)); assert_eq!(r["warnings"], json!([]), "{args}: {r}"); } @@ -286,6 +289,7 @@ fn save_file_warns_about_options_it_does_not_use() { /// when the save reuses the options the document was last saved with. #[test] fn save_file_notes_name_the_ai_file_it_writes() { + let dir = TempFolder::new("fileio-ai-notes"); let mut h = Headless::with_document(); let notes = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect() @@ -296,21 +300,22 @@ fn save_file_notes_name_the_ai_file_it_writes() { ("no-pdf.ai", json!({"pdfCompatible": false, "preview": true}), "non-PDF-compatible .ai"), ("pdf.ai", json!({"pdfCompatible": true, "preview": true}), "PDF-compatible .ai"), ] { - let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(name), "version": 3, "options": options}))); + let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, name), "version": 3, "options": options}))); let expected = [format!("`version` was not used: {label} files don't read it"), format!("`options.preview` was not used: {label} files don't read it")]; assert_eq!(notes(&r), expected, "{name}: {r}"); } // A save given no option the file reads writes it as last saved: a legacy `.ai` again. - text(&call_tool(&mut h, "save_file", &json!({"path": tmp("again.ai"), "options": {"aiVersion": "7"}}))); - let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp("again.ai"), "options": {"preview": true}}))); + text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, "again.ai"), "options": {"aiVersion": "7"}}))); + let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, "again.ai"), "options": {"preview": true}}))); assert_eq!(notes(&r), ["`options.preview` was not used: legacy .ai (version 7) files don't read it"], "{r}"); - assert!(std::fs::read(tmp("again.ai")).unwrap().starts_with(b"%!PS"), "a legacy .ai is written"); + assert!(std::fs::read(tmp(&dir, "again.ai")).unwrap().starts_with(b"%!PS"), "a legacy .ai is written"); } #[test] fn save_file_is_the_same_call_in_the_app_and_headless() { - let path = tmp("app.vectorcraft"); + let dir = TempFolder::new("fileio-app-save"); + let path = tmp(&dir, "app.vectorcraft"); let (addr, app) = recording_app(); let mut remote = Remote::connect(&addr).unwrap(); let args = json!({"path": path, "compress": true, "version": 3, "preview": null, "precision": 2, "options": {"preview": true}}); @@ -392,7 +397,8 @@ fn pdf_pages(pdf: &[u8]) -> usize { /// only, in the document and in the exported PDF. #[test] fn document_color_mode_puts_rgb_greys_on_the_black_plate() { - let svg = tmp("greys.svg"); + let dir = TempFolder::new("fileio-greys"); + let svg = tmp(&dir, "greys.svg"); std::fs::write( &svg, r##" @@ -447,7 +453,8 @@ fn open_file_passes_the_importer_on() { let tools = tool_definitions(); let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); assert!(open["inputSchema"]["properties"].get("importer").is_some(), "{open}"); - let svg = tmp("importer.svg"); + let dir = TempFolder::new("fileio-importer"); + let svg = tmp(&dir, "importer.svg"); std::fs::write(&svg, r#""#).unwrap(); let mut h = Headless::new(); assert_eq!(text(&call_tool(&mut h, "open_file", &json!({"path": svg})))["format"], "svg"); diff --git a/crates/mcp/src/tests/links.rs b/crates/mcp/src/tests/links.rs index 20298fc612..61d660a8d8 100644 --- a/crates/mcp/src/tests/links.rs +++ b/crates/mcp/src/tests/links.rs @@ -3,6 +3,7 @@ use serde_json::{Value, json}; +use super::TempFolder; use crate::{Headless, call_tool}; fn run(h: &mut Headless, command: &str, params: Value) -> Value { @@ -13,9 +14,8 @@ fn run(h: &mut Headless, command: &str, params: Value) -> Value { #[test] fn headless_links_report_missing_files_and_relink() { - let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-links-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let file = |n: &str| dir.join(n).to_string_lossy().into_owned(); + let dir = TempFolder::new("links"); + let file = |n: &str| dir.file(n).to_string_lossy().into_owned(); let mut h = Headless::new(); run(&mut h, "file.new", json!({"width": 300, "height": 200})); let png = run(&mut h, "document.serialize", json!({"format": "png"})); @@ -30,5 +30,4 @@ fn headless_links_report_missing_files_and_relink() { let r = run(&mut h, "links.relink", json!({"ids": [id], "path": file("b.png")})); assert_eq!(r["relinked"], json!([id])); assert_eq!(run(&mut h, "links.check", json!({}))["links"][0]["status"], "ok"); - let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index e76b5704ae..39d3f3f8e1 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -437,18 +437,52 @@ pub(super) fn screenshot(b: &mut dyn Backend, a: &Args) -> Result) -> Result { - let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string(); - let r = b.call("ui.screenshot", json!({"path": tmp}))?; - let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?; - // Best effort: a temporary file left behind is harmless. - std::fs::remove_file(&tmp).ok(); + let dir = new_capture_folder(&std::env::temp_dir())?; + let tmp = dir.join("window.png").to_string_lossy().to_string(); + let captured = b.call("ui.screenshot", json!({"path": tmp})).and_then(|r| Ok((std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?, r))); + // Best effort: a folder that can't be removed holds only this capture, and on unix only its + // owner can open it. + let _ = std::fs::remove_dir_all(&dir); + let (png, r) = captured?; let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null}); let png = fit_capture(png, max, &mut info)?; Ok(image_result(&png, info)) } +/// A new folder in `parent` named `vectorcraft-window-` followed by the process id, `-` and a count +/// of the process's captures; on unix only its owner can open it. A name that is already taken, by +/// an earlier process with the same id or by anyone else, is skipped for the next count. +fn new_capture_folder(parent: &std::path::Path) -> Result { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + loop { + let dir = parent.join(format!("vectorcraft-window-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + match private_folder().create(&dir) { + Ok(()) => return Ok(dir), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("{}: {e}", dir.display())), + } + } +} + +/// A builder for folders that only their owner can read, write or open (mode 0o700). +#[cfg(unix)] +fn private_folder() -> std::fs::DirBuilder { + let mut b = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b +} + +/// A builder for folders: a plain one outside unix. +#[cfg(not(unix))] +fn private_folder() -> std::fs::DirBuilder { + std::fs::DirBuilder::new() +} + /// `max_side`: absent, or an integer in 1..4096. pub(super) fn max_side(a: &Args) -> Result, String> { match a.get("max_side") { diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..df2627dfc2 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -195,7 +195,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), - ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ( + "view.snapToPixel", + "Snap to Pixel", + "", + "{} toggle: drawing, moving and scaling land on whole pixels, each while its option in view.snapToPixel.options is on", + ), ( "view.snapToGlyph", "Snap to Glyph", @@ -563,7 +568,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", ), - ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ( + "window.toolbar.dock", + "Dock Toolbar", + "", + "{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.float", + "Float Toolbar", + "", + "{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.list", + "List Toolbars", + "", + "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)", + ), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( "window.pathfinder.compoundShapes", @@ -646,6 +668,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", ), + ( + "learn.skills", + "Back to Skills", + "", + "{} show the Learn panel on its Skills tab with the list of skills, with no skill and no tutorial open, as Back to skills after a skill's last tutorial and the Quick skill guide card do; returns what learn.list returns", + ), ( "learn.show", "Show Tutorial", @@ -658,6 +686,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", ), + ( + "learn.openSample", + "Open Tutorial Sample", + "", + "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", + ), + ( + "learn.finish", + "Finish Tutorial", + "", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + ), + ( + "learn.next", + "Next Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", @@ -1212,7 +1258,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index d45ecc7142..74bd95d455 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -5,8 +5,11 @@ // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use std::path::{Path, PathBuf}; +mod common; +use std::path::Path; + +use common::TempFolder; use serde_json::{Value, json}; use vectorcraft_engine::file_access::AutomationRoots; use vectorcraft_mcp::{Headless, Server}; @@ -14,9 +17,10 @@ use vectorcraft_mcp::{Headless, Server}; const SVG: &str = r#""#; /// A folder holding `in` (the read root, with `a.svg`), `out` (the write root), `outside` (with -/// `b.svg`) and `in2`, and a server confined to `in` and `out` (either may be left out). -fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { - let base = vectorcraft_testkit::temp_dir(&format!("roots-{tag}")); +/// `b.svg`) and `in2`, and a server confined to `in` and `out` (either may be left out). The +/// folder is removed when the returned [`TempFolder`] is dropped. +fn confined(tag: &str, read: bool, write: bool) -> (Server, TempFolder) { + let base = TempFolder::new(&format!("roots-{tag}")); for d in ["in", "out", "outside", "in2"] { std::fs::create_dir_all(base.join(d)).unwrap(); } @@ -55,6 +59,29 @@ fn refused(server: &mut Server, name: &str, args: Value, why: &str) { assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}"); } +/// A `TempFolder` is removed with the files and folders in it when dropped, also when the test +/// that holds it panics. +#[test] +fn temp_folders_are_removed_when_dropped() { + let dir = TempFolder::new("roots-drop-check"); + std::fs::create_dir(dir.join("sub")).unwrap(); + std::fs::write(dir.join("sub/a.svg"), SVG).unwrap(); + let path = dir.to_path_buf(); + drop(dir); + assert!(!path.exists(), "{} is removed", path.display()); + + let mut held = None; + let panicked = vectorcraft_testkit::catch_quiet(|| { + let dir = TempFolder::new("roots-panic-check"); + std::fs::write(dir.join("a.svg"), SVG).unwrap(); + held = Some(dir.to_path_buf()); + panic!("the test fails with {} in use", dir.display()); + }); + assert!(panicked.is_err()); + let path = held.unwrap(); + assert!(!path.exists(), "{} is removed after a panic", path.display()); +} + #[test] fn files_open_only_from_inside_the_read_root() { let (mut server, base) = confined("open", true, true); diff --git a/crates/mcp/tests/common/mod.rs b/crates/mcp/tests/common/mod.rs new file mode 100644 index 0000000000..08954ca21e --- /dev/null +++ b/crates/mcp/tests/common/mod.rs @@ -0,0 +1,28 @@ +//! Shared helpers for the MCP integration tests. + +use std::path::{Path, PathBuf}; + +/// A test's own folder, made by [`vectorcraft_testkit::temp_dir`] (with its checks against a +/// folder made by someone else) and removed with everything in it when dropped, also when the +/// test panics. A removal that fails (a file still open) is ignored. Tests that run at the same +/// time use different tags. +pub struct TempFolder(PathBuf); + +impl TempFolder { + pub fn new(tag: &str) -> Self { + Self(vectorcraft_testkit::temp_dir(tag)) + } +} + +impl std::ops::Deref for TempFolder { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempFolder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index f9dc065ff1..b3145a20b9 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -4,6 +4,9 @@ // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod common; + +use common::TempFolder; use proptest::prelude::*; use serde_json::{Value, json}; use vectorcraft_doc::Document; @@ -98,7 +101,7 @@ fn tool_definitions_are_valid() { #[test] fn every_tool_survives_empty_and_junk_arguments() { - let dir = vectorcraft_testkit::temp_dir("mcp-junk"); + let dir = TempFolder::new("mcp-junk"); let safe = dir.join("junk.vectorcraft").to_string_lossy().to_string(); let mut failures = vec![]; for t in tool_definitions() { @@ -169,7 +172,7 @@ proptest! { tail in junk_tail(), tool in prop::sample::select(vec!["open_file", "save_file", "export", "screenshot"]), ) { - let base = vectorcraft_testkit::temp_dir("mcp-confined"); + let base = TempFolder::new("mcp-confined"); let root = base.join("a/b/c/root"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(base.join("outside")).unwrap(); diff --git a/crates/pathops/src/addons/cleanup.rs b/crates/pathops/src/addons/cleanup.rs index edb3489007..75cf45b583 100644 --- a/crates/pathops/src/addons/cleanup.rs +++ b/crates/pathops/src/addons/cleanup.rs @@ -3,7 +3,7 @@ //! nearly closed paths; [`near_point_groups`] finds anchors to snap together, and [`rejoin`] welds //! open pieces whose ends meet back into paths. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use kurbo::{Point, Vec2}; use vectorcraft_geom::{Anchor, AnchorKind, EPS, SubPath}; @@ -23,24 +23,64 @@ fn moved(p: Point, a_p: Point, h: Point) -> Point { /// Remove the corner anchors (no handles, straight segments both sides) where a straight run goes /// on in the same direction: the sine of the angle it turns there is at most `tolerance`. Open ends /// stay, and a subpath keeps two anchors (three when closed) → how many went. +/// +/// Each anchor is tested once, first to last, against the nearest anchors still kept on either +/// side. On a closed subpath, an anchor with none kept before it has the last anchor as its +/// previous neighbor, and the last anchor has the first one kept as its next neighbor. The kept +/// anchors move into place once at the end, so the work grows linearly with the number of anchors. pub fn remove_collinear_anchors(sp: &mut SubPath, tolerance: f64) -> usize { - let mut removed = 0; - let mut i = 0; - while i < sp.anchors.len() && sp.anchors.len() > min_anchors(sp) { - let n = sp.anchors.len(); - let inner = sp.closed || (i > 0 && i + 1 < n); - let (Some(prev), Some(cur), Some(next)) = (sp.anchors.get((i + n - 1) % n), sp.anchors.get(i), sp.anchors.get((i + 1) % n)) else { break }; + let n = sp.anchors.len(); + let min = min_anchors(sp); + if n <= min { + return 0; + } + let mut keep = vec![true; n]; + let (mut left, mut first_kept, mut last_kept) = (n, None, None); + #[cfg(test)] + let mut work = 0; + for i in 0..n { + if left <= min { + break; + } + #[cfg(test)] + { + work += 1; + } + // Every anchor after `i` is still there, the last one included. + let p = last_kept.unwrap_or(n - 1); + let q = if i + 1 < n { i + 1 } else { first_kept.unwrap_or(i) }; + let inner = sp.closed || (last_kept.is_some() && i + 1 < n); + let (Some(prev), Some(cur), Some(next)) = (sp.anchors.get(p), sp.anchors.get(i), sp.anchors.get(q)) else { break }; let straight = !prev.has_out() && !cur.has_in() && !cur.has_out() && !next.has_in(); let (a, b) = (cur.p - prev.p, next.p - cur.p); let len = a.hypot() * b.hypot(); if inner && straight && len > EPS && a.dot(b) > 0.0 && a.cross(b).abs() / len <= tolerance { - sp.anchors.remove(i); - removed += 1; + if let Some(k) = keep.get_mut(i) { + *k = false; + } + left -= 1; } else { - i += 1; + first_kept.get_or_insert(i); + last_kept = Some(i); } } - removed + if left < n { + let mut kept = keep.iter(); + sp.anchors.retain(|_| kept.next().copied().unwrap_or(true)); + #[cfg(test)] + { + work += n; + } + } + #[cfg(test)] + COLLINEAR_WORK.with(|w| w.set(work)); + n - left +} + +#[cfg(test)] +thread_local! { + /// Anchors tested plus anchors moved by the last [`remove_collinear_anchors`] on this thread. + static COLLINEAR_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Merge each anchor lying within `distance` of the one before it (and a closed subpath's last @@ -101,58 +141,203 @@ pub fn close_nearly_closed(sp: &mut SubPath, distance: f64) -> bool { true } +/// The cell of a grid of `size`-wide squares that `p` lies in. +fn grid_cell(p: Point, size: f64) -> (i64, i64) { + ((p.x / size).floor() as i64, (p.y / size).floor() as i64) +} + +/// The cell `(x, y)` and the cells around it, each once. +fn around((x, y): (i64, i64)) -> Vec<(i64, i64)> { + let mut out: Vec<(i64, i64)> = [-1, 0, 1].into_iter().flat_map(|dx| [-1, 0, 1].map(|dy| (x.saturating_add(dx), y.saturating_add(dy)))).collect(); + out.sort_unstable(); + out.dedup(); + out +} + +/// The root of `i`'s set in the union-find forest `parent`. +fn root(parent: &mut [usize], mut i: usize) -> usize { + while let Some(&p) = parent.get(i).filter(|&&p| p != i) { + let up = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(i) { + *slot = up; + } + i = p; + } + i +} + /// Groups of `points` to snap together: points within `distance` of one another (and so on, one /// to the next) that aren't all at the same place, skipping the pairs `linked` names (such as /// neighbouring anchors of one path). Each group lists its points in order; snapping them all to /// the first one makes them coincide. Non-finite points and a distance that isn't a positive number /// find nothing. +/// +/// Two points are compared when their cells of a `distance`-wide grid touch. Each group grows from +/// one point at a time, which looks only at points no group has taken yet, so a cluster of points +/// all within `distance` of one another is grouped with work that grows linearly with its size. pub fn near_point_groups(points: &[Point], distance: f64, linked: impl Fn(usize, usize) -> bool) -> Vec> { if !(distance > 0.0 && distance.is_finite()) { return vec![]; } - let cell = |p: Point| ((p.x / distance).floor() as i64, (p.y / distance).floor() as i64); - let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); - for (i, p) in points.iter().enumerate().filter(|(_, p)| p.is_finite()) { - grid.entry(cell(*p)).or_default().push(i); - } - let mut parent: Vec = (0..points.len()).collect(); - fn root(parent: &mut [usize], mut i: usize) -> usize { - while let Some(&p) = parent.get(i).filter(|&&p| p != i) { - let up = parent.get(p).copied().unwrap_or(p); - if let Some(slot) = parent.get_mut(i) { - *slot = up; - } - i = p; + let mut cells = buckets(points, distance); + let mut near = Near { + distance, + linked, + parent: (0..points.len()).collect(), + taken: points.iter().map(|p| !p.is_finite()).collect(), + queue: vec![], + #[cfg(test)] + work: 0, + }; + for (start, &p) in points.iter().enumerate() { + if near.taken.get(start) != Some(&false) { + continue; } - i - } - for (&(cx, cy), members) in &grid { - for (dx, dy) in [(0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { - let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; - for &i in members { - for &j in others.iter().filter(|&&j| (dx, dy) != (0, 0) || j > i) { - let (Some(a), Some(b)) = (points.get(i), points.get(j)) else { continue }; - let d = a.distance(*b); - if d <= distance && d > 0.0 && !linked(i.min(j), i.max(j)) { - let (ri, rj) = (root(&mut parent, i), root(&mut parent, j)); - if let Some(slot) = parent.get_mut(ri.max(rj)) { - *slot = ri.min(rj); - } - } - } - } + if let Some(t) = near.taken.get_mut(start) { + *t = true; + } + let mut at = Some((start, p)); + while let Some((i, p)) = at { + near.reach(&mut cells, i, p); + at = near.queue.pop().and_then(|j| Some((j, *points.get(j)?))); } } + #[cfg(test)] + NEAR_WORK.with(|w| w.set(near.work)); // Only pairs that aren't at the same place were joined, so no group is all at one place. let mut groups: HashMap> = HashMap::new(); for i in 0..points.len() { - groups.entry(root(&mut parent, i)).or_default().push(i); + groups.entry(root(&mut near.parent, i)).or_default().push(i); } let mut out: Vec> = groups.into_values().filter(|g| g.len() > 1).collect(); out.sort_unstable(); out } +#[cfg(test)] +thread_local! { + /// Buckets, places and points looked at by the last [`near_point_groups`] on this thread. + static NEAR_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The finite points of one cell of a grid that lie in one square a quarter of a cell wide, with +/// the box around them. +struct Bucket { + lo: Point, + hi: Point, + /// Each place (bit for bit) and the points there that no group has taken yet, though some of + /// them may have been taken since. + places: Vec<(Point, Vec)>, +} + +impl Bucket { + /// Whether [`Point::distance`] puts every point here further than `distance` from `p`: one + /// coordinate alone differs by more. The square of a `distance` of 1e-150 or more does not + /// underflow, so the measured distance is at least that difference. + fn beyond(&self, p: Point, distance: f64) -> bool { + distance >= 1e-150 && [self.lo.x - p.x, p.x - self.hi.x, self.lo.y - p.y, p.y - self.hi.y].into_iter().any(|g| g > distance) + } +} + +/// The finite `points` in buckets, by grid cell. +fn buckets(points: &[Point], distance: f64) -> HashMap<(i64, i64), Vec> { + let quarter = distance / 4.0; + let mut cells: HashMap<(i64, i64), Vec> = HashMap::new(); + // Each bucket by its cell and square, and each place by its cell, bucket and coordinates. + let mut bucket_at: HashMap<[i64; 4], usize> = HashMap::new(); + let mut place_at: HashMap<(usize, [i64; 2], [u64; 2]), usize> = HashMap::new(); + for (i, &p) in points.iter().enumerate().filter(|(_, p)| p.is_finite()) { + let c = grid_cell(p, distance); + let list = cells.entry(c).or_default(); + let square = grid_cell(p, quarter); + let b = *bucket_at.entry([c.0, c.1, square.0, square.1]).or_insert_with(|| { + list.push(Bucket { lo: p, hi: p, places: vec![] }); + list.len() - 1 + }); + let Some(bucket) = list.get_mut(b) else { continue }; + bucket.lo = Point::new(bucket.lo.x.min(p.x), bucket.lo.y.min(p.y)); + bucket.hi = Point::new(bucket.hi.x.max(p.x), bucket.hi.y.max(p.y)); + let at = *place_at.entry((b, [c.0, c.1], [p.x.to_bits(), p.y.to_bits()])).or_insert_with(|| { + bucket.places.push((p, vec![])); + bucket.places.len() - 1 + }); + if let Some((_, here)) = bucket.places.get_mut(at) { + here.push(i); + } + } + cells +} + +/// The grouping behind [`near_point_groups`]. +struct Near { + distance: f64, + linked: F, + /// The union-find forest of the groups. + parent: Vec, + /// Whether each point is in a group being grown or grown already (non-finite ones never are). + taken: Vec, + /// Points taken into the group being grown whose own neighbors are still to be looked at. + queue: Vec, + #[cfg(test)] + work: usize, +} + +impl bool> Near { + /// Take into `i`'s group every point no group has taken yet that lies within the distance of + /// `p`, `i`'s place, in the cells around it, at a different place and not linked to `i`. + fn reach(&mut self, cells: &mut HashMap<(i64, i64), Vec>, i: usize, p: Point) { + for c in around(grid_cell(p, self.distance)) { + let Some(buckets) = cells.get_mut(&c) else { continue }; + buckets.retain_mut(|b| { + #[cfg(test)] + { + self.work += 1; + } + if !b.beyond(p, self.distance) { + b.places.retain_mut(|place| { + self.visit(i, p, place); + !place.1.is_empty() + }); + } + !b.places.is_empty() + }); + } + } + + /// Take into `i`'s group the points at `place` that `i` joins, and drop the ones taken. + fn visit(&mut self, i: usize, p: Point, (at, here): &mut (Point, Vec)) { + #[cfg(test)] + { + self.work += 1; + } + let d = p.distance(*at); + if !(d <= self.distance && d > 0.0) { + return; + } + here.retain(|&j| { + if self.taken.get(j) != Some(&false) { + return false; + } + #[cfg(test)] + { + self.work += 1; + } + if (self.linked)(i.min(j), i.max(j)) { + return true; + } + let (ri, rj) = (root(&mut self.parent, i), root(&mut self.parent, j)); + if let Some(slot) = self.parent.get_mut(ri.max(rj)) { + *slot = ri.min(rj); + } + if let Some(t) = self.taken.get_mut(j) { + *t = true; + } + self.queue.push(j); + false + }); + } +} + /// Open pieces welded into one path by [`rejoin`]: `members` index the pieces in the order they /// run along `path`. #[derive(Clone, Debug, PartialEq)] @@ -179,6 +364,12 @@ fn outward(sp: &SubPath, last: bool) -> Option { /// Each junction keeps the earlier piece's anchor and the later piece's out handle. Pieces that are /// closed, have fewer than two anchors or a non-finite coordinate stay out. → the chains of two or /// more pieces, and the single pieces that closed. +/// +/// Two ends are compared when their cells of a `gap`-wide grid touch, and pairs are taken in order +/// of the turn, then the distance, then the order of the ends. Each end looks for its first +/// partner in that order among the ends still free, searching outward from the reverse of its own +/// direction, and two ends that are each other's first partner meet; these are the pairs that +/// taking every pair in that order gives. pub fn rejoin(pieces: &[SubPath], gap: f64) -> Vec { if !(gap >= 0.0 && gap.is_finite()) { return vec![]; @@ -187,7 +378,7 @@ pub fn rejoin(pieces: &[SubPath], gap: f64) -> Vec { !sp.closed && sp.anchors.len() >= 2 && sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) }; // Each usable end: where it is and the way out of its piece there. - let ends: Vec<(End, Point, Vec2)> = pieces + let ends: Vec = pieces .iter() .enumerate() .filter(|(_, sp)| usable(sp)) @@ -197,49 +388,254 @@ pub fn rejoin(pieces: &[SubPath], gap: f64) -> Vec { Some(((i, last), a.p, outward(sp, last)?)) }) .collect(); - let points: Vec = ends.iter().map(|e| e.1).collect(); - // Every pair of ends within the gap, by the turn across the junction. - let mut pairs: Vec<(f64, f64, End, End)> = vec![]; - for (a, b) in candidate_pairs(&points, gap.max(1e-9)) { - let (Some(&(ea, pa, oa)), Some(&(eb, pb, ob))) = (ends.get(a), ends.get(b)) else { continue }; - // A piece's own two ends meet only when closing it leaves a shape (three anchors). - if ea.0 == eb.0 && pieces.get(ea.0).is_none_or(|sp| sp.anchors.len() < 3) { - continue; - } - let d = pa.distance(pb); - if d <= gap { - pairs.push((oa.dot(-ob).clamp(-1.0, 1.0).acos(), d, ea, eb)); - } - } - pairs.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); - let mut link: HashMap = HashMap::new(); - for (_, _, a, b) in pairs { - if !link.contains_key(&a) && !link.contains_key(&b) { - link.insert(a, b); - link.insert(b, a); - } - } + let link = Weld::new(pieces, &ends, gap).link(); chains(pieces, &link) } -/// Pairs (a < b) of `points` that may lie within `gap`: those in neighbouring grid cells. -fn candidate_pairs(points: &[Point], gap: f64) -> Vec<(usize, usize)> { - let cell = |p: Point| ((p.x / gap).floor() as i64, (p.y / gap).floor() as i64); - let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); - for (i, p) in points.iter().enumerate() { - grid.entry(cell(*p)).or_default().push(i); - } - let mut out = vec![]; - for (i, p) in points.iter().enumerate() { - let (cx, cy) = cell(*p); - for dx in -1..=1 { - for dy in -1..=1 { - let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; - out.extend(others.iter().filter(|&&j| j > i).map(|&j| (i, j))); +/// An end, where it lies and the unit direction pointing out of its piece there. +type EndAt = (End, Point, Vec2); + +/// Where a pair of ends (`a` < `b` in the list of ends) comes in the order [`rejoin`] takes pairs: +/// the turn across the junction, the distance between the ends, `a`, the place of `b`'s grid cell +/// among the cells around `a`'s, and `b`. +type Order = (f64, f64, usize, usize, usize); + +/// Whether pair `x` comes before pair `y`. +fn earlier(x: &Order, y: &Order) -> bool { + x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)).then((x.2, x.3, x.4).cmp(&(y.2, y.3, y.4))).is_lt() +} + +/// The place of cell `to` among the cells around `from`, listed by x step and then y step from -1 +/// to 1 (cells past the end of the grid repeat their neighbor; the first place counts). +fn listed_at(from: (i64, i64), to: (i64, i64)) -> usize { + let mut steps = [-1, 0, 1].into_iter().flat_map(|dx| [-1, 0, 1].map(|dy| (from.0.saturating_add(dx), from.1.saturating_add(dy)))); + steps.position(|c| c == to).unwrap_or(9) +} + +/// `angle` as an integer in the same order. +fn angle_key(angle: f64) -> i64 { + let bits = angle.to_bits() as i64; + bits ^ (((bits >> 63) as u64) >> 1) as i64 +} + +/// The angle between the directions at angles `a` and `b`, from 0 to π. +fn apart(a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + d.min(std::f64::consts::TAU - d) +} + +/// How far past the turn of the best pair found so far [`Weld::first_partner`] still looks. The +/// turn [`rejoin`] measures (the arc cosine of a dot product of unit vectors) is within 1e-7 of +/// the angle between the directions, and the angles the search sorts by are within 1e-14 of it. +const TURN_SLACK: f64 = 1e-6; + +/// Ends in one grid cell at one place with one outward direction (bit for bit): every other end +/// pairs with each of them at the same turn and distance. +struct Class { + at: Point, + angle: f64, + /// The ends here no pair has taken yet. + free: BTreeSet, +} + +/// The pairing behind [`rejoin`]. +struct Weld<'a> { + pieces: &'a [SubPath], + ends: &'a [EndAt], + gap: f64, + /// The width of the grid's cells. + size: f64, + classes: Vec, + /// Each end's class. + class_of: Vec, + /// Each grid cell's classes with free ends, by the angle of their direction. + cells: HashMap<(i64, i64), BTreeSet<(i64, usize)>>, + /// Whether each end is still free. + free: Vec, + /// Whether each end has looked for its first partner. Another end's first partner in a class + /// is one of the class's first three free ends, so the ends after those cannot meet next and + /// look only once they move up. + awake: Vec, + /// Each awake end's first partner, and where that pair comes in the order. + first: Vec>, + /// The ends whose first partner each end is (some may have moved on since). + wanted_by: Vec>, + /// Ends to check for a partner whose first partner they are. + todo: Vec, + #[cfg(test)] + work: std::cell::Cell, +} + +impl<'a> Weld<'a> { + fn new(pieces: &'a [SubPath], ends: &'a [EndAt], gap: f64) -> Self { + let size = gap.max(1e-9); + let (mut classes, mut class_of, mut cells) = (vec![], vec![], HashMap::new()); + let mut class_at: HashMap<((i64, i64), [u64; 4]), usize> = HashMap::new(); + for (i, &(_, p, out)) in ends.iter().enumerate() { + let c = grid_cell(p, size); + let k = *class_at.entry((c, [p.x, p.y, out.x, out.y].map(f64::to_bits))).or_insert_with(|| { + let angle = out.y.atan2(out.x); + cells.entry(c).or_insert_with(BTreeSet::new).insert((angle_key(angle), classes.len())); + classes.push(Class { at: p, angle, free: BTreeSet::new() }); + classes.len() - 1 + }); + if let Some(class) = classes.get_mut(k) { + class.free.insert(i); } + class_of.push(k); + } + let n = ends.len(); + Weld { + pieces, + ends, + gap, + size, + classes, + class_of, + cells, + free: vec![true; n], + awake: vec![false; n], + first: vec![None; n], + wanted_by: vec![vec![]; n], + todo: vec![], + #[cfg(test)] + work: std::cell::Cell::new(0), } } - out + + /// The links between the ends that meet: two free ends that are each other's first partner + /// meet, and the ends that wanted either of them look again, until no such pair is left. + fn link(mut self) -> HashMap { + for k in 0..self.classes.len() { + self.wake(k); + } + let mut link: HashMap = HashMap::new(); + while let Some(v) = self.todo.pop() { + let Some(w) = self.partner(v) else { continue }; + if self.free.get(v) != Some(&true) || self.free.get(w) != Some(&true) || self.partner(w) != Some(v) { + continue; + } + let (Some(&(ev, ..)), Some(&(ew, ..))) = (self.ends.get(v), self.ends.get(w)) else { continue }; + link.insert(ev, ew); + link.insert(ew, ev); + self.take(v); + self.take(w); + for e in [v, w] { + if let Some(&k) = self.class_of.get(e) { + self.wake(k); + } + } + let waiting: Vec = [v, w].iter().flat_map(|&e| self.wanted_by.get_mut(e).map(std::mem::take).unwrap_or_default()).collect(); + for x in waiting { + if self.free.get(x) == Some(&true) && matches!(self.partner(x), Some(p) if p == v || p == w) { + self.look(x); + } + } + } + #[cfg(test)] + REJOIN_WORK.with(|w| w.set(self.work.get())); + link + } + + /// End `v`'s first partner, once it has looked. + fn partner(&self, v: usize) -> Option { + self.first.get(v).and_then(|f| f.as_ref()).map(|f| f.1) + } + + /// Have the first three free ends of class `k` that have not looked for a partner look. + fn wake(&mut self, k: usize) { + let Some(class) = self.classes.get(k) else { return }; + let sleeping: Vec = class.free.iter().take(3).copied().filter(|&e| self.awake.get(e) == Some(&false)).collect(); + for e in sleeping { + if let Some(a) = self.awake.get_mut(e) { + *a = true; + } + self.look(e); + } + } + + /// Find free end `e`'s first partner, and queue `e` to check whether that end wants it too. + fn look(&mut self, e: usize) { + let next = self.first_partner(e); + if let Some(list) = next.as_ref().and_then(|f| self.wanted_by.get_mut(f.1)) { + list.push(e); + } + if let Some(f) = self.first.get_mut(e) { + *f = next; + } + self.todo.push(e); + } + + /// End `e` has met another: it is no longer free. + fn take(&mut self, e: usize) { + if let Some(f) = self.free.get_mut(e) { + *f = false; + } + let Some(&k) = self.class_of.get(e) else { return }; + let Some(class) = self.classes.get_mut(k) else { return }; + class.free.remove(&e); + if class.free.is_empty() + && let Some(&(_, p, _)) = self.ends.get(e) + && let Some(set) = self.cells.get_mut(&grid_cell(p, self.size)) + { + set.remove(&(angle_key(class.angle), k)); + } + } + + /// The free end `v` meets first, and where that pair comes in the order: the classes in the + /// cells around `v`'s are looked at by how far their direction is from the reverse of `v`'s, + /// until that is more than [`TURN_SLACK`] past the best turn found. + fn first_partner(&self, v: usize) -> Option<(Order, usize)> { + let &((piece, _), p, out) = self.ends.get(v)?; + let toward = (-out.y).atan2(-out.x); + let split = (angle_key(toward), 0); + let mut best: Option<(Order, usize)> = None; + for c in around(grid_cell(p, self.size)) { + let Some(set) = self.cells.get(&c) else { continue }; + // Both ways round from `toward`; between them they meet every class once. + let mut up = set.range(split..).chain(set.range(..split)).peekable(); + let mut down = set.range(..split).rev().chain(set.range(split..).rev()).peekable(); + let turn = |e: Option<&&(i64, usize)>| e.and_then(|e| self.classes.get(e.1)).map_or(f64::INFINITY, |k| apart(k.angle, toward)); + for _ in 0..set.len() { + let (tu, td) = (turn(up.peek()), turn(down.peek())); + if best.as_ref().is_some_and(|b| tu.min(td) > b.0.0 + TURN_SLACK) { + break; + } + let Some(&(_, k)) = (if tu <= td { up.next() } else { down.next() }) else { break }; + if let Some(pair) = self.pair_with(v, p, piece, k) + && best.as_ref().is_none_or(|b| earlier(&pair.0, &b.0)) + { + best = Some(pair); + } + } + } + best + } + + /// The first free end of class `k` that free end `v` (at `p`, on `piece`) may meet, and where + /// that pair comes in the order. Within a class the pairs differ only in the ends' order, so + /// the end listed first comes first. + fn pair_with(&self, v: usize, p: Point, piece: usize, k: usize) -> Option<(Order, usize)> { + #[cfg(test)] + self.work.set(self.work.get() + 1); + let class = self.classes.get(k)?; + if p.distance(class.at) > self.gap { + return None; + } + // A piece's own two ends meet only when closing it leaves a shape (three anchors). + let short = self.pieces.get(piece).is_none_or(|sp| sp.anchors.len() < 3); + let u = *class.free.iter().find(|&&u| u != v && !(short && self.ends.get(u).is_some_and(|e| e.0.0 == piece)))?; + let (a, b) = (v.min(u), v.max(u)); + let (&(_, pa, oa), &(_, pb, ob)) = (self.ends.get(a)?, self.ends.get(b)?); + let at = listed_at(grid_cell(pa, self.size), grid_cell(pb, self.size)); + Some(((oa.dot(-ob).clamp(-1.0, 1.0).acos(), pa.distance(pb), a, at, b), u)) + } +} + +#[cfg(test)] +thread_local! { + /// Classes of ends looked at by the last [`rejoin`] on this thread. + static REJOIN_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Walk the links between piece ends into chains. diff --git a/crates/pathops/src/addons/cleanup/tests.rs b/crates/pathops/src/addons/cleanup/tests.rs index 3e1c58d42b..d262128234 100644 --- a/crates/pathops/src/addons/cleanup/tests.rs +++ b/crates/pathops/src/addons/cleanup/tests.rs @@ -1,7 +1,12 @@ -use kurbo::{Point, Vec2}; -use vectorcraft_geom::{Anchor, AnchorKind, SubPath}; +use std::collections::HashMap; -use super::{close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles}; +use kurbo::{Point, Vec2}; +use vectorcraft_geom::{Anchor, AnchorKind, EPS, SubPath}; + +use super::{ + End, Rejoined, chains, close_nearly_closed, merge_duplicate_anchors, near_point_groups, outward, rejoin, remove_collinear_anchors, + retract_tiny_handles, root, +}; fn line(pts: &[(f64, f64)], closed: bool) -> SubPath { SubPath::polyline(&pts.iter().map(|&p| p.into()).collect::>(), closed) @@ -30,6 +35,174 @@ fn collinear_anchors_go_and_corners_stay() { assert_eq!(remove_collinear_anchors(&mut bend, 0.005), 1); } +/// The collinear-anchor removal done one `Vec::remove` at a time, testing the anchor that moves +/// into the gap next: the result [`remove_collinear_anchors`] gives. +fn one_at_a_time(sp: &mut SubPath, tolerance: f64) -> usize { + let min = if sp.closed { 3 } else { 2 }; + let mut removed = 0; + let mut i = 0; + while i < sp.anchors.len() && sp.anchors.len() > min { + let n = sp.anchors.len(); + let inner = sp.closed || (i > 0 && i + 1 < n); + let (prev, cur, next) = (sp.anchors[(i + n - 1) % n], sp.anchors[i], sp.anchors[(i + 1) % n]); + let straight = !prev.has_out() && !cur.has_in() && !cur.has_out() && !next.has_in(); + let (a, b) = (cur.p - prev.p, next.p - cur.p); + let len = a.hypot() * b.hypot(); + if inner && straight && len > EPS && a.dot(b) > 0.0 && a.cross(b).abs() / len <= tolerance { + sp.anchors.remove(i); + removed += 1; + } else { + i += 1; + } + } + removed +} + +/// A subpath's closed flag and every anchor's coordinates bit for bit, with its kind. +fn bits(sp: &SubPath) -> (bool, Vec<([u64; 6], AnchorKind)>) { + let anchors = sp.anchors.iter().map(|a| ([a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits), a.kind)); + (sp.closed, anchors.collect()) +} + +/// Check that [`remove_collinear_anchors`] gives `sp` the count and anchors [`one_at_a_time`] does. +fn same_as_one_at_a_time(sp: &SubPath, tolerance: f64) { + let (mut fast, mut slow) = (sp.clone(), sp.clone()); + let got = remove_collinear_anchors(&mut fast, tolerance); + let want = one_at_a_time(&mut slow, tolerance); + assert_eq!(got, want, "count at tolerance {tolerance} for {sp:?}"); + assert_eq!(bits(&fast), bits(&slow), "anchors at tolerance {tolerance} for {sp:?}"); +} + +/// xorshift64: the same numbers on every run. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn pick(&mut self, xs: &[T]) -> T { + xs[(self.next() % xs.len() as u64) as usize] + } +} + +/// A subpath of `n` anchors stepping mostly straight along one direction, with repeated points, +/// small and large bends, reversals, short and long handles and non-finite or huge coordinates. +fn generated(r: &mut Rng, n: usize, closed: bool) -> SubPath { + let dir = r.pick(&[Vec2::new(1.0, 0.0), Vec2::new(0.0, -1.0), Vec2::new(0.6, 0.8), Vec2::new(-1.0, 1.0)]); + let side = Vec2::new(-dir.y, dir.x); + let mut at = Point::new(r.pick(&[0.0, 3.0, -7.5]), r.pick(&[0.0, 2.0])); + let anchors = (0..n) + .map(|_| { + at += dir * r.pick(&[1.0, 1.0, 1.0, 2.0, 0.5, 0.0, -1.0]); + let bend = r.pick(&[0.0, 0.0, 0.0, 0.0, 1e-3, 0.004, 0.005, 0.006, 1.0]); + let mut p = at + side * bend; + match r.next() % 40 { + 0 => p.x = f64::NAN, + 1 => p.y = f64::INFINITY, + 2 => p.x = f64::NEG_INFINITY, + 3 => p = Point::new(p.x * 1e300, p.y * 1e300), + 4 => p = Point::new(1e-300, -1e-300), + _ => {} + } + let handle = |r: &mut Rng| p + Vec2::new(r.pick(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-10, 1.0]), 0.0); + let (h_in, h_out) = (handle(r), handle(r)); + Anchor { p, h_in, h_out, kind: r.pick(&[AnchorKind::Corner, AnchorKind::Corner, AnchorKind::Smooth]) } + }) + .collect(); + SubPath::new(anchors, closed) +} + +const TOLERANCES: [f64; 12] = [-1.0, -0.0, 0.0, 1e-9, 0.001, 0.005, 0.5, 1.0, 2.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY]; + +#[test] +fn collinear_anchors_go_as_when_removed_one_at_a_time() { + let mut r = Rng(0x9e37_79b9_7f4a_7c15); + for n in (0..40).chain([64, 100, 257]) { + for _ in 0..40 { + for closed in [false, true] { + let sp = generated(&mut r, n, closed); + for tolerance in TOLERANCES { + same_as_one_at_a_time(&sp, tolerance); + } + } + } + } +} + +#[test] +fn collinear_anchors_keep_their_neighbors_order_and_ends() { + let ring = |pts: &[(f64, f64)]| line(pts, true); + let cases = [ + // A closed run whose first anchor goes: the last anchor's next neighbor is then the second. + ring(&[(5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]), + ring(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0), (5.0, 0.0), (2.0, 4.0)]), + // Every anchor on one line: the path stops at three (closed) or two (open) anchors. + ring(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)]), + line(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)], false), + // The last anchor lies between the first one kept and the one before it. + ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 5.0)]), + // Repeated and zero-length points. + line(&[(0.0, 0.0), (0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 0.0)], false), + ring(&[(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (0.0, 0.0)]), + // Products of huge coordinates overflow; tiny ones underflow. + line(&[(0.0, 0.0), (1e200, 1e200), (2e200, 2e200), (f64::MAX, f64::MAX)], false), + line(&[(0.0, 0.0), (1e-200, 0.0), (2e-200, 0.0)], false), + line(&[(0.0, 0.0), (f64::NAN, 0.0), (2.0, 0.0), (f64::INFINITY, 0.0), (5.0, 0.0)], false), + ]; + for sp in &cases { + for tolerance in TOLERANCES { + same_as_one_at_a_time(sp, tolerance); + } + } + let mut first = cases[0].clone(); + assert_eq!(remove_collinear_anchors(&mut first, 0.0), 1); + assert_eq!(points(&first), [(10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]); + let (mut closed, mut open) = (cases[2].clone(), cases[3].clone()); + assert_eq!((remove_collinear_anchors(&mut closed, 0.0), closed.anchors.len()), (2, 3)); + assert_eq!(remove_collinear_anchors(&mut open, 0.0), 3); + assert_eq!(points(&open), [(0.0, 0.0), (4.0, 0.0)], "open ends stay"); + // A handle on either side of either segment keeps the anchor between them. + for side in 0..4 { + let mut sp = line(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)], false); + let (i, h) = [(0, 1), (1, 0), (1, 1), (2, 0)][side]; + for kind in [AnchorKind::Corner, AnchorKind::Smooth] { + let a = &mut sp.anchors[i]; + a.kind = kind; + *(if h == 0 { &mut a.h_in } else { &mut a.h_out }) = a.p + Vec2::new(0.0, 1.0); + same_as_one_at_a_time(&sp, 0.005); + assert_eq!(remove_collinear_anchors(&mut sp.clone(), 0.005), 0, "handle {h} on anchor {i}"); + } + } +} + +#[test] +fn collinear_anchors_at_the_tolerance_go_and_just_past_it_stay() { + let sp = line(&[(0.0, 0.0), (100.0, 0.1), (200.0, 0.0)], false); + let (a, b) = (sp.anchors[1].p - sp.anchors[0].p, sp.anchors[2].p - sp.anchors[1].p); + let sine = a.cross(b).abs() / (a.hypot() * b.hypot()); + for (tolerance, removed) in [(sine, 1), (sine.next_down(), 0), (sine.next_up(), 1)] { + same_as_one_at_a_time(&sp, tolerance); + assert_eq!(remove_collinear_anchors(&mut sp.clone(), tolerance), removed, "{tolerance}"); + } +} + +#[test] +fn collinear_anchors_go_in_linear_work() { + for (n, closed) in [(10_000, false), (20_000, false), (20_000, true)] { + let pts: Vec = (0..n).map(|i| Point::new(i as f64, 0.0)).collect(); + let mut sp = SubPath::polyline(&pts, closed); + let removed = remove_collinear_anchors(&mut sp, 0.005); + // A closed straight run stops at three anchors; an open one keeps its two ends. + assert_eq!((removed, sp.anchors.len()), if closed { (n - 3, 3) } else { (n - 2, 2) }); + let work = super::COLLINEAR_WORK.with(std::cell::Cell::get); + assert!(work <= 2 * n, "{n} anchors took {work} steps"); + } +} + #[test] fn duplicate_anchors_merge_keeping_the_outer_handles() { let mut sp = SubPath::new( @@ -89,6 +262,244 @@ fn near_points_group_but_linked_and_equal_ones_do_not() { assert!(near_point_groups(&far, 1e-300, |_, _| false).is_empty()); } +/// Nearly Coincident Anchors' grouping comparing every pair of points in touching grid cells: the +/// groups [`near_point_groups`] gives. +fn near_groups_pair_by_pair(points: &[Point], distance: f64, linked: impl Fn(usize, usize) -> bool) -> Vec> { + if !(distance > 0.0 && distance.is_finite()) { + return vec![]; + } + let cell = |p: Point| ((p.x / distance).floor() as i64, (p.y / distance).floor() as i64); + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, p) in points.iter().enumerate().filter(|(_, p)| p.is_finite()) { + grid.entry(cell(*p)).or_default().push(i); + } + let mut parent: Vec = (0..points.len()).collect(); + for (&(cx, cy), members) in &grid { + for (dx, dy) in [(0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; + for &i in members { + for &j in others.iter().filter(|&&j| (dx, dy) != (0, 0) || j > i) { + let d = points[i].distance(points[j]); + if d <= distance && d > 0.0 && !linked(i.min(j), i.max(j)) { + let (ri, rj) = (root(&mut parent, i), root(&mut parent, j)); + parent[ri.max(rj)] = ri.min(rj); + } + } + } + } + } + let mut groups: HashMap> = HashMap::new(); + for i in 0..points.len() { + groups.entry(root(&mut parent, i)).or_default().push(i); + } + let mut out: Vec> = groups.into_values().filter(|g| g.len() > 1).collect(); + out.sort_unstable(); + out +} + +/// Points around a few spots on a lattice, many at the same place, with signed zeros, huge, tiny +/// and non-finite coordinates. +fn generated_points(r: &mut Rng, n: usize, distance: f64) -> Vec { + let spots: Vec = (0..1 + r.next() % 6).map(|_| Point::new((r.next() % 5) as f64 * distance, (r.next() % 5) as f64 * distance)).collect(); + (0..n) + .map(|_| { + let jitter = |r: &mut Rng| r.pick(&[0.0, 0.0, -0.0, 0.1, 0.3, 0.5, 0.7, 0.99, 1.0, 1.01, 2.0, -0.5, -1.0]) * distance; + let at = r.pick(&spots); + let p = Point::new(at.x + jitter(r), at.y + jitter(r)); + match r.next() % 50 { + 0 => Point::new(f64::NAN, p.y), + 1 => Point::new(p.x, f64::INFINITY), + 2 => Point::new(-0.0, -0.0), + 3 => Point::new(p.x * 1e300, p.y), + 4 => Point::new(1e-300, 1e-300), + _ => p, + } + }) + .collect() +} + +#[test] +fn near_points_group_as_when_every_pair_is_compared() { + let mut r = Rng(0x2545_f491_4f6c_dd1d); + let linked: [fn(usize, usize) -> bool; 5] = + [|_, _| false, |a, b| b == a + 1, |a, b| (a * 31 + b * 17) % 5 == 0, |a, b| (a + b) % 2 == 0, |_, _| true]; + for n in (0..30).chain([64, 150, 300]) { + for _ in 0..12 { + for distance in [0.01, 0.5, 1.0, 3.0, 1e-200, 1e300] { + let pts = generated_points(&mut r, n, distance); + for (k, linked) in linked.iter().enumerate() { + let want = near_groups_pair_by_pair(&pts, distance, linked); + assert_eq!(near_point_groups(&pts, distance, linked), want, "linked {k} at {distance} for {pts:?}"); + } + } + } + } +} + +/// A tight cluster of `n` points around `at`, no two at the same place. +fn cluster(at: Point, radius: f64, n: usize) -> Vec { + (0..n).map(|i| at + Vec2::from_angle(i as f64 * 2.399_963) * (radius * ((i + 1) as f64 / n as f64).sqrt())).collect() +} + +#[test] +fn near_points_in_a_dense_cell_group_in_linear_work() { + let n = 4_000; + let neighbours = |a: usize, b: usize| b == a + 1; + let one_cluster = cluster(Point::new(0.005, 0.005), 0.004, n); + let mut two_clusters = cluster(Point::new(0.001, 0.001), 0.0009, n / 2); + two_clusters.extend(cluster(Point::new(0.019, 0.019), 0.0009, n / 2)); + let same_place = vec![Point::new(0.005, 0.005); n]; + let cases = [(&one_cluster, 1, n), (&two_clusters, 2, n / 2), (&same_place, 0, 0)]; + for (pts, count, size) in cases { + let groups = near_point_groups(pts, 0.01, neighbours); + let work = super::NEAR_WORK.with(std::cell::Cell::get); + assert_eq!(groups, near_groups_pair_by_pair(pts, 0.01, neighbours)); + assert_eq!(groups.len(), count); + assert!(groups.iter().all(|g| g.len() == size), "{:?}", groups.iter().map(Vec::len).collect::>()); + assert!(work <= 4 * n, "{n} points took {work} steps"); + } +} + +/// One pair of ends (`a` < `b` in the list of ends) for [`rejoin_sorting_every_pair`]: the turn, +/// the distance, and the two ends. +type Pair = (f64, f64, End, End); + +/// Rejoin listing every pair of ends in touching grid cells and sorting them by turn and then +/// distance: the joins [`rejoin`] makes. +fn rejoin_sorting_every_pair(pieces: &[SubPath], gap: f64) -> Vec { + if !(gap >= 0.0 && gap.is_finite()) { + return vec![]; + } + let usable = |sp: &&SubPath| { + !sp.closed && sp.anchors.len() >= 2 && sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) + }; + let ends: Vec<(End, Point, Vec2)> = pieces + .iter() + .enumerate() + .filter(|(_, sp)| usable(sp)) + .flat_map(|(i, sp)| [(i, sp, false), (i, sp, true)]) + .filter_map(|(i, sp, last)| { + let a = if last { sp.anchors.last() } else { sp.anchors.first() }?; + Some(((i, last), a.p, outward(sp, last)?)) + }) + .collect(); + let size = gap.max(1e-9); + let cell = |p: Point| ((p.x / size).floor() as i64, (p.y / size).floor() as i64); + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, e) in ends.iter().enumerate() { + grid.entry(cell(e.1)).or_default().push(i); + } + let mut pairs: Vec = vec![]; + for (a, &(ea, pa, oa)) in ends.iter().enumerate() { + let (cx, cy) = cell(pa); + for dx in -1..=1 { + for dy in -1..=1 { + let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; + for &b in others.iter().filter(|&&b| b > a) { + let (eb, pb, ob) = ends[b]; + if ea.0 == eb.0 && pieces[ea.0].anchors.len() < 3 { + continue; + } + let d = pa.distance(pb); + if d <= gap { + pairs.push((oa.dot(-ob).clamp(-1.0, 1.0).acos(), d, ea, eb)); + } + } + } + } + } + pairs.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); + let mut link: HashMap = HashMap::new(); + for (_, _, a, b) in pairs { + if !link.contains_key(&a) && !link.contains_key(&b) { + link.insert(a, b); + link.insert(b, a); + } + } + chains(pieces, &link) +} + +/// Check that [`rejoin`] joins `pieces` as [`rejoin_sorting_every_pair`] does, bit for bit. +fn same_as_sorting_every_pair(pieces: &[SubPath], gap: f64) { + let (got, want) = (rejoin(pieces, gap), rejoin_sorting_every_pair(pieces, gap)); + let shape = |out: &[Rejoined]| out.iter().map(|j| (j.members.clone(), bits(&j.path))).collect::>(); + assert_eq!(shape(&got), shape(&want), "gap {gap} for {pieces:?}"); +} + +/// Pieces of two to four anchors whose ends sit on or near a few lattice points, often at the +/// same place, some curving, closed or non-finite. +fn generated_pieces(r: &mut Rng, n: usize, gap: f64) -> Vec { + let spot = |r: &mut Rng| { + let near = r.pick(&[0.0, 0.0, 0.0, 0.25, 0.5, 1.0, 1.5]) * gap; + Point::new((r.next() % 3) as f64 + near, (r.next() % 3) as f64 + r.pick(&[0.0, 0.0, near, -near])) + }; + (0..n) + .map(|_| { + let count = 2 + (r.next() % 3) as usize; + let mut pts: Vec = (0..count).map(|_| spot(r)).collect(); + if r.next().is_multiple_of(30) { + pts[0].x = f64::NAN; + } + let mut sp = SubPath::polyline(&pts, r.next().is_multiple_of(15)); + if r.next().is_multiple_of(4) { + let a = &mut sp.anchors[0]; + a.h_out = a.p + r.pick(&[Vec2::new(0.3, 0.1), Vec2::new(0.0, -0.2), Vec2::new(-0.1, -0.1)]); + } + sp + }) + .collect() +} + +#[test] +fn rejoin_joins_as_when_every_pair_is_sorted() { + let mut r = Rng(0xd1b5_4a32_d192_ed03); + for n in (0..25).chain([40, 80, 160]) { + for _ in 0..15 { + for gap in [0.0, 0.05, 0.1, 0.5, 1.0, 3.0] { + same_as_sorting_every_pair(&generated_pieces(&mut r, n, gap), gap); + } + } + } + // Ends meeting from directions that tie: a plus sign, a doubled star and stacked copies. + let plus: Vec = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)].map(|(x, y)| line(&[(0.0, 0.0), (x, y)], false)).to_vec(); + let mut doubled = plus.clone(); + doubled.extend(plus.iter().map(|sp| { + let mut sp = sp.clone(); + sp.reverse(); + sp + })); + let stacked = vec![line(&[(0.0, 0.0), (1.0, 0.0), (2.0, 1.0)], false); 7]; + for pieces in [&plus, &doubled, &stacked] { + for gap in [0.0, 0.1, 5.0] { + same_as_sorting_every_pair(pieces, gap); + } + } +} + +#[test] +fn rejoin_pairs_coincident_ends_in_near_linear_work() { + let n = 2_000; + let ray = |angle: f64| { + let tip = Vec2::from_angle(angle) * 100.0; + line(&[(0.0, 0.0), (tip.x, tip.y)], false) + }; + let star: Vec = (0..n).map(|i| ray(i as f64 * std::f64::consts::TAU / n as f64)).collect(); + let mut r = Rng(0x9e37_79b9_7f4a_7c15); + let scattered: Vec = (0..n).map(|_| ray((r.next() % 1_000_000) as f64 * 1e-6 * std::f64::consts::TAU)).collect(); + let stacked = vec![line(&[(0.0, 0.0), (10.0, 0.0)], false); n]; + for (name, pieces) in [("star", &star), ("scattered", &scattered), ("stacked", &stacked)] { + let out = rejoin(pieces, 0.1); + let work = super::REJOIN_WORK.with(std::cell::Cell::get); + let want = rejoin_sorting_every_pair(pieces, 0.1); + assert_eq!( + out.iter().map(|j| (j.members.clone(), bits(&j.path))).collect::>(), + want.iter().map(|j| (j.members.clone(), bits(&j.path))).collect::>(), + "{name}" + ); + assert!(work <= 20 * n, "{name}: {n} pieces took {work} steps"); + } +} + #[test] fn rejoin_welds_pieces_within_the_gap() { // Three pieces of one zig-zag, the middle one drawn backwards, plus one too far away. diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index d8d121ecb9..960f4036d8 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -22,14 +22,23 @@ pub struct SimplifyOptions { /// How far (points, the spread of a Gaussian along the path) the path is smoothed before it is /// refitted, so that bumps and jags come out as one curve ([`smooth_level`]); 0 for none. pub smooth: f64, + /// With [`smooth`](Self::smooth), refit each stretch fitted as one piece (between corners) within + /// twice the farthest the smoothing moved any of its points when that is less than `tolerance`, and + /// never within less than a tenth of `tolerance`. A stretch that smoothing barely moves anywhere + /// keeps its shape. The limit is the same along the whole stretch, so where smoothing moves some of + /// its points far, all of it is refitted within up to `tolerance`, as without this option. + pub smooth_moved_only: bool, } impl Default for SimplifyOptions { fn default() -> Self { - Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0 } + Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0, smooth_moved_only: false } } } +/// The least share of the tolerance that [`SimplifyOptions::smooth_moved_only`] refits within. +const SMOOTH_MOVED_FLOOR: f64 = 0.1; + /// Simplify with the default corner threshold (30°). pub fn simplify(path: &PathData, tolerance: f64) -> PathData { simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() }) @@ -310,16 +319,27 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge } /// Adds the refit of `part`, consecutive segments with no corner between them and the steps to -/// sample each one with, to `result`. `ends` is the direction it leaves and arrives along, when not -/// those of its first and last segments. -fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Option, result: &mut Vec) { +/// sample each one with, to `result`. `ends`, given only when `part` is a whole closed loop, is the +/// direction it leaves and arrives along; when the loop is smoothed, the smoothing runs across its +/// start and the direction comes from the smoothed samples there. +fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option, result: &mut Vec) { let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return }; let mut pts = Vec::new(); for (k, (s, steps)) in part.iter().enumerate() { sample(&s.c, *steps, &mut pts, k == 0); } + let mut tol = tol; if opts.smooth > 0.0 { - pts = smooth_points(&pts, opts.smooth); + let smoothed = if ends.is_some() { smooth_loop_points(&pts, opts.smooth) } else { smooth_points(&pts, opts.smooth) }; + if ends.is_some() + && let (Some(&next), Some(&prev)) = (smoothed.get(1), smoothed.len().checked_sub(2).and_then(|i| smoothed.get(i))) + { + ends = unit(next - prev).or(ends); + } + if opts.smooth_moved_only { + tol = tol.min((2.0 * smoothing_moved(&pts, &smoothed)).max(tol * SMOOTH_MOVED_FLOOR)); + } + pts = smoothed; } if opts.straight_lines { let keep = rdp(&pts, tol * (1.0 - SAMPLE_GAP)); @@ -328,14 +348,39 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Op } // The fit may be `tol * 0.98` from the lines between the samples, those lines may be // `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up - // to 1.5% of `tol`: 3/4 of how far its handles may be from the line. + // to 1.5% of `tol`: 3/4 of how far its handles may be from the line. The fitted curves join + // smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. The last + // curve of a loop fitted across its start leaves along the loop's first segment. let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d)); - for c in fit_cubics(&pts, t0, t1, tol * 0.98) { - let line = is_straight(&c, tol * 0.02); + let fitted = fit_cubics(&pts, t0, t1, tol * 0.98); + let from = result.len(); + let mut arrive = t0; + for (k, &c) in fitted.iter().enumerate() { + let leave = match fitted.get(k + 1) { + Some(next) => start_tangent(next), + None if ends.is_some() => result.get(from).map_or(t1, |s| start_tangent(&s.c)), + None => t1, + }; + let line = is_straight(&c, tol * 0.02) && line_keeps_joins(&c, arrive, leave); + arrive = if line { unit(c.p3 - c.p0).unwrap_or(arrive) } else { end_tangent(&c) }; result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); } } +/// Most degrees between the line from a refitted curve's start to its end and the directions the +/// path arrives and leaves along, for the curve to become that line: so every join next to such a +/// line turns by at most this much. VectorCraft's own value. +const LINE_JOIN_DEG: f64 = 0.5; + +/// Whether the line from `c.p0` to `c.p3` is within [`LINE_JOIN_DEG`] of `arrive`, the direction the +/// path arrives at `c.p0` along, and of `leave`, the direction it leaves `c.p3` along. A curve whose +/// ends are at one point, or a direction of no length, gives `false`. +fn line_keeps_joins(c: &CubicBez, arrive: Vec2, leave: Vec2) -> bool { + let (Some(dir), Some(arrive), Some(leave)) = (unit(c.p3 - c.p0), unit(arrive), unit(leave)) else { return false }; + let min = LINE_JOIN_DEG.to_radians().cos(); + dir.dot(arrive) >= min && dir.dot(leave) >= min +} + /// `pts` (a run's samples, in order) smoothed along the path: each point becomes the Gaussian-weighted /// mean of the points around it, `sigma` points of arc length wide, the window narrowing towards the /// ends so that the first and last points stay where they are. Consecutive samples within @@ -346,6 +391,66 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec { smooth_points_counted(pts, sigma, &mut 0) } +/// [`smooth_points`] for the samples `pts` of a whole closed loop, whose last sample is its first: +/// the samples at its start are smoothed like the others, and the first and last ones move together. +/// The loop is smoothed twice as an open run of the same samples, once from its start and once from +/// the sample nearest halfway round by length, where [`smooth_points`] keeps each run's ends in place. +/// Each sample takes a blend of the two that runs smoothly round the loop, with weights +/// `(1 ± cos θ) / 2`: `θ` goes from 0 at the start to π at the halfway sample and on to 2π at the end, +/// in proportion to length, so the start takes only the second run and the halfway sample only the +/// first, and no sample takes any of a run's fixed ends. +fn smooth_loop_points(pts: &[Point], sigma: f64) -> Vec { + let Some(m) = pts.len().checked_sub(1).filter(|&m| m >= 3) else { return smooth_points(pts, sigma) }; + let mut along = Vec::with_capacity(pts.len()); + let mut total = 0.0; + for (i, p) in pts.iter().enumerate() { + if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) { + total += prev.distance(*p); + } + along.push(total); + } + if !(total.is_finite() && total > 0.0) { + return smooth_points(pts, sigma); + } + let half = (1..m).min_by(|&a, &b| (along[a] - total / 2.0).abs().total_cmp(&(along[b] - total / 2.0).abs())).unwrap_or(m / 2); + let mid = along[half]; + if !(mid > 0.0 && mid < total) { + return smooth_points(pts, sigma); + } + let ring = &pts[..m]; + let turned: Vec = ring[half..].iter().chain(&ring[..=half]).copied().collect(); + let from_half = smooth_points(&turned, sigma); + let mut out = smooth_points(pts, sigma); + for (i, (p, &s)) in out.iter_mut().zip(&along).enumerate() { + // Sample `i` of the loop is sample `i - half` of `turned`, round the loop. + let Some(&q) = from_half.get((i + m - half) % m) else { continue }; + let theta = if s <= mid { s / mid } else { 1.0 + (s - mid) / (total - mid) } * std::f64::consts::PI; + let w = 0.5 * (1.0 + theta.cos()); + *p = p.lerp(q, w); + } + if let Some(&start) = out.first() + && let Some(end) = out.last_mut() + { + *end = start; + } + out +} + +/// How far [`smooth_points`] moved the samples `pts` to `smoothed` (the same number of points, in +/// order) across the path: the largest distance from a smoothed point to the lines between the +/// samples up to two on either side of the one it came from. +fn smoothing_moved(pts: &[Point], smoothed: &[Point]) -> f64 { + smoothed + .iter() + .enumerate() + .map(|(i, &q)| { + let own = pts.get(i).map_or(f64::INFINITY, |p| p.distance(q)); + let (lo, hi) = (i.saturating_sub(2), (i + 2).min(pts.len().saturating_sub(1))); + (lo..hi).filter_map(|j| Some(Line::new(*pts.get(j)?, *pts.get(j + 1)?).nearest(q, 1e-9).distance_sq.sqrt())).fold(own, f64::min) + }) + .fold(0.0, f64::max) +} + /// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups /// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one /// by one. @@ -678,6 +783,7 @@ pub fn smooth_options(path: &PathData, level: f64) -> SimplifyOptions { corner_angle_deg: SMOOTH_CORNER_DEG, straight_lines: false, smooth: size * 0.05 * level / 100.0, + smooth_moved_only: false, } } @@ -860,27 +966,10 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { return false; } if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 { - let (pi, ni) = ((index + n - 1) % n, (index + 1) % n); - if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) { - let (left, right) = (sp.segment(pi), sp.segment(index)); - const N: usize = 16; - let mut pts = Vec::with_capacity(2 * N + 1); - sample(&left, N, &mut pts, true); - sample(&right, N, &mut pts, false); - let (t0, t1) = (start_tangent(&left), end_tangent(&right)); - // The new curve passes the removed point at some parameter k: the left samples sit - // at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit. - let fit = |k: f64| { - let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect(); - fit_single_from(&pts, u, t0, t1) - }; - let (c, _, _) = golden_min(fit, 0.0, 1.0); - if c.p1.is_finite() && c.p2.is_finite() { - sp.anchors[pi].h_out = c.p1; - sp.anchors[ni].h_in = c.p2; - } - } + refit_across(&mut sp.anchors, (index + n - 1) % n, index, (index + 1) % n); } + #[cfg(test)] + tests::count_moves(n - index - 1); sp.anchors.remove(index); if !sp.closed { // A new end has nothing beyond it. @@ -897,6 +986,101 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { true } +/// Refit the facing handles of anchors `pi` and `ni` so one cubic follows the two segments that +/// meet at anchor `index` between them ([`remove_anchor`]). Two straight segments are left as +/// they are, and so are the handles when the fit is not finite. +fn refit_across(anchors: &mut [Anchor], pi: usize, index: usize, ni: usize) { + let (Some(&a), Some(&b), Some(&c)) = (anchors.get(pi), anchors.get(index), anchors.get(ni)) else { return }; + let line = |a: &Anchor, b: &Anchor| !a.has_out() && !b.has_in(); + if line(&a, &b) && line(&b, &c) { + return; + } + let (left, right) = (CubicBez::new(a.p, a.h_out, b.h_in, b.p), CubicBez::new(b.p, b.h_out, c.h_in, c.p)); + const N: usize = 16; + let mut pts = Vec::with_capacity(2 * N + 1); + sample(&left, N, &mut pts, true); + sample(&right, N, &mut pts, false); + let (t0, t1) = (start_tangent(&left), end_tangent(&right)); + // The new curve passes the removed point at some parameter k: the left samples sit at k·i/N, + // the right ones at k + (1 − k)·i/N. Search k for the closest fit. + let fit = |k: f64| { + let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect(); + fit_single_from(&pts, u, t0, t1) + }; + let (curve, _, _) = golden_min(fit, 0.0, 1.0); + if curve.p1.is_finite() && curve.p2.is_finite() { + anchors[pi].h_out = curve.p1; + anchors[ni].h_in = curve.p2; + } +} + +/// [`remove_anchor`] for each of `indexes`, the last first, with the same result, moving each +/// anchor that stays at most once: removing many anchors from a long subpath one at a time shifts +/// the rest of it each time. Indexes out of range and repeats are skipped. Returns how many +/// anchors were removed. +pub fn remove_anchors(sp: &mut SubPath, indexes: &[usize]) -> usize { + let total = sp.anchors.len(); + let mut targets: Vec = indexes.iter().copied().filter(|&i| i < total).collect(); + targets.sort_unstable_by(|a, b| b.cmp(a)); + targets.dedup(); + if targets.is_empty() { + return 0; + } + // Every anchor below the one being removed is still there, so it keeps its index, the one + // before it is `index - 1` and the first one left is anchor 0 until anchor 0 itself goes. + let mut gone = vec![false; total]; + let mut len = total; + let mut last = total - 1; + // The lowest anchor removed so far and the first one left after it. + let (mut lowest, mut after_lowest) = (total, None); + for &index in &targets { + let next = if index + 1 < lowest { Some(index + 1) } else { after_lowest }; + if (sp.closed || (index > 0 && next.is_some())) && len >= 3 { + let pi = if index > 0 { index - 1 } else { last }; + refit_across(&mut sp.anchors, pi, index, next.unwrap_or(0)); + } + gone[index] = true; + len -= 1; + (lowest, after_lowest) = (index, next); + if next.is_none() { + last = index.saturating_sub(1); + } + if len == 0 { + continue; + } + let first = if index > 0 { 0 } else { next.unwrap_or(0) }; + if !sp.closed { + // A new end has nothing beyond it. + if let Some(a) = sp.anchors.get_mut(first) { + a.h_in = a.p; + } + if let Some(a) = sp.anchors.get_mut(last) { + a.h_out = a.p; + } + } + if len < 3 { + let ends = [first, last].map(|i| sp.anchors.get(i).is_some_and(|a| a.has_in() || a.has_out())); + sp.closed = sp.closed && len == 2 && (ends[0] || ends[1]); + } + } + if len == 0 { + sp.closed = false; + } + let mut kept = 0; + for (i, &g) in gone.iter().enumerate() { + if !g { + if kept != i { + sp.anchors[kept] = sp.anchors[i]; + #[cfg(test)] + tests::count_moves(1); + } + kept += 1; + } + } + sp.anchors.truncate(kept); + targets.len() +} + /// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a /// golden-section search (a bounded number of steps). fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) { @@ -1107,6 +1291,129 @@ fn end_point(sp: &SubPath, last: bool) -> Option { mod tests { use super::*; + std::thread_local! { + /// Anchors moved within a subpath's anchor list by [`remove_anchor`] and [`remove_anchors`]. + static MOVES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + pub(super) fn count_moves(n: usize) { + MOVES.set(MOVES.get() + n); + } + + /// A subpath of `n` anchors from `seed`: corners, smooth points and points with one handle. + fn random_subpath(seed: &mut u64, n: usize, closed: bool) -> SubPath { + let mut rnd = || { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let anchors = (0..n) + .map(|i| { + let p = Point::new(i as f64 * 20.0 + (rnd() % 100) as f64 / 10.0, (rnd() % 400) as f64 / 4.0); + let off = Vec2::new((rnd() % 300) as f64 / 10.0 - 15.0, (rnd() % 300) as f64 / 10.0 - 15.0); + match rnd() % 4 { + 0 => Anchor::corner(p), + 1 => Anchor::smooth(p, p + off), + 2 => Anchor::with_handles(p, p, p + off), + _ => Anchor::with_handles(p, p - off, p), + } + }) + .collect(); + SubPath::new(anchors, closed) + } + + /// `sp` with [`remove_anchor`] run for each of `indexes`, the last first. + fn one_at_a_time(sp: &SubPath, indexes: &[usize]) -> SubPath { + let mut order = indexes.to_vec(); + order.sort_unstable_by(|a, b| b.cmp(a)); + order.dedup(); + let mut out = sp.clone(); + for i in order { + remove_anchor(&mut out, i); + } + out + } + + fn assert_same_bits(got: &SubPath, want: &SubPath, what: &str) { + let bits = |sp: &SubPath| -> Vec<[u64; 6]> { + sp.anchors.iter().map(|a| [a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits)).collect() + }; + assert_eq!(got.closed, want.closed, "closed: {what}"); + assert_eq!(bits(got), bits(want), "{what}"); + assert_eq!(got.anchors.iter().map(|a| a.kind).collect::>(), want.anchors.iter().map(|a| a.kind).collect::>(), "{what}"); + } + + #[test] + fn removing_anchors_together_gives_what_removing_them_one_at_a_time_gives() { + let mut seed = 0x9e37_79b9_7f4a_7c15_u64; + for case in 0..600 { + let n = case % 40 + 1; + let closed = case % 2 == 0; + let sp = random_subpath(&mut seed, n, closed); + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + // Up to n + 2 picks over 0..n + 2: runs of neighbors, both ends, everything, repeats + // and indexes past the end. + let picks = (seed % (n as u64 + 3)) as usize; + let mut indexes: Vec = (0..picks).map(|k| ((seed >> (k % 48)) as usize).wrapping_add(k * 7) % (n + 2)).collect(); + if case % 5 == 0 { + indexes = (0..n).collect(); + } + let mut got = sp.clone(); + let removed = remove_anchors(&mut got, &indexes); + let want = one_at_a_time(&sp, &indexes); + let what = format!("case {case}: {n} anchors, closed {closed}, removing {indexes:?}"); + assert_same_bits(&got, &want, &what); + assert_eq!(removed, sp.anchors.len() - want.anchors.len(), "{what}"); + } + } + + #[test] + fn removing_anchors_together_keeps_each_step_of_one_at_a_time() { + let curve = |p: Point, d: f64| Anchor::smooth(p, p + Vec2::new(d, 25.0)); + let pts = [Point::new(0.0, 0.0), Point::new(50.0, 40.0), Point::new(100.0, 0.0), Point::new(150.0, 40.0), Point::new(200.0, 0.0)]; + let cases: Vec<(SubPath, Vec)> = vec![ + // A refit at one removal makes the next segment curved, which the next removal refits. + ( + SubPath::new( + vec![Anchor::corner(pts[0]), Anchor::corner(pts[1]), curve(pts[2], 20.0), Anchor::corner(pts[3]), Anchor::corner(pts[4])], + false, + ), + vec![1, 2, 3], + ), + // The last anchor of an open path goes, then the one before it becomes the end. + (SubPath::new(pts.iter().map(|&p| curve(p, 15.0)).collect(), false), vec![2, 3, 4]), + // The first anchor of a closed path refits across the wrap. + (SubPath::new(pts.iter().map(|&p| curve(p, -10.0)).collect(), true), vec![0, 4]), + // A closed path down to two anchors stays closed with a handle, and opens without one. + (SubPath::new(pts.iter().map(|&p| curve(p, 10.0)).collect(), true), vec![1, 2, 3]), + (SubPath::polyline(&pts, true), vec![1, 2, 3]), + (SubPath::polyline(&pts, true), vec![0, 1, 2, 3]), + (SubPath::polyline(&pts[..1], false), vec![0]), + (SubPath::new(vec![], true), vec![0]), + ]; + for (sp, indexes) in cases { + let mut got = sp.clone(); + remove_anchors(&mut got, &indexes); + assert_same_bits(&got, &one_at_a_time(&sp, &indexes), &format!("{sp:?} removing {indexes:?}")); + } + } + + #[test] + fn removing_anchors_together_moves_each_anchor_left_at_most_once() { + let n = 2001; + let pts: Vec = (0..n).map(|i| Point::new(i as f64, (i % 2) as f64)).collect(); + let mut sp = SubPath::polyline(&pts, false); + let every_other: Vec = (1..n).step_by(2).collect(); + MOVES.set(0); + assert_eq!(remove_anchors(&mut sp, &every_other), n / 2); + assert_eq!(sp.anchors.len(), n - n / 2); + let moves = MOVES.get(); + assert!(moves < n, "{moves} anchors moved to remove {} of {n}", n / 2); + } + /// The samples Object › Path › Smooth at `level` smooths for `sp`, a subpath with no corner, /// and the spread it smooths them with. fn smooth_samples(sp: &SubPath, level: f64) -> (Vec, f64) { @@ -1182,6 +1489,31 @@ mod tests { assert!(work <= 400 * pts.len(), "{work} steps for {} samples", pts.len()); } + /// A spike on a closed loop whose samples are ten times denser on one half than on the other is + /// smoothed out wherever it is, including at the sample halfway through the list, which is only + /// about a quarter of the way round by length. + #[test] + fn loop_smoothing_smooths_a_spike_on_unevenly_sampled_loops() { + let c = Point::new(300.0, 300.0); + let on = |a: f64, r: f64| c + r * Vec2::from_angle(a); + let mut pts: Vec = (0..300).map(|i| on(f64::from(i) / 300.0 * std::f64::consts::PI, 100.0)).collect(); + pts.extend((0..30).map(|i| on(std::f64::consts::PI * (1.0 + f64::from(i) / 30.0), 100.0))); + let m = pts.len(); + pts.push(pts[0]); + for spike in [m / 2, 0, 100] { + let mut p = pts.clone(); + p[spike] = on(std::f64::consts::PI * spike as f64 / 300.0, 105.0); + if spike == 0 { + p[m] = p[0]; + } + let out = smooth_loop_points(&p, 10.0); + assert_eq!(out.len(), p.len()); + assert_eq!(out[0], out[m], "spike at {spike}: the loop stays closed"); + let left = out[spike].distance(c) - 100.0; + assert!(left < 1.0, "spike at {spike}: {left} pt of 5 pt left"); + } + } + #[test] fn the_samples_of_a_flat_loop_and_its_line_segments_stay_within_the_result_limit() { for (curved, kept) in [(1, 0), (32, 1), (40, 1_000_000), (1_000, MAX_RUN_RESULT - 1_000), (3, MAX_RUN_RESULT - 7)] { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 291c956786..49b7c051f1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -23,8 +23,8 @@ pub use boolean::{ }; pub use edit::{ 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, smooth_options, - split_into_grid, + remove_anchor, remove_anchors, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, + smooth_options, split_into_grid, }; pub use fit::{fit_cubics, fit_cubics_through}; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index f9579f1bf3..6ada08d3b8 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -183,10 +183,18 @@ fn offset_region( } else { BezPath::new() }; - let arr = Arrangement::new_budgeted( - vec![(fill, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)], - MAX_OFFSET_SEGMENT_PAIRS, - )?; + let parts = vec![fill, ring, open_bp]; + match crate::sweep_axis::turned_outlines(&parts) { + Some(turned) => offset_faces(turned, delta, |p, r| within(crate::sweep_axis::unturn_point(p), r)).map(crate::sweep_axis::unturn), + None => offset_faces(parts, delta, within), + } +} + +/// The faces of the offset by `delta` from `parts` (the closed fill, the ring stroked around it and +/// the stroke of the open subpaths, in that order), less the spurious ones that `within` finds. +fn offset_faces(parts: Vec, delta: f64, within: impl Fn(kurbo::Point, f64) -> bool) -> Result { + let d = delta.abs(); + let arr = Arrangement::new_budgeted(parts.into_iter().map(|p| (p, FillRule::NonZero)).collect(), MAX_OFFSET_SEGMENT_PAIRS)?; let c = if delta >= 0.0 { arr.contours_covering(|c| !c.is_empty()) } else { arr.contours_covering(|c| c.contains(&0) && !c.contains(&1)) }; // When `d` exceeds a curvature radius the stroker's inner offset inverts and its loops cancel // winding, leaving faces uncovered that are really within `d` of the path. Every face of the diff --git a/crates/pathops/src/sweep_axis.rs b/crates/pathops/src/sweep_axis.rs index 364325afcd..fb544ad163 100644 --- a/crates/pathops/src/sweep_axis.rs +++ b/crates/pathops/src/sweep_axis.rs @@ -149,6 +149,11 @@ pub(crate) fn unturn(mut path: PathData) -> PathData { path } +/// A point of turned input, turned back. +pub(crate) fn unturn_point(p: kurbo::Point) -> kurbo::Point { + UNTURN * p +} + #[cfg(test)] mod tests { use kurbo::{Join, Stroke, StrokeOpts}; @@ -251,6 +256,39 @@ mod tests { assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&short)))), 0); } + #[test] + fn offset_path_sweeps_a_long_low_path_turned_with_the_result_of_the_path_on_end() { + let mut rng = Rng(17); + // Grown past the zigzag's bends, where the stroker leaves loops whose faces are dropped by + // the distance test; grown a little; and inset. + for (closed, delta) in [(false, 1.5), (true, 0.5), (true, -0.3)] { + let what = format!("closed {closed}, offset {delta}"); + let low = zigzag(3, 300, 0.4, &mut rng, closed); + let tall = upright(&low); + let offset = |p: &PathData| crate::try_offset_path(p, delta, Join::Round, 4.0).unwrap(); + let mut turned = PathData::default(); + assert_eq!(turns_during(|| turned = offset(&low)), 1, "{what}: low"); + let mut on_end = PathData::default(); + assert_eq!(turns_during(|| on_end = offset(&tall)), 0, "{what}: tall"); + assert!(!turned.is_empty(), "{what}: empty"); + assert_same_region(&turned, &upright(&on_end), &what); + } + // A long plate with a row of round holes: growing it by 1 closes the narrow holes, which + // the distance test drops, and keeps the wide ones. + use kurbo::Shape; + let mut plate = kurbo::Rect::new(0.0, 0.0, 4000.0, 20.0).to_path(0.1); + for i in 0..200u32 { + let r = if i % 2 == 0 { 0.8 } else { 4.0 }; + plate.extend(kurbo::Circle::new((10.0 + 20.0 * f64::from(i), 10.0), r).to_path(0.01).reverse_subpaths()); + } + let plate = PathData::from_bezpath(&plate); + let grow = |p: &PathData| crate::try_offset_path(p, 1.0, Join::Round, 4.0).unwrap(); + let mut grown = PathData::default(); + assert_eq!(turns_during(|| grown = grow(&plate)), 1, "plate"); + assert_eq!(grown.subpaths.len(), 1 + 100, "plate"); + assert_same_region(&grown, &upright(&grow(&upright(&plate))), "plate"); + } + #[test] fn a_repeating_path_is_measured_at_every_position() { // Short segments in a row along the bottom, with a long one stacked above the row after diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 492aaf56f1..93f6d4c29c 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -417,6 +417,77 @@ fn simplify_keeps_square_corners() { assert!(!has_curves(&s)); } +/// The largest turn, in degrees, at an anchor of `sp` that is not an end of an open subpath: the +/// angle between the direction a segment arrives along and the one the next leaves along. +fn largest_join_turn(sp: &SubPath) -> f64 { + let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord); + let n = sp.segment_count(); + let first = usize::from(!sp.closed); + (first..n) + .map(|i| { + let (prev, next) = (sp.segment((i + n - 1) % n), sp.segment(i)); + let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0); + let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0); + arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees() + }) + .fold(0.0, f64::max) +} + +/// A refitted curve becomes a line only when the line keeps the smooth joins on both sides of it: +/// with no corners kept, the refit of a jagged line or of an arc running into a line turns by at most +/// 0.5° at every anchor. Points that all lie on one line still become one line. +#[test] +fn simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth() { + let mut seed = 7u64; + let mut noise = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0 + }; + let jagged: Vec = (0..=60).map(|i| Point::new(100.0 + f64::from(i) * 5.0, 200.0 + 6.0 * noise())).collect(); + let mut arc_into_line: Vec = + (0..=40).map(|i| Point::new(50.0 * (f64::from(i) / 80.0 * PI).cos(), 50.0 * (f64::from(i) / 80.0 * PI).sin())).collect(); + arc_into_line.extend((1..=20).map(|i| Point::new(-f64::from(i) * 2.0, 50.0))); + for pts in [&jagged, &arc_into_line] { + let path = PathData::single(SubPath::polyline(pts, false)); + for tolerance in [1.0, 2.5, 5.0] { + let s = simplify_with(&path, &SimplifyOptions { tolerance, corner_angle_deg: 179.0, ..Default::default() }); + let turn = largest_join_turn(&s.subpaths[0]); + assert!(turn <= 0.5 + 1e-9, "{tolerance} pt: an anchor turns {turn}°: {s:?}"); + assert!(max_deviation(&path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(&path, &s)); + } + } + let in_line: Vec = [0.0, 3.0, 10.0, 11.0, 40.0, 41.5, 90.0, 100.0].map(|x| Point::new(x, 0.5 * x)).to_vec(); + let s = simplify_with(&PathData::single(SubPath::polyline(&in_line, false)), &SimplifyOptions { tolerance: 1.0, ..Default::default() }); + assert_eq!(s.anchor_count(), 2, "{s:?}"); + assert!(!has_curves(&s), "{s:?}"); +} + +/// A smoothed loop with no corner is fitted across its start, and the join there also turns by at +/// most 0.5°. Each loop is a flat side that bends by 0.6° at the loop's start, closed by an arc of +/// `n` lines `height` tall, with `inner` anchors along each half of the side. +#[test] +fn simplify_keeps_the_join_at_the_start_of_a_smoothed_loop_smooth() { + for (half, height, n, inner, tolerance, smooth) in + [(40.0, 20.0, 8, 3, 2.5, 2.0), (80.0, 60.0, 8, 3, 5.0, 0.5), (160.0, 200.0, 24, 3, 10.0, 2.0), (160.0, 20.0, 24, 8, 10.0, 5.0)] + { + let rise = half * 0.3f64.to_radians().tan(); + let along = |i: i32| Point::new(half * f64::from(i) / f64::from(inner), -rise * f64::from(i.abs()) / f64::from(inner)); + let mut pts: Vec = (0..=inner).map(along).collect(); + pts.extend((1..n).map(|i| { + let a = f64::from(i) / f64::from(n) * PI; + Point::new(half * a.cos(), -rise - height * a.sin()) + })); + pts.extend((-inner..0).map(along)); + let path = PathData::single(SubPath::polyline(&pts, true)); + let s = simplify_with(&path, &SimplifyOptions { tolerance, corner_angle_deg: 179.0, smooth, ..Default::default() }); + let sp = &s.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < pts.len(), "{tolerance} pt: {s:?}"); + assert!(sp.segment_is_line(0), "{tolerance} pt: the first segment is a curve: {s:?}"); + let turn = largest_join_turn(sp); + assert!(turn <= 0.5 + 1e-9, "{tolerance} pt: an anchor turns {turn}°: {s:?}"); + } +} + #[test] fn simplify_straight_lines_option() { let c = dense_circle(50.0, 200); diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 710037a7c1..adaaa30c9c 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -217,7 +217,7 @@ choice! { } /// Save PDF settings (the dialog's sections; what a preset stores). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PdfSettings { pub standard: Standard, @@ -237,9 +237,9 @@ pub struct PdfSettings { /// `create_layers` is on). pub include_non_printing: bool, /// Write a page with a side longer than 14,400 pt (200 in) at its size in points, without a - /// `UserUnit`, at every PDF version, in files without a standard (off, and in PDF/A files: - /// PDF 1.6 and later files that aren't PDF/X files write it with a `UserUnit`, and PDF 1.5 - /// files are written as PDF 1.6 for it). + /// `UserUnit`, at every PDF version, in files without a standard (on by default; off, and in + /// PDF/A files: PDF 1.6 and later files that aren't PDF/X files write it with a `UserUnit`, + /// and PDF 1.5 files are written as PDF 1.6 for it). pub long_pages_in_points: bool, pub compression: CompressionSettings, pub marks: MarkSettings, @@ -253,6 +253,29 @@ pub struct PdfSettings { pub flattener_preset: String, } +impl Default for PdfSettings { + fn default() -> Self { + Self { + standard: Standard::default(), + compatibility: Compatibility::default(), + preserve_editing: false, + thumbnails: false, + fast_web_view: false, + view_after_saving: false, + create_layers: false, + include_non_printing: false, + long_pages_in_points: true, + compression: CompressionSettings::default(), + marks: MarkSettings::default(), + bleed: BleedSettings::default(), + output: OutputSettings::default(), + advanced: AdvancedSettings::default(), + security: SecuritySettings::default(), + flattener_preset: String::new(), + } + } +} + /// Resampling and compression of one kind of image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 655ed43ea4..4693b4c44b 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -105,8 +105,9 @@ fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> { } /// A page with a side longer than ISO 32000-1's 14,400 units is written in larger units (its -/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points. -/// Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep their sizes in points. +/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points, +/// with `longPagesInPoints` off. Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep +/// their sizes in points. #[test] fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() { let mut d = doc(); @@ -117,7 +118,7 @@ fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() small.rect = Rect::new(0.0, 12_000.0, 20_000.0, 12_001.0); d.artboards.push(small); let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.01); - let bytes = pdf(&d, json!({})); + let bytes = pdf(&d, json!({"longPagesInPoints": false})); let pages = units_and_media(&bytes); // 20,000 / 14,400 = 1.3889, rounded up to 1.389: the page is 14,398.85 × 7,199.42 units. assert_eq!(pages[0].0, 1.389, "{pages:?}"); @@ -155,6 +156,12 @@ fn report(d: &Document, set: PdfSettings) -> Result { export_with_report(d, &PdfOptions { settings: set, created: Some(1_791_200_000), ..PdfOptions::uncompressed() }) } +/// The default settings with `long_pages_in_points` off: long pages from PDF 1.6 on get a +/// `UserUnit`. +fn user_units() -> PdfSettings { + PdfSettings { long_pages_in_points: false, ..PdfSettings::default() } +} + /// The numbers that follow `key` in the file's bytes (`/MediaBox [0 0 w h]`, `/UserUnit u`), read /// as text, without the PDF reader: none when the file has no `key`. fn raw_numbers(bytes: &[u8], key: &str, count: usize) -> Vec { @@ -178,13 +185,13 @@ fn long_page_warnings(r: &ExportReport) -> Vec<&String> { r.warnings.iter().filter(|w| w.contains("page limit of 14,400 pt")).collect() } -/// A 20 m page at PDF 1.7 (the default) is written with a `UserUnit` of 3.938, reads back at 20 m -/// from the file's bytes, through the PDF reader and through the import, and the export says how -/// it is written. +/// A 20 m page at PDF 1.7 (the default) with `long_pages_in_points` off is written with a +/// `UserUnit` of 3.938, reads back at 20 m from the file's bytes, through the PDF reader and +/// through the import, and the export says how it is written. #[test] fn a_20_m_page_keeps_its_size_with_a_user_unit_and_the_export_says_so() { let d = sized(TWENTY_M, TWENTY_M / 20.0); - let r = report(&d, PdfSettings::default()).unwrap(); + let r = report(&d, user_units()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.7")); assert_eq!( long_page_warnings(&r), @@ -209,7 +216,7 @@ fn a_20_m_page_keeps_its_size_with_a_user_unit_and_the_export_says_so() { /// Every built-in preset and every PDF version keeps a 20 m page at its size, or refuses it with /// the reason: PDF 1.3 and 1.4 files (PDF/X-1a and PDF/X-3 among them) have no `UserUnit` and /// hold no real number past 32,767, PDF 1.5 is written as PDF 1.6 to have one, and PDF/X-4 pages -/// at PDF 1.5 or 1.6 stay in points with a warning. +/// at PDF 1.5 or 1.6 stay in points with a warning (with `long_pages_in_points` off). #[test] fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { let d = sized(TWENTY_M, TWENTY_M / 20.0); @@ -223,7 +230,7 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { }; for preset in crate::builtin_presets() { let name = preset.name.as_str(); - match report(&d, preset.settings.clone()) { + match report(&d, PdfSettings { long_pages_in_points: false, ..preset.settings.clone() }) { Err(e) if matches!(name, "PDF/X-1a:2001" | "PDF/X-3:2002") => refused(e, name), Err(e) => panic!("{name}: {e}"), Ok(r) if name == "PDF/X-4:2010" => { @@ -241,7 +248,7 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { } } for c in Compatibility::ALL.iter().copied() { - let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let set = PdfSettings { compatibility: c, ..user_units() }; match (c, report(&d, set)) { (Compatibility::Pdf13 | Compatibility::Pdf14, Err(e)) => refused(e, c.label()), (Compatibility::Pdf13 | Compatibility::Pdf14, Ok(_)) => panic!("{} is refused", c.label()), @@ -362,7 +369,7 @@ fn the_error_names_the_long_page_whose_art_needs_a_larger_real() { #[test] fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { const MM: f64 = 72.0 / 25.4; - for set in [PdfSettings::default(), PdfSettings { standard: Standard::PdfA2b, ..PdfSettings::default() }] { + for set in [user_units(), PdfSettings { standard: Standard::PdfA2b, ..PdfSettings::default() }] { let r = report(&sized(TWENTY_M * 5.0, 4.0 * MM), set).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.779]); let media = raw_numbers(&r.bytes, "/MediaBox", 4); @@ -377,11 +384,11 @@ fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { assert!(long_page_warnings(&r).is_empty(), "{:?}", r.warnings); } // A short side of 3 units or more at the long side's unit keeps that unit. - let r = report(&sized(TWENTY_M * 5.0, 60.0), PdfSettings::default()).unwrap(); + let r = report(&sized(TWENTY_M * 5.0, 60.0), user_units()).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); let media = raw_numbers(&r.bytes, "/MediaBox", 4); assert!(media[3] - media[1] >= 3.0, "{media:?}"); - let r = report(&sized(TWENTY_M * 5.0, 2.0), PdfSettings::default()).unwrap(); + let r = report(&sized(TWENTY_M * 5.0, 2.0), user_units()).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); } @@ -389,7 +396,7 @@ fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { /// the Save PDF dialog shows it. #[test] fn a_pdf_1_5_file_written_as_1_6_keeps_rc4_encryption() { - let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..PdfSettings::default() }; + let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..user_units() }; set.security.open_password = "secret".into(); let has = |bytes: &[u8], s: &[u8]| bytes.windows(s.len()).any(|w| w == s); let r = report(&sized(TWENTY_M, 300.0), set).unwrap(); @@ -408,7 +415,7 @@ fn the_long_page_warning_rounds_the_size_up() { /// point longer gets a `UserUnit` of 1.001 and the warning, and stays in points at PDF 1.4. #[test] fn a_page_of_14400_pt_stays_in_points_and_one_point_more_does_not() { - let at = |side: f64, c: Compatibility| report(&sized(side, 300.0), PdfSettings { compatibility: c, ..PdfSettings::default() }); + let at = |side: f64, c: Compatibility| report(&sized(side, 300.0), PdfSettings { compatibility: c, ..user_units() }); for c in [Compatibility::Pdf14, Compatibility::Pdf15, Compatibility::Pdf17] { let r = at(14_400.0, c).unwrap(); assert!(long_page_warnings(&r).is_empty() && !r.warnings.iter().any(|w| w.contains("UserUnit")), "{}: {:?}", c.label(), r.warnings); @@ -455,13 +462,14 @@ fn a_page_within_14400_pt_is_written_as_before_at_every_version() { /// 10 m in points. const TEN_M: f64 = 10.0 / 0.0254 * 72.0; -/// With `long_pages_in_points`, a 10 m page at PDF 1.7 is written at its size in points, with no -/// `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the page keeps -/// its `UserUnit`. +/// With `long_pages_in_points` (on by default), a 10 m page at PDF 1.7 is written at its size in +/// points, with no `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the +/// page keeps its `UserUnit`. #[test] fn long_pages_in_points_writes_a_10_m_page_in_points() { let d = sized(TEN_M, TEN_M / 10.0); - let r = report(&d, PdfSettings { long_pages_in_points: true, ..PdfSettings::default() }).unwrap(); + assert!(settings(json!({})).long_pages_in_points, "on by default"); + let r = report(&d, PdfSettings::default()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.7")); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new()); let media = raw_numbers(&r.bytes, "/MediaBox", 4); @@ -479,13 +487,12 @@ fn long_pages_in_points_writes_a_10_m_page_in_points() { let back = import(&r.bytes).unwrap(); let rect = back.artboards[0].rect; assert!((rect.width() - TEN_M).abs() < 0.01 && (rect.height() - TEN_M / 10.0).abs() < 0.01, "{rect:?}"); - // Off (the default), the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. - let off = report(&d, PdfSettings::default()).unwrap(); + // Off, the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. + let off = report(&d, user_units()).unwrap(); assert_eq!(raw_numbers(&off.bytes, "/UserUnit", 1), [1.969]); assert!( long_page_warnings(&off)[0].ends_with("it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller") ); - assert!(!settings(json!({})).long_pages_in_points, "off by default"); } /// With `long_pages_in_points`, every PDF version writes a 10 m page in points: PDF 1.5 stays @@ -496,7 +503,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ let d = sized(TEN_M, TEN_M / 10.0); let with = |set: &PdfSettings| PdfSettings { long_pages_in_points: true, ..set.clone() }; for c in Compatibility::ALL.iter().copied() { - let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let set = PdfSettings { compatibility: c, ..user_units() }; let r = report(&d, with(&set)).unwrap(); assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); @@ -520,7 +527,7 @@ fn pdfa2b_long_page_stays_within_14400_units() { let d = sized(TEN_M, TEN_M / 10.0); for s in Standard::ALL.iter().copied().filter(|s| *s != Standard::None && !s.is_pdfx()) { for c in Compatibility::ALL.iter().copied().filter(|c| s.allows(*c)) { - let off = PdfSettings { standard: s, compatibility: c, ..PdfSettings::default() }; + let off = PdfSettings { standard: s, compatibility: c, ..user_units() }; let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; let (on, off) = (report(&d, on).unwrap(), report(&d, off).unwrap()); assert_eq!(on.bytes, off.bytes, "{} {}", s.label(), c.label()); @@ -540,7 +547,7 @@ fn pdfa2b_long_page_stays_within_14400_units() { fn long_pages_in_points_leaves_a_page_within_14400_pt_as_it_is() { let d = sized(14_400.0, 300.0); for c in Compatibility::ALL.iter().copied() { - let off = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let off = PdfSettings { compatibility: c, ..user_units() }; let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; let (off, on) = (report(&d, off).unwrap(), report(&d, on).unwrap()); assert_eq!(on.bytes, off.bytes, "{}", c.label()); diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index ee3732908c..1178f7c9ac 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -82,6 +82,57 @@ pub fn install(mut plugin: Plugin) -> Result> { Ok(p) } +/// What [`install_checked`] does to the grant recorded for the plug-in's id. +pub enum GrantUpdate { + /// Records this grant, replacing an earlier one for the id ([`grant`]). + Record(GrantToken), + /// Forgets the grant for the id ([`revoke`]). + Revoke, +} + +/// Installs `plugin` and updates the grant for its id as one step. While it holds the plug-ins and +/// then the grants, `check` is given the plug-in installed under the id and the grant recorded for +/// it. When `check` returns an error, nothing changes and that error is returned. A plug-in that +/// lists permissions the grant `update` records doesn't give this very module is refused the same +/// way, with [`Error::NeedsApproval`] passed through `refused`. Otherwise `attach` runs, the grant +/// is updated and the plug-in is installed, replacing one with the same id. `check` and `attach` +/// must not call the other functions of this module, which wait for the locks held here. +pub fn install_checked( + mut plugin: Plugin, + update: GrantUpdate, + check: impl FnOnce(Option<&Plugin>, Option<&Grant>) -> std::result::Result<(), E>, + attach: impl FnOnce(), + refused: impl FnOnce(Error) -> E, +) -> std::result::Result, E> { + // The same order as `install`, which reads the grants while it holds the plug-ins. + let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); + let mut grants = GRANTS.write().unwrap_or_else(|e| e.into_inner()); + let id = plugin.id().to_string(); + check(plugins.get(&id).map(|p| &**p), grants.get(&id))?; + let m = plugin.manifest(); + let granted = match &update { + GrantUpdate::Record(token) => Some(token.grant()), + GrantUpdate::Revoke => None, + }; + if m.asks_permissions() && !granted.is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) { + return Err(refused(Error::NeedsApproval(crate::permissions::names(&m.permissions()).join(", ")))); + } + attach(); + match update { + GrantUpdate::Record(token) => { + let g = token.grant().clone(); + grants.insert(g.id.clone(), g); + } + GrantUpdate::Revoke => { + grants.remove(&id); + } + } + plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); + let p = Arc::new(plugin); + plugins.insert(id, p.clone()); + Ok(p) +} + /// Loads `bytes` with the default limits and installs the plug-in ([`install`]: one that lists /// permissions installs only when they were granted to this very module). pub fn install_bytes(bytes: &[u8]) -> Result> { @@ -345,4 +396,61 @@ mod tests { assert!(granted(id).is_none() && remove(id), "{listed:?}"); } } + + /// `install_checked` gives `check` the plug-in and grant held for the id. When `check` refuses, + /// or the grant it would record doesn't approve the module, the plug-in, the grant and the + /// revision stay as they were and `attach` doesn't run; otherwise it records the grant (or + /// forgets it) and installs the module. + #[test] + fn install_checked_changes_nothing_when_refused() { + use crate::permissions::Permission::DocRead; + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let id = "test.registry.checked"; + let old = module(id, Some(r#"["doc.read"]"#), 8); + let new = module(id, Some(r#"["doc.read"]"#), 9); + grant(GrantToken::after_engine_approval(grant_for(id, &old, vec![DocRead]))); + install_bytes(&old).unwrap(); + let load = |bytes: &[u8]| Plugin::load(bytes, Limits::default()).unwrap(); + let record = |bytes: &[u8]| GrantUpdate::Record(GrantToken::after_engine_approval(grant_for(id, bytes, vec![DocRead]))); + let unchanged = |why: &str| { + assert_eq!(get(id).map(|p| p.hash().to_string()), Some(crate::permissions::sha256_hex(&old)), "{why}"); + assert_eq!(granted(id), Some(grant_for(id, &old, vec![DocRead])), "{why}"); + }; + let attached = std::cell::Cell::new(false); + let before = revision(); + + let seen = std::cell::RefCell::new(None); + let e = install_checked( + load(&new), + record(&new), + |p, g| { + *seen.borrow_mut() = Some((p.map(|p| p.hash().to_string()), g.cloned())); + Err("refused") + }, + || attached.set(true), + |_| "not approved", + ) + .unwrap_err(); + assert_eq!(e, "refused"); + assert_eq!(seen.take(), Some((Some(crate::permissions::sha256_hex(&old)), Some(grant_for(id, &old, vec![DocRead]))))); + unchanged("check refused"); + + let same = std::convert::identity::; + let e = install_checked(load(&new), record(&old), |_, _| Ok(()), || attached.set(true), same).unwrap_err(); + assert_eq!(e, Error::NeedsApproval("doc.read".into())); + unchanged("a grant for other bytes"); + let e = install_checked(load(&new), GrantUpdate::Revoke, |_, _| Ok(()), || attached.set(true), same).unwrap_err(); + assert_eq!(e, Error::NeedsApproval("doc.read".into())); + unchanged("no grant"); + assert!(!attached.get() && revision() == before, "nothing ran or moved"); + + let p = install_checked(load(&new), record(&new), |_, _| Ok(()), || attached.set(true), same).unwrap(); + assert!(attached.get() && revision() > before); + assert_eq!(get(id).map(|p| p.hash().to_string()), Some(p.hash().to_string())); + assert_eq!(granted(id), Some(grant_for(id, &new, vec![DocRead]))); + let plain = module(id, Some("[]"), 10); + install_checked(load(&plain), GrantUpdate::Revoke, |_, _| Ok(()), || {}, same).unwrap(); + assert!(granted(id).is_none(), "a module without permissions forgets the grant"); + assert!(remove(id)); + } } diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0beabd34cb..936d5d9e53 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -127,11 +127,31 @@ const NEEDLE_AT: usize = 0x8000; /// its input unchanged otherwise. A live effect made from it draws one shape or the other, so a /// test sees from the geometry whether the host handed the plug-in something. pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> String { + given_or(manifest, given, needle, output, None) +} + +/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and +/// `otherwise` when it does not. +pub fn when_given_else(manifest: &str, given: Given, needle: &str, output: &str, otherwise: &str) -> String { + given_or(manifest, given, needle, output, Some(otherwise)) +} + +/// [`when_given`], returning `otherwise` in place of the input when it is given. +fn given_or(manifest: &str, given: Given, needle: &str, output: &str, otherwise: Option<&str>) -> String { let (src, len) = match given { Given::Input => ("$in", "$il"), Given::Params => ("$pp", "$pl"), }; let n = needle.len(); + // `otherwise` sits right after `output`. + let other_at = OUTPUT_AT + output.len(); + let (none, other) = match otherwise { + Some(o) => ( + format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {other_at}))", o.len()), + format!(r#" (data (i32.const {other_at}) "{}")"#, string(o)), + ), + None => (ECHO.to_string(), String::new()), + }; // For each place `$i` the needle fits, compare it byte by byte (`$j`). let run = format!( r#"(local $i i32) (local $j i32) @@ -149,12 +169,13 @@ pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> S (br $cmp))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $at))) - (return {ECHO})) + (return {none})) (i64.or (i64.shl (i64.const {out}) (i64.const 32)) (i64.const {OUTPUT_AT}))"#, out = output.len(), ); - let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}")"#, string(needle), string(output)); - module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) + let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}"){other}"#, string(needle), string(output)); + let end = other_at + otherwise.map_or(0, str::len); + module(manifest, &run, &extra, end.div_ceil(0x1_0000) as u32 + 1) } /// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs). diff --git a/crates/plugins/tests/grant_sites.rs b/crates/plugins/tests/grant_sites.rs index d0c0479337..a4bdb4b12b 100644 --- a/crates/plugins/tests/grant_sites.rs +++ b/crates/plugins/tests/grant_sites.rs @@ -1,7 +1,7 @@ //! The shipped code that records a plug-in grant: only the engine's install approval and its load -//! of the plug-in folder's grants file make a `GrantToken` and call `registry::grant`. A new call -//! site fails this test until it is added to the list. It checks the source, not what native code -//! can do at run time. +//! of the plug-in folder's grants file make a `GrantToken` and call `registry::grant` or +//! `registry::install_checked`. A new call site fails this test until it is added to the list. It +//! checks the source, not what native code can do at run time. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::collections::BTreeMap; @@ -11,10 +11,13 @@ use std::path::{Path, PathBuf}; const EXPECTED: &[(&str, &str, usize)] = &[ ("GrantToken::after_engine_approval", "crates/engine/src/cmd/plugin.rs", 1), ("GrantToken::from_engine_grants_file", "crates/engine/src/store.rs", 2), - ("registry::grant", "crates/engine/src/cmd/plugin.rs", 1), ("registry::grant", "crates/engine/src/store.rs", 2), + ("registry::install_checked", "crates/engine/src/cmd/plugin.rs", 1), ]; +/// The names the audit counts. +const NAMES: [&str; 4] = ["GrantToken::after_engine_approval", "GrantToken::from_engine_grants_file", "registry::grant", "registry::install_checked"]; + /// Every `.rs` file under `dir`, without test files and folders. fn sources(dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return }; @@ -61,7 +64,7 @@ fn grant_token_call_sites_are_limited() { let text = std::fs::read_to_string(file).unwrap(); let rel = file.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"); for line in shipped(&text) { - for name in ["GrantToken::after_engine_approval", "GrantToken::from_engine_grants_file", "registry::grant"] { + for name in NAMES { let n = count(line, name); if n > 0 { *found.entry((name, rel.clone())).or_default() += n; diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 2b724f2805..b1b46b3327 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -125,9 +125,15 @@ impl BrushCache { } /// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs), symbol -/// instances measured by their art from `symbols`, with the brushed strokes in that art. +/// instances measured by their art from `symbols`, with the brushed strokes in that art. A path +/// whose live-effect plug-ins paint their pieces leaves that room past each piece +/// ([`crate::fx::painted_cull_bounds`]). A group or layer leaves that room past each of its +/// members, as the renderer measures a group by its members (`Renderer::bounds_of`). pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - let b = crate::fx::cull_bounds(symbols, n)?; + if let Some(b) = crate::fx::painted_cull_bounds(symbols, n) { + return b; + } + let b = crate::fx::brushed_cull_bounds(symbols, n)?; let pad = pad(symbols, n); Some(b.inflate(pad, pad)) } @@ -145,12 +151,15 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO /// How far brush art can paint past `n`'s brushed strokes, or past the brushed strokes in the art /// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it /// has a visible brushed stroke and of the objects in that art that have one -/// ([`SymbolBoxes::brush_width`]), and 0 when neither has one. +/// ([`SymbolBoxes::brush_width`]) or the pieces live-effect plug-ins paint for them +/// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. pub(crate) fn pad(symbols: &SymbolBoxes, n: &Node) -> f64 { let own = vectorcraft_brush::has_brush(n) .then(|| n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max)); let art = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => symbols.brush_width(symbol).map(|w| w.max(1.0)), + NodeKind::SymbolInstance { symbol, .. } => { + symbols.brush_width(symbol).into_iter().chain(crate::fx::painted_brush_width(symbols, symbol)).reduce(f64::max).map(|w| w.max(1.0)) + } _ => None, }; own.into_iter().chain(art).reduce(f64::max).map_or(0.0, |w| 16.0 * w + 8.0) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index a337616329..0dd0beb0eb 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -70,7 +70,10 @@ mod renderer; #[cfg(test)] pub(crate) use bounds::visual_bounds; -pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds, three_d_art_bounds, with_own_raster_reach}; +pub(crate) use bounds::{ + brushed_cull_bounds, cull_bounds, drawn_outline, drawn_outlines, painted_brush_width, painted_cull_bounds, painted_reach, path_cull_bounds, + three_d_art_bounds, with_own_raster_reach, +}; #[cfg(test)] pub(crate) use entries::Drawn; pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry}; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 6fbd9f37ac..2887180a42 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -99,6 +99,72 @@ fn painted_art(symbols: &SymbolBoxes, n: &Node) -> Option { effects::has_painted_effects(n).then(|| effects::reshape(Some(symbols.document()), n, None)).flatten() } +/// Calls `f` on each object in `art`, the art [`painted_art`] makes, that has a visible brushed +/// stroke, looking into the visible members of its groups. Hidden objects are left out. +fn each_brushed_piece(art: &Node, f: &mut impl FnMut(&Node)) { + if !art.visible { + return; + } + match &art.kind { + NodeKind::Group { children, .. } => children.iter().for_each(|c| each_brushed_piece(c, f)), + _ if vectorcraft_brush::has_brush(art) => f(art), + _ => {} + } +} + +/// [`cull_bounds`] of path or compound path `n` whose live-effect plug-ins paint their pieces +/// ([`painted_art`]), with room for brush art past each piece's brushed strokes +/// ([`crate::brush_fx::pad`] of that piece, from the piece's own stroke widths) and the reach of +/// `n`'s raster effects past them. `None` when the renderer measures `n` otherwise: it has no such +/// pieces, or has Crop Marks, Vector Halftone or a 3D effect. +pub(crate) fn painted_cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option> { + if !effects::has_painted_effects(n) + || effects::has_crop_marks(n) + || effects::addons::halftone::has_halftone(n) + || effects::has_revolve(n) + || effects::has_solid(n) + { + return None; + } + let art = painted_art(symbols, n)?; + let mut b = art.visual_bounds_with(symbols); + each_brushed_piece(&art, &mut |p| { + let pad = crate::brush_fx::pad(symbols, p); + b = vectorcraft_geom::union_opt(b, p.visual_bounds_with(symbols).map(|r| r.inflate(pad, pad))); + }); + Some(b.map(|b| with_raster_reach(symbols, n, b))) +} + +/// The widest stroke of the pieces with a visible brushed stroke that live-effect plug-ins paint +/// ([`painted_art`]) for the paths in the art of the symbol named `symbol`, and in the art of the +/// symbols the instances in it show, each symbol measured once for `symbols` +/// ([`SymbolBoxes::measured_width`]). `None` when no such piece has one; symbols whose art has no +/// visible effect ([`SymbolBoxes::has_effects`]) are not looked at. +pub(crate) fn painted_brush_width(symbols: &SymbolBoxes, symbol: &str) -> Option { + symbols.measured_width("painted brush", symbol, || { + if !symbols.has_effects(symbol) { + return None; + } + let mut width: Option = None; + symbols.measure_art(symbol, |art| { + art.walk(&mut |n| { + let nested = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => painted_brush_width(symbols, symbol), + _ => None, + }; + width = width.into_iter().chain(nested).reduce(f64::max); + if let Some(pieces) = painted_art(symbols, n) { + each_brushed_piece(&pieces, &mut |p| { + let widths = p.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }); + width = Some(widths.fold(width.unwrap_or(0.0), f64::max)); + }); + } + }) + }); + width + }) +} + /// Does `item` paint (as [`Renderer::paint_items`] draws it)? fn paints(item: &AppearanceItem) -> bool { item.visible() && !item.paint().is_none() @@ -152,8 +218,18 @@ fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect { } /// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of -/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches). +/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches). A path +/// whose live-effect plug-ins paint their pieces is measured by those pieces, each with the art +/// its brushed strokes draw along it. fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + if let Some(art) = painted_art(symbols, n) { + let mut b = art.visual_bounds_with(symbols); + each_brushed_piece(&art, &mut |p| { + let brushed = vectorcraft_brush::expand(symbols.document(), p).and_then(|a| a.visual_bounds_with(symbols)); + b = vectorcraft_geom::union_opt(b, brushed); + }); + return b; + } let b = shape_bounds(symbols, n); let brushed = vectorcraft_brush::has_brush(n) .then(|| vectorcraft_brush::expand(symbols.document(), n)) @@ -180,9 +256,6 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { } NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n), _ if has_fx(n) => { - if let Some(art) = painted_art(symbols, n) { - return art.visual_bounds_with(symbols); - } let g = effected_path(symbols.document(), n, &node_bezpath(n)?); // The path as the fills and strokes without geometry effects of their own paint it // (as every fill and stroke would when none paints). @@ -396,7 +469,28 @@ fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 { /// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are /// measured by their art from `symbols`. pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - cull_bounds_in(symbols, n, true) + cull_bounds_in(symbols, n, true, false) +} + +/// [`cull_bounds`] with room for the brush art of the members of the groups and layers in `n`, each +/// member measured as [`crate::brush_fx::cull_bounds`] measures it. The room for `n`'s own brushed +/// strokes is left to the caller. +pub(crate) fn brushed_cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + cull_bounds_in(symbols, n, true, true) +} + +/// [`cull_bounds_in`] of `c`, a member of a group or layer; with `brush`, grown by the room its +/// brushed strokes leave for brush art, as [`crate::brush_fx::cull_bounds`] measures it. +fn member_cull_bounds(symbols: &SymbolBoxes, c: &Node, after_reshape: bool, brush: bool) -> Option { + if !brush { + return cull_bounds_in(symbols, c, after_reshape, false); + } + if let Some(b) = painted_cull_bounds(symbols, c) { + return b; + } + let b = cull_bounds_in(symbols, c, after_reshape, true)?; + let pad = crate::brush_fx::pad(symbols, c); + Some(b.inflate(pad, pad)) } /// [`cull_bounds`] of path or compound path `n` made from `visual`, its visual bounds as @@ -420,16 +514,21 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO /// with geometry effects of its own are measured on the art as those effects reshape it /// ([`reshaped_art_effects_reach`]). With `after_reshape` false, they are measured on the art /// before those effects run ([`art_effects_reach`]), which `symbols` keeps for each symbol and -/// linear part. -fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Option { - let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape); +/// linear part. With `brush` true, the members of a group or layer are measured with the room +/// their brushed strokes leave for brush art ([`member_cull_bounds`]). +fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: bool) -> Option { + let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape, false); + let member = |c: &Node| member_cull_bounds(symbols, c, after_reshape, brush); + // The art that a group's or layer's crop marks or Vector Halftone draw holds its members. The + // caller leaves the room for an object's own brushed strokes. + let held = |art: &Node| cull_bounds_in(symbols, art, after_reshape, brush && is_container(n)); match &n.kind { // The object and its crop marks. - _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)), + _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| held(&art)), // The object Vector Halftone samples, and as far as its dots reach past it. _ if effects::addons::halftone::has_halftone(n) => { let reach = effects::addons::halftone::reach(n); - inner(&effects::addons::halftone::split(n).0).map(|b| b.inflate(reach, reach)) + held(&effects::addons::halftone::split(n).0).map(|b| b.inflate(reach, reach)) } // A 3D effect's art, as the renderer draws it, and the raster effects' reach: the solid of // type or of a group's members reaches past the letters or members the arms below measure. @@ -445,12 +544,12 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio let b = if art.clips() { art.visual_bounds_with(symbols) } else { - art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) + art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c))) }?; Some(with_own_raster_reach(symbols, n, b)) } NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) + children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c))) } _ if has_fx(n) => visual_bounds(symbols, n), // The reshaped art (a symbol instance's reshaped art is made from its symbol's art; type's @@ -568,14 +667,24 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that /// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far -/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any -/// other kind of object. +/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side; for a path +/// whose live-effect plug-ins paint their pieces, how far its pieces and their brush art reach past +/// it ([`painted_cull_bounds`]). 0 for any other kind of object. fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { if !vectorcraft_doc::live::is_live(n) { return 0.0; } let mut reach = 0.0f64; - n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c))); + n.walk(&mut |c| { + let own = match painted_cull_bounds(symbols, c) { + Some(b) => match (b, c.visual_bounds_with(symbols)) { + (Some(b), Some(v)) => [v.x0 - b.x0, v.y0 - b.y0, b.x1 - v.x1, b.y1 - v.y1].into_iter().fold(0.0, f64::max), + _ => 0.0, + }, + None => art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c), + }; + reach = reach.max(own); + }); reach } @@ -628,7 +737,7 @@ fn reshaped_art_effects_reach(symbols: &SymbolBoxes, n: &Node, r: &Node) -> f64 if !symbols.has_effects(symbol) { return 0.0; } - let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false))); + let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false, false))); match (art, r.visual_bounds_with(symbols)) { (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), _ => 0.0, diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 47f2dec770..f649453663 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -123,7 +123,7 @@ impl Renderer { /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, /// of symbol instances and of the objects that hold them, and the art drawn for the effects of - /// objects with a plug-in effect. Rasters of raster effects and evaluations of live objects hold + /// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, /// `live::resources_key`). /// @@ -146,7 +146,8 @@ impl Renderer { if stale { self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node)); self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); - self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node)); + // The art of a group holds what its members' plug-in effects draw. + self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9cf4ac9ca0..bde3278bfc 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -27,6 +27,7 @@ mod longstroke; mod memory; mod movedstroke; mod objectfx; +mod paintedbrush; mod placed_document; mod rasterize; mod setup; diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index f95735baec..011f9fd7fe 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1716,7 +1716,8 @@ fn installing_a_plugin_keeps_what_is_kept_for_other_objects() { } /// The art drawn for an object's effects follows the plug-ins installed: in the next frame, a group -/// with a plug-in effect, the same group feathered (Feather clips to the art's outline), an +/// with a plug-in effect, the same group feathered (Feather clips to the art's outline), a group +/// with a Transform effect holding a square with a plug-in effect, an /// instance of symbol `S` ([`symbol_art`]) and type, each with a plug-in effect, are drawn as the /// plug-in installed again with another shape draws them. #[test] @@ -1738,6 +1739,13 @@ fn effect_art_follows_the_plugins_installed() { plugin_change_draws_as_new(group(vec![]), ID, small.clone(), big.clone()); let feather = effect("stylize.feather", serde_json::json!({"radius": 2.0})); plugin_change_draws_as_new(group(vec![feather]), ID, small.clone(), big.clone()); + // A group with a Transform effect holding a square with the plug-in effect: the group's art + // holds what the plug-in draws for the square. + let mut d = Document::new(200.0, 100.0); + let square = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 60.0, 70.0)), Paint::solid(Color::BLACK), vec![plugin()]); + let mut moved = Node::group(d.alloc_id(), vec![Arc::new(square)]); + moved.appearance.effects.push(effect("distort.transform", serde_json::json!({"moveH": 10.0}))); + plugin_change_draws_as_new(with_art(d, vec![moved]), ID, small.clone(), big.clone()); let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); diff --git a/crates/render/src/tests/paintedbrush.rs b/crates/render/src/tests/paintedbrush.rs new file mode 100644 index 0000000000..ad31da5b3b --- /dev/null +++ b/crates/render/src/tests/paintedbrush.rs @@ -0,0 +1,238 @@ +//! A path whose live-effect plug-in paints its pieces draws each piece's brushed stroke along that +//! piece, at the piece's stroke width. Culling leaves room for that brush art past each piece, in a +//! symbol instance and a live object too, and Object › Rasterize measures it. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeKind, Symbol}; +use vectorcraft_geom::{Affine, PathData, Point, SubPath}; + +use crate::{RenderOptions, Renderer}; + +const BRUSH: &str = "3 pt. Round"; + +/// A 20 × 20 square at `x`, 40. +fn square_at(x: f64) -> PathData { + PathData::single(SubPath::polyline(&[Point::new(x, 40.0), Point::new(x + 20.0, 40.0), Point::new(x + 20.0, 60.0), Point::new(x, 60.0)], true)) +} + +/// Installs live-effect plug-in `id`, which declares `paintOutput` and returns one piece: the +/// square at 120, 40 with a black stroke 10 pt wide. +fn install(id: &str) { + use vectorcraft_plugins::wat as tpl; + let paint = serde_json::to_value(Paint::solid(Color::BLACK)).unwrap(); + let output = json!({"objects": [{"path": square_at(120.0), "strokes": [{"paint": paint, "width": 10.0}]}]}).to_string(); + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + let module = wat::parse_str(tpl::returning(&manifest, &output)).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); +} + +/// A document whose brush `3 pt. Round` paints a round nib 10 pt across for each point of stroke +/// weight: 100 pt across on a piece's 10 pt stroke, reaching from 70 to 190 across the piece. +fn document() -> Document { + let mut d = Document::new(300.0, 100.0); + let mut lib = vectorcraft_brush::library(&d); + let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == BRUSH).map(|b| &mut b.kind) else { + panic!("the {BRUSH} brush") + }; + c.size = 10.0; + vectorcraft_brush::store(&mut d, &lib); + d +} + +/// The square at 0, 40 with a black 1 pt stroke, painted by the brush when `brushed`, with +/// live-effect plug-in `plugin` as its own effect, or as its stroke's when `on_stroke`. +fn source(d: &mut Document, plugin: &str, on_stroke: bool, brushed: bool) -> Node { + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let effect = Effect { id: format!("plugin.{plugin}"), params: json!({}), visible: true }; + let stroke = ap.stroke_mut().unwrap(); + stroke.brush = brushed.then(|| BRUSH.to_string()); + if on_stroke { + stroke.effects = vec![effect]; + } else { + ap.effects = vec![effect]; + } + Node::path(d.alloc_id(), square_at(0.0), ap) +} + +/// `d` with `n` added to its first layer. +fn with(mut d: Document, n: Node) -> Document { + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// Does a new renderer draw `d` dark at (`x` + 5, 50), in a view of 10 × 20 pt from (`x`, 40)? +fn dark_at(d: &Document, x: f64) -> bool { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = Renderer::new().render(d, 10, 20, Affine::translate((-x, -40.0)), &opts); + let p = img.pixel(5, 10); + (p[0] as u32 + p[1] as u32 + p[2] as u32) < 200 +} + +/// The brush art of the piece reaches 50 pt past it, to 190; the room left for the source's 1 pt +/// brushed stroke ends at 169. A view from 175 shows that art whether the plug-in is the path's own +/// effect or its stroke's, and culling leaves `16 × 10 + 8` pt past the piece's 10 pt stroke. A +/// renderer that draws the whole document draws it there too. +#[test] +fn a_painted_piece_is_culled_by_its_own_brush_art() { + for (id, on_stroke) in [("test.render.paintedbrush.object", false), ("test.render.paintedbrush.stroke", true)] { + install(id); + let mut d = document(); + let n = source(&mut d, id, on_stroke, true); + let d = with(d, n.clone()); + assert!(dark_at(&d, 175.0), "on the stroke: {on_stroke}; the brush art past the source's room is drawn"); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let whole = Renderer::new().render(&d, 300, 100, Affine::IDENTITY, &opts).pixel(180, 50); + assert!((whole[0] as u32 + whole[1] as u32 + whole[2] as u32) < 200, "on the stroke: {on_stroke}; {whole:?}"); + let b = crate::brush_fx::cull_bounds(&d.symbol_boxes(), &n).unwrap(); + assert!(b.x0 <= -52.9 && b.x1 >= 312.9 && b.y0 <= -132.9 && b.y1 >= 232.9, "on the stroke: {on_stroke}; {b:?}"); + vectorcraft_plugins::registry::remove(id); + } +} + +/// Object › Rasterize measures the brush art drawn along the piece, from 70 to 190, and not the +/// brush art of the source, which is not drawn. +#[test] +fn a_painted_piece_s_brush_art_is_in_its_drawn_bounds() { + const ID: &str = "test.render.paintedbrush.crop"; + install(ID); + let mut d = document(); + let n = source(&mut d, ID, false, true); + let d = with(d, n.clone()); + let b = crate::drawn_bounds(&d, &n).unwrap(); + assert!(b.x0 > 65.0 && b.x0 < 75.0 && b.x1 > 185.0 && b.x1 < 195.0, "{b:?}"); + vectorcraft_plugins::registry::remove(ID); +} + +/// An instance of a symbol whose art is the path, and a one-by-one grid repeat of it, are drawn +/// where the piece's brush art reaches. +#[test] +fn a_symbol_and_a_repeat_holding_a_painted_path_are_culled_by_its_pieces_brush_art() { + const ID: &str = "test.render.paintedbrush.held"; + install(ID); + let mut d = document(); + let art = source(&mut d, ID, false, true); + d.symbols.push(Symbol { dynamic: false, name: "Painted".into(), art: Arc::new(art) }); + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Painted".into(), xf: Affine::IDENTITY }); + let d = with(d, instance); + assert!(dark_at(&d, 175.0), "an instance"); + let mut d = document(); + let held = source(&mut d, ID, false, true); + let kind = vectorcraft_doc::RepeatKind::Grid { + h_spacing: 0.0, + v_spacing: 0.0, + rows: 1, + cols: 1, + grid_type: vectorcraft_doc::TileType::Grid, + flip_rows: false, + flip_cols: false, + }; + let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec { kind, source: vec![Arc::new(held)] })); + let d = with(d, repeat); + assert!(dark_at(&d, 175.0), "a repeat"); + vectorcraft_plugins::registry::remove(ID); +} + +/// The room an instance leaves for the pieces' brush art is measured once for a symbol's art: the +/// second instance measured with the same boxes measures no art. +#[test] +fn the_pieces_brush_width_is_measured_once_for_a_symbol() { + const ID: &str = "test.render.paintedbrush.once"; + install(ID); + let mut d = document(); + let art = source(&mut d, ID, false, true); + d.symbols.push(Symbol { dynamic: false, name: "Painted".into(), art: Arc::new(art) }); + let instance = + |d: &mut Document, x: f64| Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Painted".into(), xf: Affine::translate((x, 0.0)) }); + let (a, b) = (instance(&mut d, 0.0), instance(&mut d, 30.0)); + let symbols = d.symbol_boxes(); + assert_eq!(crate::brush_fx::pad(&symbols, &a), 168.0, "16 × the piece's 10 pt + 8"); + let measured = symbols.art_measured(); + assert_eq!(crate::brush_fx::pad(&symbols, &b), 168.0); + assert_eq!(symbols.art_measured(), measured); + vectorcraft_plugins::registry::remove(ID); +} + +/// The square at 0, 40 with a black 1 pt stroke painted by the brush, without effects. +fn brushed_path(d: &mut Document) -> Node { + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut().unwrap().brush = Some(BRUSH.to_string()); + Node::path(d.alloc_id(), square_at(0.0), ap) +} + +/// A group leaves the room each member's brushed strokes leave for brush art, as the renderer +/// measures the group by its members: a plain brushed path (`16 × 1 + 8` pt past its 1 pt stroke), +/// a painted path (the room past its piece), and the brushed path in a group with an effect of its +/// own. +#[test] +fn a_group_is_culled_by_its_members_brush_art() { + const ID: &str = "test.render.paintedbrush.group"; + install(ID); + for painted in [false, true] { + let mut d = document(); + let member = if painted { source(&mut d, ID, false, true) } else { brushed_path(&mut d) }; + let g = Node::group(d.alloc_id(), vec![Arc::new(member.clone())]); + let d = with(d, g); + let g = d.layers[0].children().unwrap()[0].clone(); + let symbols = d.symbol_boxes(); + let own = crate::brush_fx::cull_bounds(&symbols, &member); + assert!(own.is_some_and(|b| b.x0 < -24.0), "painted: {painted}; {own:?}"); + assert_eq!(crate::brush_fx::cull_bounds(&symbols, &g), own, "painted: {painted}"); + assert_eq!(crate::painted_bounds(&d, &g), own, "painted: {painted}"); + assert_eq!(Renderer::new().bounds_of(&symbols, &g), own, "painted: {painted}"); + } + vectorcraft_plugins::registry::remove(ID); + // A 4 pt stroke leaves `16 × 4 + 8` pt, past the crop marks. An empty effect id stands for a + // stroke of the group's own. + for effect in ["adjust.hueSaturation", "cropMarks", ""] { + let mut d = document(); + let mut member = brushed_path(&mut d); + member.appearance.stroke_mut().unwrap().width = 4.0; + let mut g = Node::group(d.alloc_id(), vec![Arc::new(member.clone())]); + if effect.is_empty() { + g.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into(); + } else { + g.appearance.effects = vec![Effect { id: effect.into(), params: json!({}), visible: true }]; + } + let d = with(d, g); + let g = d.layers[0].children().unwrap()[0].clone(); + assert!(crate::fx::has_object_fx(&g), "{effect}: an effect of the group's own"); + let own = crate::brush_fx::cull_bounds(&d.symbol_boxes(), &member).unwrap(); + assert!(own.x0 <= -73.9, "{own:?}"); + let b = crate::painted_bounds(&d, &g); + assert!(b.is_some_and(|b| b.union(own) == b), "{effect}: {b:?} holds {own:?}"); + } +} + +/// A layer leaves the room its members' brushed strokes leave for brush art. +#[test] +fn a_layer_is_culled_by_its_members_brush_art() { + let mut d = document(); + let member = brushed_path(&mut d); + let d = with(d, member.clone()); + let layer = d.layers[0].clone(); + let symbols = d.symbol_boxes(); + let own = crate::brush_fx::cull_bounds(&symbols, &member); + assert!(own.is_some_and(|b| b.x0 <= -24.4 && b.x1 >= 44.4), "{own:?}"); + assert_eq!(crate::brush_fx::cull_bounds(&symbols, &layer), own); + assert_eq!(crate::painted_bounds(&d, &layer), own); + assert_eq!(Renderer::new().bounds_of(&symbols, &layer), own); +} + +/// Pieces without a brushed stroke are culled by the pieces alone, as before. +#[test] +fn painted_pieces_without_a_brush_take_no_room_for_one() { + const ID: &str = "test.render.paintedbrush.plain"; + install(ID); + let mut d = document(); + let n = source(&mut d, ID, false, false); + let d = with(d, n.clone()); + let symbols = d.symbol_boxes(); + let b = crate::brush_fx::cull_bounds(&symbols, &n); + assert_eq!(b, crate::fx::cull_bounds(&symbols, &n)); + assert!(b.is_some_and(|b| b.x0 > 100.0 && b.x1 < 150.0), "{b:?}"); + vectorcraft_plugins::registry::remove(ID); +} diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 1a4c06f0e6..0472bc83e9 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -3,11 +3,15 @@ //! stays a linked image ([`LinkInfo`]); an SVG file becomes vector art. One that can't be read //! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it //! can be relinked, and a warning. +//! +//! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag) +//! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as +//! it is shown. use std::collections::HashMap; use std::path::Path; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use usvg::{ImageHrefResolver, ImageKind, roxmltree}; use vectorcraft_doc::LinkInfo; @@ -44,6 +48,20 @@ pub(in crate::import) struct Files { svg_images: AtomicUsize, /// SVG images were left out for exceeding [`MAX_NESTED_SVG_IMAGES`]. too_many_svg_images: AtomicBool, + /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got + /// (kept here too, so the address stays its own). + cmyk: Mutex>, +} + +/// Encoded image bytes, shared. +type Bytes = Arc>; + +/// Raster bytes as usvg gets them. +struct Shown { + kind: RasterKind, + bytes: Arc>, + /// The CMYK TIFF stored in their place: a turned CMYK image's inks, turned. + cmyk: Option>, } /// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…). @@ -174,27 +192,37 @@ impl Files { files } - /// What usvg gets for a file read (`bytes`, linked by `link`): a raster usvg reads (others are - /// stored as PNG) or an SVG; `None` for anything else. + /// What usvg gets for a file read (`bytes`, linked by `link`): a raster as [`shown`] gives it + /// (formats usvg doesn't read are stored as PNG) or an SVG; `None` for anything else. fn source(&mut self, bytes: Vec, link: LinkInfo) -> Option { let Ok(format) = image::guess_format(&bytes) else { // Unpacked once, here, within what the SVGZ images may unpack to. let text = crate::text_within(&bytes, &self.unpacked).ok()?; return text.contains(" (RasterKind, Arc>) { + if let Some(cmyk) = shown.cmyk + && let Ok(mut stored) = self.cmyk.lock() + { + stored.insert(key(&shown.bytes), (shown.bytes.clone(), Arc::new(cmyk))); + } + (shown.kind, shown.bytes) + } + + /// The CMYK TIFF stored in place of the image whose data usvg got as `kind`: a CMYK JPEG or + /// TIFF its orientation turns, which usvg got as PNG. + pub(in crate::import) fn stored(&self, kind: &ImageKind) -> Option>> { + let ImageKind::PNG(b) = kind else { return None }; + self.cmyk.lock().ok()?.get(&key(b)).map(|(_, tiff)| tiff.clone()) + } + /// Does href `h` show a placeholder? fn placeholder(&self, h: &str) -> bool { matches!(self.sources.get(h), Some(Source::Raster(_, b)) if self.links.get(&key(b)).is_some_and(|l| l.missing)) @@ -241,21 +269,24 @@ impl Files { pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> { self.depth.store(height, Ordering::Relaxed); ImageHrefResolver { - // By type as usvg reads them; one without a type (`text/plain`) by its bytes: a - // raster usvg reads, else SVG. + // By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its + // bytes: one of those rasters, else SVG. A raster as [`shown`] gives it. resolve_data: Box::new(move |mime, bytes, opts| { - let kind = match mime { - "image/jpg" | "image/jpeg" => ImageKind::JPEG, - "image/png" => ImageKind::PNG, - "image/gif" => ImageKind::GIF, - "image/webp" => ImageKind::WEBP, + use image::ImageFormat as F; + let format = match mime { + "image/jpg" | "image/jpeg" => F::Jpeg, + "image/png" => F::Png, + "image/gif" => F::Gif, + "image/webp" => F::WebP, + "image/tiff" => F::Tiff, "image/svg+xml" => return self.svg_data(&bytes, opts), - "text/plain" => match image::guess_format(&bytes).ok().and_then(raster) { - Some(kind) => kind, + "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { + Some(format) => format, None => return self.svg_data(&bytes, opts), }, _ => return None, }; + let (kind, bytes) = self.keep(shown(bytes, format)?); Some(kind(bytes)) }), resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? { @@ -312,6 +343,82 @@ fn raster(format: image::ImageFormat) -> Option { } } +/// Encoded raster `bytes`, `format` by their type (or by their bytes when they have none), as usvg +/// gets them, as Open and Place store them: unchanged when usvg reads the format and the image's +/// orientation ([`vectorcraft_doc::cmyk::exif_orientation`], read for PNG, JPEG, GIF, WebP and +/// TIFF) leaves the pixels as stored. Otherwise the pixels are read ([`decoded`], +/// [`tiff_pixels`]), turned or flipped as the orientation says, and written as PNG, so usvg sizes +/// and fits the image as it is shown. A CMYK TIFF also keeps its inks as a CMYK TIFF +/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]), and a CMYK JPEG or TIFF that is turned keeps them +/// turned ([`vectorcraft_doc::cmyk::turned_cmyk`]). `None` when the pixels can't be read, and for +/// a TIFF with more strips or tiles than the decoder can count +/// ([`vectorcraft_doc::cmyk::oversized_tiff`]), before any decoder opens it. +fn shown(bytes: Arc>, format: image::ImageFormat) -> Option { + use image::ImageFormat as F; + if vectorcraft_doc::cmyk::oversized_tiff(&bytes).is_some() { + return None; + } + let orientation = + matches!(format, F::Png | F::Jpeg | F::Gif | F::WebP | F::Tiff).then(|| vectorcraft_doc::cmyk::exif_orientation(&bytes)).flatten(); + if let Some(kind) = raster(format) + && orientation.is_none() + { + return Some(Shown { kind, bytes, cmyk: None }); + } + let tiff = image::guess_format(&bytes).ok() == Some(F::Tiff); + let mut img = if tiff { tiff_pixels(&bytes)? } else { decoded(&bytes, format)? }; + let cmyk = match orientation { + Some(o) if matches!(format, F::Jpeg | F::Tiff) => vectorcraft_doc::cmyk::turned_cmyk(&bytes, o, None).map(|(tiff, _)| tiff), + None if tiff => vectorcraft_doc::cmyk::stored_cmyk_tiff(&bytes, None), + _ => None, + }; + if let Some(orientation) = orientation { + img.apply_orientation(orientation); + } + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?; + Some(Shown { kind: ImageKind::PNG, bytes: Arc::new(png), cmyk }) +} + +/// The pixels of encoded image `bytes` (`format` when their bytes don't say), as stored, decoded +/// within [`vectorcraft_doc::MAX_RASTER_ALLOC`], counted as decoded and as RGBA, as Open and Place +/// decode them; `None` when they can't be. +fn decoded(bytes: &[u8], format: image::ImageFormat) -> Option { + use image::ImageDecoder as _; + let max = vectorcraft_doc::MAX_RASTER_ALLOC; + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + if reader.format().is_none() { + reader.set_format(format); + } + let mut limits = image::Limits::default(); + limits.max_alloc = Some(max); + reader.limits(limits); + let decoder = reader.into_decoder().ok()?; + let (w, h) = decoder.dimensions(); + if decoder.total_bytes() > max || u64::from(w) * u64::from(h) * 4 > max { + return None; + } + image::DynamicImage::from_decoder(decoder).ok() +} + +/// The pixels of TIFF `bytes`, as stored, read as Open and Place read them: a TIFF with extra +/// samples (CMYK or gray with alpha) as RGBA, CMYK converted with the active color settings +/// ([`vectorcraft_doc::cmyk::extra_samples_tiff_rgba`]); a 1-bit TIFF compressed with CCITT Group +/// 3 as RGBA ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]); others by [`decoded`], a CMYK TIFF +/// that stores one plane per ink with its inks written together first +/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]). `None` when they can't be read. +fn tiff_pixels(bytes: &[u8]) -> Option { + use image::DynamicImage::ImageRgba8; + let cms = vectorcraft_color::cms::active(); + if let Some(img) = vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) { + return Some(ImageRgba8(img)); + } + if let Some(read) = vectorcraft_doc::cmyk::group3_tiff_rgba(bytes) { + return read.ok().map(ImageRgba8); + } + decoded(bytes, image::ImageFormat::Tiff).or_else(|| decoded(&vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, None)?, image::ImageFormat::Tiff)) +} + /// No image: left out, noted in `why` when `noted`. fn leave_out(why: &AtomicBool, noted: bool) -> Option { if noted { diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 306a0c17d2..2eede1f7be 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -771,6 +771,12 @@ impl Importer { .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); + // A turned CMYK image is stored as the CMYK TIFF of its inks, the size of the PNG usvg got. + let cmyk = self.files.stored(i.kind()); + let (bytes, mime) = match &cmyk { + Some(tiff) => (tiff, "image/tiff"), + None => (bytes, mime), + }; // A linked file keeps a preview; a placeholder is all a missing one has (its preview). let linked = self.files.linked(i.kind()); let missing = linked.is_some_and(|l| l.missing); diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 51bcffa41e..419e73a385 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1236,12 +1236,6 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { - Some("middle") => 0.5, - Some("end") => 1.0, - _ => 0.0, - }; - let anchor = anchor_of(0); let path = cs.path.and_then(|tp| match self.text_path(xml, tp) { Some(p) => Some((tp, p)), None => { @@ -1250,6 +1244,23 @@ impl Ctx { } }); let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + let rtl = self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl"); + // Where the chunk starting at character `i` sits on its anchor: 0 its left (or top) edge, + // 0.5 its middle, 1 its right (or bottom) edge; on a path, its edge toward the path's + // start, its middle or its edge toward the path's end. In horizontal type `start` and + // `end` follow the `direction` of the element holding that character: right-to-left text + // starts at its right edge, and on a path at the edge toward the path's end (type on a + // path lays its glyphs out in visual order from its start). + let anchor_of = |i: usize| { + let Some(&(_, el)) = cs.chars.get(i) else { return 0.0 }; + let a = match self.prop(el, "text-anchor").as_deref() { + Some("middle") => 0.5, + Some("end") => 1.0, + _ => 0.0, + }; + if !vertical && self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") { 1.0 - a } else { a } + }; + let anchor = anchor_of(0); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; @@ -1268,6 +1279,8 @@ impl Ctx { let em = self.font_size(tp); let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; + // Text that would start before the path's beginning starts at it, with all its glyphs + // on the path (SVG does not draw the glyphs that fall off it). let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0)); obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None }; obj.xf = Affine::IDENTITY; @@ -1293,14 +1306,14 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks); + let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, rtl, origin.x, &mut breaks); obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); - // Lines anchored differently (paragraphs aligned differently): each line's paragraph - // takes its alignment, and indents that put it where it was. - if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) { - for (k, &(i, ax)) in starts.iter().enumerate() { - let a = anchor_of(i); + // Lines anchored differently or at different x (paragraphs aligned or indented + // differently): each line's paragraph takes its alignment, and indents that put it + // where it was. + if starts.iter().any(|&(_, a, ax)| a != starts[0].1 || ax != starts[0].2) { + for (k, &(_, a, ax)) in starts.iter().enumerate() { let d = finite(ax - left); let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() }; match pa.justify { @@ -1319,13 +1332,7 @@ impl Ctx { if !para_styles.is_empty() { obj.set_paragraph_styles(para_styles); } - // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first - // strong character: pinned (on every paragraph) when that would read as right to left. - if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { - obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); - } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { - obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); - } + pin_direction(&mut obj, rtl); keep_reader_anchors(&mut obj); // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); @@ -1412,7 +1419,7 @@ fn finite(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } -/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end). +/// The alignment of an SVG text chunk anchored at `anchor` (0 left edge, 0.5 middle, 1 right edge). fn justify_of(anchor: f64) -> Justify { match anchor { a if a >= 1.0 => Justify::Right, @@ -1428,8 +1435,11 @@ fn leading_tolerance(spacing: f64) -> f64 { } /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the -/// justification, the x of the first line's left (or anchor) edge and, when no character is -/// placed on its own, each line's first character and anchor x. +/// justification, the x of the first line's left (or anchor) edge and each line's first +/// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk +/// starting at a character sits on its anchor, and `rtl` whether the text's `direction` is `rtl` +/// ([`pin_direction`]). When characters are placed on their own, every line is left-aligned and +/// its anchor x is its left edge. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1437,10 +1447,11 @@ fn lines( y: &[Option], dx: &[f64], dy: &[f64], - anchor: f64, + anchor_of: &dyn Fn(usize) -> f64, + rtl: bool, x0: f64, breaks: &mut Vec, -) -> (Justify, f64, Option>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1482,53 +1493,59 @@ fn lines( } } if targets.is_empty() { - return (justify_of(anchor), x0, Some(lines)); + let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); + return (justify_of(anchor_of(0)), x0, starts); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. - // SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each - // chunk's target moves back by its share of the chunk's width. + // SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left + // edge moves back by its share of its own width. A chunk's edges are those of its glyphs as + // they are drawn: the first character of a right-to-left chunk is at its right. let line_of = |i: usize| lines.partition_point(|(s, _)| *s <= i) - 1; - let mut left = x0; - let mut wants: Vec<(usize, f64)> = targets.clone(); - if anchor > 0.0 { - let (runs, bytes) = assemble(cells, breaks); - let lay = measure(&with_runs(runs.0)); - let (pens, ends) = pens(&lay, &bytes); - // Chunk starts in order: line starts and absolutely placed characters. - let mut starts: Vec = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect(); - starts.sort_unstable(); - let width = |s: usize| { - let k = starts.partition_point(|&c| c <= s); - let e = starts.get(k).copied().unwrap_or(n); - let end = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max); - match pens[s] { - Some(p) if end.is_finite() => end - p, - _ => 0.0, - } - }; - let line_left: Vec = lines.iter().map(|&(s, lx)| lx - anchor * width(s)).collect(); - left = line_left[0]; - for w in &mut wants { - w.1 -= anchor * width(w.0) + line_left[line_of(w.0)]; - } - } else { - for w in &mut wants { - w.1 -= lines[line_of(w.0)].1; - } - } + // Chunk starts in order: line starts and absolutely placed characters. + let mut starts: Vec = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect(); + starts.sort_unstable(); + // The left and right edges of the chunk starting at character `s`, from each character's pen and end. + let edges = |s: usize, pens: &[Option], ends: &[Option]| { + let e = starts.get(starts.partition_point(|&c| c <= s)).copied().unwrap_or(n); + let left = (s..e).filter_map(|j| pens[j]).fold(f64::INFINITY, f64::min); + let right = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max); + (left.is_finite() && right.is_finite()).then_some((left, right)) + }; + let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); + // Where each absolutely placed character's pen goes, from the left edge of its line's first chunk. + let mut wants: Vec<(usize, f64)> = vec![]; // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. - for _ in 0..4 { + for round in 0..4 { let (runs, bytes) = assemble(cells, breaks); - let lay = measure(&with_runs(runs.0)); - let (pens, _) = pens(&lay, &bytes); + let mut text = with_runs(runs.0); + pin_direction(&mut text, rtl); + let (pens, ends) = pens(&measure(&text), &bytes); + // The edges of each line's first chunk, found once a round. + let first: Vec> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect(); + if round == 0 { + // The chunks as they lie before any is moved: each one's left edge on its anchor. + let left_at = |s: usize, x: f64, chunk: Option<(f64, f64)>| chunk.map_or(x, |(left, right)| x - anchor_of(s) * (right - left)); + let first_left: Vec = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect(); + wants = targets + .iter() + .map(|&(i, x)| { + let chunk = edges(i, &pens, &ends); + // The pen of a right-to-left chunk's first character is right of the chunk's left edge. + let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, _))| p - left); + (i, left_at(i, x, chunk) + pen - first_left[line_of(i)]) + }) + .collect(); + // A line's layout starts at the line's left edge, and its first chunk's left edge is `left` from there. + line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, _)| left)).collect(); + } let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64); for &(i, want) in &wants { let l = line_of(i); if l != line { (line, moved) = (l, 0.0); } - let (Some(p), Some(p0)) = (pens[i], pens[lines[l].0]) else { continue }; + let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue }; let d = want - (p - p0 + moved); cells[i - 1].kern += d; moved += d; @@ -1538,7 +1555,8 @@ fn lines( break; } } - (Justify::Left, left, None) + let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); + (Justify::Left, line_left[0], starts) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1546,7 +1564,9 @@ fn lines( /// export anchors lines so); VectorCraft aligns a line without that tracking /// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the /// type moves when every line moves alike, otherwise each paragraph's right indent takes the -/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing +/// tracking (a line of imported point type is a paragraph). A right-to-left paragraph's lines +/// keep all their tracking in the layout (`trailing` is 0), so they stay where they are and their +/// whole advance sits on the anchor. Browsers add that letter spacing /// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end. fn keep_reader_anchors(obj: &mut TextObject) { let share = |j: Justify| match j { @@ -1590,6 +1610,17 @@ fn keep_reader_anchors(obj: &mut TextObject) { obj.set_paragraph_styles(styles); } +/// SVG text runs left to right unless `direction: rtl` on the `` says otherwise (`rtl`), +/// whatever its first strong character: pinned (on every paragraph of `obj`) when that would read +/// as right to left. +fn pin_direction(obj: &mut TextObject, rtl: bool) { + if rtl { + obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); + } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { + obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); + } +} + /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); diff --git a/crates/svg/tests/it/image_orientation.rs b/crates/svg/tests/it/image_orientation.rs new file mode 100644 index 0000000000..fbf412a0bb --- /dev/null +++ b/crates/svg/tests/it/image_orientation.rs @@ -0,0 +1,278 @@ +//! Raster images whose orientation (EXIF, or a TIFF's Orientation tag) turns their pixels import +//! as they are shown, linked or as data URLs: the pixels turned, the width and height swapped, and +//! the turned image fitted to the `` box. + +use std::path::PathBuf; + +use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind}; +use vectorcraft_geom::Rect; +use vectorcraft_svg::{ImportOptions, base64_encode, import_with, import_with_report}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +fn images(d: &Document) -> Vec<(&Node, &ImageObject)> { + let mut v = Vec::new(); + d.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + v.push((n, &**im)); + } + }); + v +} + +/// The one image `body` imports to, with its warnings, its link read from `dir` when given. +fn import_one(body: &str, dir: Option<&PathBuf>) -> (Document, ImageObject) { + let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path))); + let folder = dir.map(|d| d.to_string_lossy().into_owned()); + let (d, w) = match &folder { + Some(f) => import_with(&svg(body), &ImportOptions { folder: Some(f), read: Some(&read) }).unwrap(), + None => import_with_report(&svg(body)).unwrap(), + }; + assert!(w.is_empty(), "{w:?}"); + let im = match &images(&d)[..] { + [(_, im)] => (*im).clone(), + other => panic!("{} images", other.len()), + }; + (d, im) +} + +/// A fresh folder for one test's files. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-svg-orientation-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A 16 × 8 JPEG, red on the left half, with an EXIF segment giving Orientation `orientation` +/// when there is one. +fn jpeg(orientation: Option) -> Vec { + let rgb = image::RgbImage::from_fn(16, 8, |x, _| if x < 8 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) }); + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + let Some(orientation) = orientation else { return jpg }; + let o = orientation.to_le_bytes(); + let exif: Vec = + [b"Exif\0\0".as_slice(), b"II", &[0x2a, 0, 8, 0, 0, 0], &[1, 0], &[0x12, 0x01, 3, 0, 1, 0, 0, 0, o[0], o[1], 0, 0], &[0, 0, 0, 0]].concat(); + let mut tagged = jpg[..2].to_vec(); + tagged.extend([0xff, 0xe1]); + tagged.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + tagged.extend(&exif); + tagged.extend(&jpg[2..]); + tagged +} + +/// A little-endian, uncompressed TIFF of `w` × `h` pixels of `samples` 8-bit samples each (1 for +/// gray, 2 for gray with an unassociated alpha channel, 4 for CMYK inks), `data` row by row, with +/// an Orientation tag (274) of `orientation`. Its RowsPerStrip (278) value is at byte [`ROWS_AT`] +/// and its PlanarConfiguration (284, 1) value at [`PLANAR_AT`]. +fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec { + let entries: u32 = if samples == 2 { 12 } else { 11 }; + let ifd_end = 8 + 2 + entries * 12 + 4; + let bits_at = ifd_end; + let data_at = bits_at + if samples > 2 { 2 * u32::from(samples) } else { 0 }; + let entry = |tag: u16, kind: u16, count: u32, value: u32| { + [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &count.to_le_bytes(), &value.to_le_bytes()].concat() + }; + // Two SHORTs fit in the entry; more are written after the directory. + let bits = match samples { + 1 => 8, + 2 => 8 | 8 << 16, + _ => bits_at, + }; + let photometric = if samples == 4 { 5 } else { 1 }; + let mut out = [b"II".as_slice(), &[0x2a, 0], &8u32.to_le_bytes(), &u16::try_from(entries).unwrap().to_le_bytes()].concat(); + // ExtraSamples (338): unassociated alpha. + let extra = (samples == 2).then(|| entry(338, 3, 1, 2)); + for e in [ + entry(256, 4, 1, w), + entry(257, 4, 1, h), + entry(258, 3, u32::from(samples), bits), + entry(259, 3, 1, 1), + entry(262, 3, 1, photometric), + entry(273, 4, 1, data_at), + entry(274, 3, 1, u32::from(orientation)), + entry(277, 3, 1, u32::from(samples)), + entry(278, 4, 1, h), + entry(279, 4, 1, u32::try_from(data.len()).unwrap()), + entry(284, 3, 1, 1), + ] + .into_iter() + .chain(extra) + { + out.extend(e); + } + out.extend(0u32.to_le_bytes()); + if samples > 2 { + for _ in 0..samples { + out.extend(8u16.to_le_bytes()); + } + } + assert_eq!(out.len(), data_at as usize); + out.extend(data); + out +} + +/// Where [`tiff`] writes the RowsPerStrip value (the ninth entry) and the PlanarConfiguration value +/// (the eleventh). +const ROWS_AT: usize = 8 + 2 + 8 * 12 + 8; +const PLANAR_AT: usize = 8 + 2 + 10 * 12 + 8; + +/// A 4 × 2 gray TIFF, black on the left half, turned a quarter clockwise by Orientation 6. +fn gray_tiff() -> Vec { + tiff(4, 2, 1, &[0, 0, 255, 255, 0, 0, 255, 255], 6) +} + +/// Where the image's own box goes on the page. +fn placed(im: &ImageObject) -> Rect { + im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))) +} + +fn near(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6) +} + +/// The stored pixels of `im`, as RGB. +fn pixels(d: &Document, im: &ImageObject) -> image::RgbImage { + image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgb8() +} + +fn red(p: &image::Rgb) -> bool { + p[0] > 150 && p[1] < 80 +} + +/// A JPEG whose EXIF Orientation 6 turns it a quarter clockwise shows upright, linked and as a +/// data URL alike: 8 × 16, the left half on top, fitted (centered) in a square box as the turned +/// image is, and stored without an orientation that would turn it again. +#[test] +fn a_turned_jpeg_imports_upright() { + let jpg = jpeg(Some(6)); + let dir = folder("jpeg"); + std::fs::write(dir.join("photo.jpg"), &jpg).unwrap(); + let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg)); + let untyped = format!("data:;base64,{}", base64_encode(&jpg)); + for href in ["photo.jpg", data.as_str(), untyped.as_str()] { + let (d, im) = import_one(&format!(r#""#), Some(&dir)); + let what = &href[..href.len().min(20)]; + assert_eq!((im.width, im.height), (8, 16), "{what}: turned a quarter"); + assert!(near(placed(&im), Rect::new(30.0, 20.0, 70.0, 100.0)), "{what}: fitted as turned: {:?}", placed(&im)); + let px = pixels(&d, &im); + assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "{what}: the left half is on top"); + assert_eq!(vectorcraft_doc::cmyk::exif_orientation(&d.images[&im.key].bytes), None, "{what}: not turned again"); + assert_eq!(im.link.is_some(), href == "photo.jpg", "{what}"); + } +} + +/// With `preserveAspectRatio="none"` the turned image fills the box. +#[test] +fn a_turned_jpeg_stretches_to_the_box_as_turned() { + let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6)))); + let (_, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), (8, 16)); + assert!(near(placed(&im), Rect::new(10.0, 20.0, 90.0, 60.0)), "{:?}", placed(&im)); +} + +/// A JPEG without an orientation, or with Orientation 1, keeps its own bytes. +#[test] +fn an_upright_jpeg_keeps_its_bytes() { + for jpg in [jpeg(None), jpeg(Some(1))] { + let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg)); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), (16, 8)); + assert_eq!(*d.images[&im.key].bytes, jpg); + assert_eq!(d.images[&im.key].mime, "image/jpeg"); + } +} + +/// A TIFF whose Orientation tag is 6 shows upright, linked, as an `image/tiff` data URL and as a +/// data URL without a type. +#[test] +fn a_turned_tiff_imports_upright() { + let tif = gray_tiff(); + let dir = folder("tiff"); + std::fs::write(dir.join("scan.tif"), &tif).unwrap(); + let data = format!("data:image/tiff;base64,{}", base64_encode(&tif)); + let untyped = format!("data:;base64,{}", base64_encode(&tif)); + for href in ["scan.tif", data.as_str(), untyped.as_str()] { + let (d, im) = import_one(&format!(r#""#), Some(&dir)); + let what = &href[..href.len().min(20)]; + assert_eq!((im.width, im.height), (2, 4), "{what}: turned a quarter"); + assert!(near(placed(&im), Rect::new(0.0, 0.0, 20.0, 40.0)), "{what}: {:?}", placed(&im)); + let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8(); + assert_eq!((px.get_pixel(0, 0)[0], px.get_pixel(1, 1)[0], px.get_pixel(0, 3)[0]), (0, 0, 255), "{what}: the left half is on top"); + } +} + +/// A CMYK TIFF keeps its inks as a CMYK TIFF, turned when its orientation turns it. +#[test] +fn a_cmyk_tiff_keeps_its_inks_turned_or_not() { + let (c, none) = ([255u8, 0, 0, 0], [0u8; 4]); + let data: Vec = [c, c, none, none, c, c, none, none].concat(); + for (orientation, (w, h), cyan_at, none_at) in [(1, (4usize, 2usize), (1, 1), (2, 0)), (6, (2, 4), (1, 1), (0, 2))] { + let tif = tiff(4, 2, 4, &data, orientation); + let href = format!("data:image/tiff;base64,{}", base64_encode(&tif)); + let (d, im) = import_one(&format!(r#""#), None); + let what = format!("orientation {orientation}"); + assert_eq!((im.width as usize, im.height as usize), (w, h), "{what}"); + let blob = &d.images[&im.key]; + assert_eq!(blob.mime, "image/tiff", "{what}"); + let inks = blob.cmyk().expect("CMYK inks"); + assert_eq!((inks.width as usize, inks.height as usize), (w, h), "{what}"); + let ink = |(x, y): (usize, usize)| &inks.data[(y * w + x) * 4..][..4]; + assert_eq!((ink((0, 0)), ink(cyan_at), ink(none_at)), (&c[..], &c[..], &none[..]), "{what}: cyan on the left, or on top"); + } +} + +/// A gray TIFF with an alpha channel, which the image decoder can't read, imports as Open and +/// Place read it, with its transparency: upright, and turned by Orientation 6. +#[test] +fn a_gray_tiff_with_alpha_imports_turned_or_not() { + let (black, clear) = ([0u8, 255], [255u8, 0]); + let data: Vec = [black, black, clear, clear, black, black, clear, clear].concat(); + for (orientation, size, black_at, clear_at) in [(1, (4, 2), (0, 1), (3, 0)), (6, (2, 4), (1, 1), (0, 3))] { + let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff(4, 2, 2, &data, orientation))); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), size, "orientation {orientation}"); + let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(black_at.0, black_at.1).0, [0, 0, 0, 255], "orientation {orientation}: black"); + assert_eq!(px.get_pixel(clear_at.0, clear_at.1)[3], 0, "orientation {orientation}: clear"); + } +} + +/// A turned TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a +/// strip each, times 5 planes) is refused without a panic: linked, it is a placeholder with a +/// warning; as a data URL, it is left out. +#[test] +fn a_tiff_with_more_strips_than_can_be_counted_is_refused() { + let mut tif = tiff(3, 1 << 31 | 1, 5, &[0; 30], 6); + tif[ROWS_AT..ROWS_AT + 4].copy_from_slice(&1u32.to_le_bytes()); + tif[PLANAR_AT..PLANAR_AT + 2].copy_from_slice(&2u16.to_le_bytes()); + assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&tif), Some((3, 1 << 31 | 1))); + let dir = folder("oversized"); + std::fs::write(dir.join("huge.tif"), &tif).unwrap(); + let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path))); + let f = dir.to_string_lossy(); + let (d, w) = + import_with(&svg(r#""#), &ImportOptions { folder: Some(&f), read: Some(&read) }).unwrap(); + assert!(w.iter().any(|w| w.contains("huge.tif") && w.contains("is not an image VectorCraft reads")), "{w:?}"); + let [(_, im)] = images(&d)[..] else { panic!("{} images", images(&d).len()) }; + assert!(d.images[&im.key].is_proxy(), "a placeholder"); + let data = format!("data:image/tiff;base64,{}", base64_encode(&tif)); + let (d, _) = import_with_report(&svg(&format!(r#""#))).unwrap(); + assert!(images(&d).is_empty()); +} + +/// A turned JPEG inside an SVG shown as an image is turned too. +#[test] +fn a_turned_jpeg_in_an_svg_image_imports_upright() { + let jpg = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6)))); + let inner = format!(r#""#); + let nested = format!("data:image/svg+xml;base64,{}", base64_encode(inner.as_bytes())); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), (8, 16)); + assert!(near(placed(&im), Rect::new(20.0, 0.0, 60.0, 80.0)), "{:?}", placed(&im)); + let px = pixels(&d, &im); + assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12))); +} diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index b25dc23678..92747cd05a 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -360,6 +360,197 @@ fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); } +/// Document-space left and right edges of the advance of each laid-out line. +fn line_edges(t: &TextObject) -> Vec<(f64, f64)> { + let lay = layout(FontDb::global(), t); + lay.lines + .iter() + .map(|l| { + let g = &lay.glyphs[l.glyph_start..l.glyph_end]; + let left = g.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let right = g.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max); + (left, right) + }) + .collect() +} + +/// SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): with +/// `direction: rtl`, `start` puts the right side of the text at its position and `end` puts the +/// left side there; `middle` centers it either way. The direction is the effective one of the element that +/// holds the chunk's first character. +#[test] +fn rtl_point_text_start_and_end_use_directional_edges() { + // (markup, share of the advance left of x = 200) + for (body, share) in [ + (r#"Wave"#, 1.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.5), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + ] { + let d = open(body); + let (left, right) = line_edges(only_text(&d))[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{body}: the advance spans {left}..{right}, so it does not start at {want}"); + } +} + +/// On a path, right-to-left text anchored by `start` ends at `startOffset` and one anchored by +/// `end` begins there (its glyphs run from the path's start in visual order); `middle` centers it. +#[test] +fn rtl_type_on_a_path_starts_at_its_directional_edge() { + // (anchor, direction, share of the advance before the offset) + for (anchor, direction, share) in [("start", "rtl", 1.0), ("middle", "rtl", 0.5), ("end", "rtl", 0.0), ("start", "ltr", 0.0), ("end", "ltr", 1.0)] + { + let d = open(&format!( + r##" + Wave"## + )); + let t = only_text(&d); + assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind); + let lay = layout(FontDb::global(), t); + let left = lay.glyphs.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let width: f64 = lay.glyphs.iter().map(|g| g.advance).sum(); + let want = 200.0 - share * width; + assert!(close(left, want, 0.05), "{anchor} {direction}: the text starts at {left}, not {want}"); + } +} + +/// Letter-spaced right-to-left lines: a line keeps the tracking after each of its characters, and +/// its whole advance sits on its anchor as `start`, `middle` and `end` place it. +#[test] +fn rtl_letter_spaced_lines_stay_on_their_anchor() { + for (anchor, share) in [("start", 1.0), ("middle", 0.5), ("end", 0.0)] { + let d = open(&format!(r#"WAVE"#)); + let t = only_text(&d); + let (left, right) = line_edges(t)[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{anchor}: the advance spans {left}..{right}, so it does not start at {want}"); + let natural = { + let d = open(&format!(r#"WAVE"#)); + let (l, r) = line_edges(only_text(&d))[0]; + r - l + }; + assert!(close(right - left, natural + 4.0 * 8.0, 1e-6), "{anchor}: the advance {} holds the tracking of all 4 letters", right - left); + } + // A right-to-left line anchored by `start` beside a left-to-right `middle` line: each keeps its own anchor. + let d = open( + r#"WaveLine"#, + ); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "the right-to-left line ends at 200: {e:?}"); + let lay = layout(FontDb::global(), t); + let a: f64 = lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end].iter().map(|g| g.advance).sum(); + assert!(close(e[1].0, 200.0 - a / 2.0, 1e-6), "the middle line is centered on 200 with its tracking: {e:?}"); +} + +/// Each line is a chunk anchored by the `text-anchor` and `direction` of its first character. +#[test] +fn rtl_lines_use_each_chunk_first_character_direction_and_anchor() { + let d = open( + r#"WaveLineLastMid"#, + ); + let e = line_edges(only_text(&d)); + assert_eq!(e.len(), 4, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "a right-to-left start line ends at 200: {e:?}"); + assert!(close(e[1].0, 200.0, 1e-6), "a left-to-right start line starts at 200: {e:?}"); + assert!(close(e[2].0, 200.0, 1e-6), "a right-to-left end line starts at 200: {e:?}"); + assert!(close((e[3].0 + e[3].1) / 2.0, 200.0, 1e-6), "a middle line is centered on 200: {e:?}"); +} + +/// A line starts at its own x, also when it is anchored as the line before it is and when another +/// line holds absolutely placed characters. +#[test] +fn each_line_starts_at_its_own_x() { + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].0, 50.0, 1e-6) && close(e[1].0, 120.0, 1e-6), "{e:?}"); + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].1, 150.0, 1e-6) && close(e[1].1, 100.0, 1e-6), "{e:?}"); + let d = open(r#"abLine"#); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].0, 50.0, 0.05) && close(e[1].1, 200.0, 0.05), "{e:?}"); + let b = (t.xf * layout(FontDb::global(), t).glyphs[1].origin).x; + assert!(close(b, 100.0, 0.05), "b starts at {b}"); +} + +/// Absolutely placed chunks are anchored on their own, each by the `text-anchor` and `direction` +/// of its first character, whatever the first chunk's anchor. +#[test] +fn absolutely_placed_chunks_use_their_own_rtl_anchor() { + // (markup, share of each chunk's advance left of its x) + for (body, shares) in [ + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.0, 0.0]), + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.5, 0.0]), + (r#"ab"#, [0.5, 1.0]), + (r#"ab"#, [0.5, 0.5]), + (r#"ab"#, [1.0, 1.0]), + ] { + let d = open(body); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + assert_eq!(lay.glyphs.len(), 2, "{body}"); + // Natural advance: the kerning that moves `b` onto its x is added after `a`. + let style = |i: usize| t.runs.iter().flat_map(|r| r.text.chars().map(move |_| &r.style)).nth(i).unwrap(); + let natural = |i: usize| lay.glyphs[i].advance - style(i).kerning.unwrap_or(0.0) / 1000.0 * style(i).size; + for (i, (x, share)) in [80.0, 200.0].into_iter().zip(shares).enumerate() { + let left = (t.xf * lay.glyphs[i].origin).x; + let want = x - share * natural(i); + assert!(close(left, want, 0.05), "{body}: chunk {i} starts at {left}, not {want}"); + } + } +} + +/// A right-to-left chunk of several characters is anchored by its visual edges: its first +/// character is drawn at its right, so the chunk runs left from that character's right edge. +#[test] +fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges() { + // (markup, share of the right-to-left chunk's advance left of x = 200) + for (body, share) in [ + (r#"aאב"#, 1.0), + (r#"aאב"#, 0.5), + (r#"aאב"#, 0.0), + (r#"aאב"#, 1.0), + // A digit is not a strong character: the text still runs left to right. + (r#"1אב"#, 0.0), + // The chunk starts a line, and another line holds absolutely placed characters. + (r#"אבab"#, 1.0), + ( + r#"אבab"#, + 0.5, + ), + ( + r#"אבab"#, + 0.0, + ), + ] { + let d = open(body); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + let chunk: Vec<_> = lay.glyphs.iter().filter(|g| g.rtl).collect(); + assert_eq!(chunk.len(), 2, "{body}"); + assert!(chunk.iter().all(|g| g.advance > 1.0), "{body}: both letters have an advance"); + let left = chunk.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let right = chunk.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max); + let want = 200.0 - share * (right - left); + assert!(close(left, want, 0.05), "{body}: the chunk spans {left}..{right}, so it does not start at {want}"); + // The first character is the right one of the two. + let first = lay.glyphs.iter().filter(|g| g.rtl).min_by_key(|g| g.byte).map(|g| (t.xf * g.origin).x); + assert!(first.is_some_and(|x| x > left + 1.0), "{body}: the first character starts at {first:?}, the chunk at {left}"); + } +} + #[test] fn a_small_line_gap_in_tiny_type_keeps_its_leading() { // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index b241f2e75b..05546891f4 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -20,6 +20,7 @@ mod font_weights; mod foreign_attributes; mod gradients; mod ids; +mod image_orientation; mod import_details; mod import_live; mod import_text; diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index bac0edbe92..be738f639e 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -61,10 +61,51 @@ pub(crate) struct Params { pub hyphen_limit: usize, } -struct Node { +/// The search gave up: it ran past its budget ([`Measure::line`]). +#[derive(Debug)] +pub(crate) struct Exhausted; + +/// Where each line of a paragraph goes and how wide it is: the measure a line gets may depend on +/// the lines set above it (their heights move it down a shaped frame, or past a wrap object). +pub(crate) trait Measure { + /// What the measure of the next line depends on; breaks reached with equal states are + /// interchangeable from there on. + type State: Clone + PartialEq; + /// The state of the paragraph's first line. + fn first(&mut self) -> Self::State; + /// The width of the line of glyphs `pos..end` set from `state`, and the state of the line + /// after it; `Ok(None)` when the line can't be set (the frame is full), and [`Exhausted`] when + /// the search is to stop. + fn line(&mut self, state: &Self::State, pos: usize, end: usize) -> Result, Exhausted>; + /// Whether a line from `state` longer than `pos..end`, which [`Measure::line`] just measured, + /// may get more room than it. While it may, a line from `state` that is too long, or that + /// can't be set, does not end the reach of `state`. Lines from a state are all as wide by + /// default. + fn may_widen(&mut self, _state: &Self::State, _pos: usize, _end: usize) -> bool { + false + } +} + +/// Line widths by line number: `width(k)` for line `k`, lines from `uniform_from` on all as wide. +struct ByLine<'a> { + width: &'a dyn Fn(usize) -> f64, + uniform_from: usize, +} + +impl Measure for ByLine<'_> { + type State = usize; + fn first(&mut self) -> usize { + 0 + } + fn line(&mut self, &line: &usize, _: usize, _: usize) -> Result, Exhausted> { + Ok(Some(((self.width)(line), (line + 1).min(self.uniform_from)))) + } +} + +struct Node { pos: usize, - /// Line number of the next line, clamped to `uniform_from`. - line: usize, + /// The measure of the next line (its line number, clamped to `uniform_from`, for [`compose`]). + state: S, fitness: u8, demerits: f64, /// Lines in a row ending with a hyphen up to this break (at most `hyphen_limit`, or 1 with no @@ -131,6 +172,19 @@ pub(crate) fn compose( cands: &[Breakpoint], flex: &[Flex], p: &Params, +) -> Option> { + compose_with(g, &mut ByLine { width, uniform_from: p.uniform_from }, start_credit, end_credit, cands, flex, p) +} + +/// [`compose`] with each line's width from `measure`, which follows where the lines above it went. +pub(crate) fn compose_with( + g: &[SGlyph], + measure: &mut M, + start_credit: &dyn Fn(usize) -> f64, + end_credit: &dyn Fn(usize) -> f64, + cands: &[Breakpoint], + flex: &[Flex], + p: &Params, ) -> Option> { let n = g.len(); if n == 0 { @@ -165,14 +219,14 @@ pub(crate) fn compose( all.push(Breakpoint { end: n, hyphen: 0.0 }); let (adj_demerits, hyphen_penalty) = if p.ragged { (ADJ_DEMERITS_RAGGED, HYPHEN_PENALTY_RAGGED) } else { (ADJ_DEMERITS_JUSTIFIED, HYPHEN_PENALTY) }; - let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphens: 0, prev: None }]; + let mut nodes = vec![Node { pos: 0, state: measure.first(), fitness: 1, demerits: 0.0, hyphens: 0, prev: None }]; let mut active: Vec = vec![0]; for bp in &all { let last = bp.end == n; let hyphen = bp.hyphen > 0.0; // `automatic` is true for a hyphenation point and false for a soft hyphen typed in the text. let automatic = hyphen && !bp.end.checked_sub(1).and_then(|k| g.get(k)).is_some_and(SGlyph::is_soft_hyphen); - let mut best: Vec<(usize, u8, usize, f64, usize)> = vec![]; // (line, fitness, hyphens, demerits, from) + let mut best: Vec<(M::State, u8, usize, f64, usize)> = vec![]; // (state, fitness, hyphens, demerits, from) let mut keep = Vec::with_capacity(active.len()); for &ai in &active { let Some(a) = nodes.get(ai) else { continue }; @@ -182,13 +236,25 @@ pub(crate) fn compose( // last glyph's advance, so a longer line from the same node is never narrower. let end = if hyphen || t <= a.pos { 0.0 } else { line_end_credit(t) }; let natural = w[t] - w[a.pos] - credit - end + bp.hyphen; - let lw = width(a.line); - // Adjustment ratio, badness and whether the line ends the active node's reach. - let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue }; - if ratio < -1.0 { - // Too long already; longer lines from this node only get worse. + // A line that can't be set (no room left in the frame) ends this node's reach, unless a + // longer line may be placed elsewhere (a taller line steps down the frame by its own + // leading). + let Some((lw, next)) = measure.line(&a.state, a.pos, bp.end).ok()? else { + if measure.may_widen(&a.state, a.pos, bp.end) { + keep.push(ai); + } continue; - } + }; + // Adjustment ratio, badness and whether the line ends the active node's reach. + let ratio = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p); + let Some(ratio) = ratio.filter(|&r| r >= -1.0 || r.is_nan()) else { + // Too long already; longer lines from this node only get worse, unless they may + // get more room (a taller line set lower down a shaped frame). + if measure.may_widen(&a.state, a.pos, bp.end) { + keep.push(ai); + } + continue; + }; keep.push(ai); if automatic && p.hyphen_limit > 0 && a.hyphens >= p.hyphen_limit { continue; @@ -214,22 +280,21 @@ pub(crate) fn compose( d += adj_demerits; } let total = a.demerits + d; - let line = (a.line + 1).min(p.uniform_from); // With a limit, breaks reached with different runs of hyphenated lines stay apart. let hyphens = match (hyphen, p.hyphen_limit) { (false, _) => 0, (true, 0) => 1, (true, limit) => (a.hyphens + 1).min(limit), }; - match best.iter_mut().find(|b| b.0 == line && b.1 == fit && b.2 == hyphens) { + match best.iter_mut().find(|b| b.0 == next && b.1 == fit && b.2 == hyphens) { Some(b) if b.3 <= total => {} - Some(b) => *b = (line, fit, hyphens, total, ai), - None => best.push((line, fit, hyphens, total, ai)), + Some(b) => *b = (next, fit, hyphens, total, ai), + None => best.push((next, fit, hyphens, total, ai)), } } active = keep; - for (line, fitness, hyphens, demerits, from) in best { - nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphens, prev: Some(from) }); + for (state, fitness, hyphens, demerits, from) in best { + nodes.push(Node { pos: bp.end, state, fitness, demerits, hyphens, prev: Some(from) }); active.push(nodes.len() - 1); } if active.is_empty() || active.len() > MAX_ACTIVE { diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 9e18b2a86c..ace69a4148 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -105,6 +105,8 @@ pub struct FontDb { family_cache: Mutex>>, menu_cache: Mutex>, generation: AtomicU64, + /// This database's [`FontDb::id`]. + id: u64, /// System fallback state: characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 4ef17c40c6..0e28d57528 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -21,6 +21,9 @@ use super::{Arc, AtomicU64, FontDb, FontPaths, HashMap, Mutex, Ordering, Path, P #[cfg(not(target_arch = "wasm32"))] use super::{ScanStamp, modified}; +/// The next [`FontDb::id`]. +static NEXT_ID: AtomicU64 = AtomicU64::new(0); + impl FontDb { /// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders, /// read with their subfolders, and font files). @@ -64,6 +67,7 @@ impl FontDb { family_cache: Mutex::new(None), menu_cache: Mutex::new(None), generation: AtomicU64::new(0), + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), #[cfg(not(target_arch = "wasm32"))] @@ -128,6 +132,12 @@ impl FontDb { self.generation.load(Ordering::Relaxed) } + /// A number no other database made in this process has: with [`FontDb::generation`], it tells + /// what a list built from a database was built from. + pub fn id(&self) -> u64 { + self.id + } + /// Family names available (loaded plus installed system fonts), sorted and deduplicated. pub fn families(&self) -> Vec { self.family_list().to_vec() diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index cbe7aaf394..4f3566b97d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -31,6 +31,7 @@ mod flow; mod on_path; mod region; mod ruby; +mod shaped; mod vertical; pub(crate) use bidi::{is_rtl, para_bidi}; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 30a498257e..fd53a26428 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -26,14 +26,18 @@ impl Metrics { pub(super) fn max(g: &[SGlyph]) -> Option { let mut it = g.iter(); let first = Self::of(it.next()?); - Some(it.fold(first, |m, g| Self { - asc: m.asc.max(g.ascent), - desc: m.desc.max(g.descent), - lead: m.lead.max(g.leading), - cap: m.cap.max(g.cap), - xh: m.xh.max(g.xh), - top: m.top.max(Self::of(g).top), - })) + Some(it.fold(first, |m, g| m.union(Self::of(g)))) + } + /// The larger of each of the metrics of `self` and `o`: those of a line holding both. + pub(super) fn union(self, o: Self) -> Self { + Self { + asc: self.asc.max(o.asc), + desc: self.desc.max(o.desc), + lead: self.lead.max(o.lead), + cap: self.cap.max(o.cap), + xh: self.xh.max(o.xh), + top: self.top.max(o.top), + } } /// Distance from the frame top to the first baseline. pub(super) fn first_baseline(&self, fb: FirstBaseline, min: f64) -> f64 { @@ -67,6 +71,9 @@ pub(super) struct Pen<'r> { /// Baseline steps still allowed over all the frame's cells and placements (shared by this pen's /// copies): see [`MAX_TOTAL_STEPS`]. pub(super) steps_left: std::rc::Rc>, + /// What is left of the every-line composer's search over the frame's paragraphs (shared by + /// this pen's copies): see [`super::shaped::Budget`]. + pub(super) search: std::rc::Rc, } /// The largest number of steps layout takes to move a line down a frame cell to find room for it: @@ -103,9 +110,24 @@ impl Pen<'_> { let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est)); return Some((b, f64::NEG_INFINITY, f64::INFINITY, false)); }; - if !self.queued.is_empty() { + // A further span was measured across the height of the line set before it on its baseline. + // This line gets the part of it that is free across its own height: a taller line can + // reach a wrap object the line before it stayed clear of. A span with no such part is + // left empty, and so is a part the line's height made narrower than `min`. + while !self.queued.is_empty() { let (b, x0, x1) = self.queued.remove(0); - return Some((b, x0, x1, true)); + let Some(r) = regions.get(self.ri) else { return Some((b, x0, x1, true)) }; + let mut free = r.spans(b - est.asc, b + est.desc).into_iter().filter_map(|(s, e)| { + let (s, e) = (s.max(x0), e.min(x1)); + let room = e - s - ind_l - ind_r; + let narrowed = s > x0 + EPS || e < x1 - EPS; + (room > est.asc.max(1.0) && (!narrowed || room + EPS >= min)).then_some((s, e)) + }); + let Some((s, e)) = free.next() else { continue }; + // An object inside the span splits it: the parts after the first stay queued. + let rest: Vec<_> = free.map(|(s, e)| (b, s, e)).collect(); + self.queued.splice(0..0, rest); + return Some((b, s, e, true)); } loop { let r = regions.get(self.ri)?; @@ -841,6 +863,7 @@ fn a_pen_out_of_baseline_steps_finds_no_room() { next_top: None, no_room_for: f64::INFINITY, steps_left: std::rc::Rc::new(std::cell::Cell::new(steps)), + search: Default::default(), }; assert!(pen(MAX_TOTAL_STEPS).place(est, 0.0, 0.0, 0.0).is_some()); assert!(pen(0).place(est, 0.0, 0.0, 0.0).is_none()); diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index aae91be1ee..9e2362d9d1 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -24,9 +24,15 @@ fn pushes_in(para: &ParaStyle) -> bool { /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; +/// Whether every line of area type in `regions` is as wide however tall it is and wherever it is set: +/// rectangular cells without text wrap. +fn constant_width(regions: Option<&[Region]>) -> bool { + regions.is_some_and(|r| r.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty())) +} + /// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged, -/// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the -/// greedy single-line composer. With `continued`, the paragraph continues from the frame before, +/// in any frame shape, with or without text wrap); `None` falls back to the greedy single-line +/// composer. With `continued`, the paragraph continues from the frame before, /// and its first composed line is not its first line. `line_end` is how much narrower a line /// ending with each glyph is set ([`ParagraphData::line_end`]). #[allow(clippy::too_many_arguments)] @@ -66,14 +72,11 @@ fn compose_para( let m = Metrics::of(&sg[0]); // Line widths are independent of the breaks when every line has the same metrics, or when // a line's width does not depend on how far down the frame it is set: rectangular cells - // without text wrap. Then a paragraph whose characters differ in line metrics (type size, - // font, leading, vertical scale, an inline graphic taller than the text) is composed too. In - // other shapes, or with text wrap, a taller line can move the lines below it to where the - // frame is narrower, so such a paragraph is broken line by line. - let plain = pen.regions.is_some_and(|r| r.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty())); - if !plain && sg.iter().any(|g| Metrics::of(g) != m) { - return None; - } + // without text wrap. In other shapes, or with text wrap, a paragraph whose characters differ + // in line metrics (type size, font, leading, vertical scale, an inline graphic taller than + // the text) can have a taller line move the lines below it to where the frame is narrower: + // each line the composer tries is then placed after the lines it follows (`shaped::Shaped`). + let shaped = !constant_width(pen.regions) && sg.iter().any(|g| Metrics::of(g) != m); let total: f64 = sg.iter().map(|g| g.adv).sum(); // The same rule as the line loop below: an opening bracket starting a wrapped line gives up // the space before it. @@ -86,7 +89,7 @@ fn compose_para( let mut acc = 0.0; // A line's width and placement depend on the sum of its indents alone, which the mirrored // indents of a right-to-left list ([`line_indents`]) keep. - while widths.len() < sg.len() { + while !shaped && widths.len() < sg.len() { let first = widths.is_empty(); let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed @@ -103,7 +106,11 @@ fn compose_para( break; } } - let last = *widths.last()?; + let last = match widths.last() { + Some(&last) => last, + None if shaped => 0.0, + None => return None, + }; let width = |k: usize| widths.get(k).copied().unwrap_or(last); // Lines from `uniform_from` on are interchangeable (same width). let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1); @@ -119,6 +126,14 @@ fn compose_para( let (strict, loose) = (1.0, f64::INFINITY); // A line's last character is set narrower, as the line loop sets it. let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); + if shaped { + // Both passes share the placements tried and one search budget. + let mirror = mirrors_indents(para, rtl, cx.vertical); + let mut measure = super::shaped::Shaped::new(sg, line_end, para, &start_credit, continued, mirror, pen); + let mut pass = |p: &Params| crate::composer::compose_with(sg, &mut measure, &start_credit, &end_credit, &cands, flex, p); + let lines = pass(¶ms(strict)); + return lines.or_else(|| pass(¶ms(loose))); + } let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p); pass(¶ms(strict)).or_else(|| pass(¶ms(loose))) } @@ -142,7 +157,7 @@ fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { /// the right and the right indent from the left: the bullet of a right-to-left list hangs at the /// right, and the lines it wraps to line up with the text after the bullet. A list level past the /// first adds to the left indent ([`ParaStyle::laid_out_left_indent`]), mirrored with it. -fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { +pub(super) fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { let start = para.laid_out_left_indent() + if first { para.first_line_indent } else { 0.0 }; if mirror { (para.right_indent, start) } else { (start, para.right_indent) } } @@ -162,6 +177,7 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg next_top: None, no_room_for: f64::INFINITY, steps_left: std::rc::Rc::new(std::cell::Cell::new(super::breaking::MAX_TOTAL_STEPS)), + search: Default::default(), }; let markers = list_markers(t, paras); for (pi, pr) in paras.iter().enumerate() { @@ -392,6 +408,12 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC } // The line's first word, as narrow as the line sets it at both ends. let word = word_width(sg, *i, line_end, para.kinsoku); + // A composed line in a frame whose lines are not all as wide is placed at its own tallest + // metrics, as the composer placed it (`shaped::Shaped`): its room is the room across its height. + let est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + Some(&(end, _)) if end > *i && !constant_width(pen.regions) => sg.get(*i..end).and_then(Metrics::max).unwrap_or(est), + _ => est, + }; // Place, break, then settle the baseline on the line's real metrics (moving on to the // next row/column if it no longer fits). loop { @@ -494,6 +516,9 @@ struct LineJustification { /// The tracking after the line's last character that its alignment leaves out of its width: /// [`LineGeometry::trailing`] for a line that isn't justified, 0 for one that is. trailing: f64, + /// The glyphs justification leaves at their natural advance, by offset from the line's first + /// glyph: on a line with a tab, those drawn up to its last tab ([`tab_fixed_glyphs`]). + fixed: Vec, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -796,10 +821,48 @@ fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option], index: u } } -// A space that takes a share of a justified line's room: not a zero-width space, which is a line -// break opportunity but not a word separator. -fn is_justified_space(g: &SGlyph) -> bool { - g.is_space() && !is_format_control(g.ch) +// The glyphs of a justified line that keep their natural advance, by offset from the line's first +// glyph: on a line with a tab of its own (not a list marker's), every glyph drawn up to and +// including the last such tab, so the tabs keep their stops and leaders. Justification spreads the +// line's room over the glyphs drawn after that tab. Empty on a line without such a tab. +fn tab_fixed_glyphs(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> Vec { + let sg = ¶graph.sg; + let Some(last) = line.drawn.iter().rposition(|&j| j >= paragraph.marker && sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0)) else { + return Vec::new(); + }; + let mut fixed = vec![false; line.body.saturating_sub(line.index)]; + for &j in line.drawn.iter().take(last + 1) { + if let Some(slot) = j.checked_sub(line.index).and_then(|k| fixed.get_mut(k)) { + *slot = true; + } + } + fixed +} + +// Does justification leave glyph `glyph` of the line starting at `index` at its natural advance +// (see [`tab_fixed_glyphs`])? +fn is_tab_fixed(fixed: &[bool], index: usize, glyph: usize) -> bool { + glyph.checked_sub(index).and_then(|k| fixed.get(k)).copied().unwrap_or(false) +} + +// The natural width of justified area type `line` with a tab, as [`emit_flow_line`] draws it from +// the line's start: its glyphs in drawing order, each tab up to its stop from where the glyphs +// drawn before it end, then the line's hyphen. +fn tabbed_natural_width(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, fixed: &[bool]) -> f64 { + let sg = ¶graph.sg; + let start = line.ax0 - line.opt_l; + let mut x = start; + for &j in &line.drawn { + let Some(g) = sg.get(j) else { continue }; + x += if g.ch == '\t' && g.len > 0 && is_tab_fixed(fixed, line.index, j) { + tab_advance(¶graph.para.tabs, line.position.x0, x + line.opt_l, sg.get(j + 1..line.trimmed).unwrap_or_default()).0 + } else if j + 1 == line.trimmed { + g.adv - line.end_trim + } else { + g.adv + }; + } + x - start + line.hyphen.as_ref().map_or(0.0, |h| h.adv) } // May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, @@ -845,7 +908,11 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin Justify::JustifyAll => (0, true), }; justify &= paragraph.regions.is_some(); - let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).count(); + // A tab keeps its stop: the glyphs drawn up to a line's last tab keep their advances, and the + // ones after it take the line's room. + let fixed = tab_fixed_glyphs(paragraph, line); + let free = |j: usize| !is_tab_fixed(&fixed, line.index, j); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j])).count(); // A gap that justification adds between two glyphs goes into the advance of the one // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a // right-to-left line comes before it in the text. @@ -855,10 +922,10 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin *slot = Some(b); } } - let cjk_gaps = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).count(); + let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg)).count(); // Single Word Justification: a justified line holding one word (no word space) is - // spread between its letters (Full Justify) or aligned. - let single_word = justify && spaces == 0 && cjk_gaps == 0; + // spread between its letters (Full Justify) or aligned. A tab separates two words. + let single_word = justify && fixed.is_empty() && spaces == 0 && cjk_gaps == 0; (align, justify) = match (single_word, para.justification.single_word) { (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), (true, SingleWordJustify::AlignLeft) => (0, false), @@ -867,12 +934,24 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin }; // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, // and the word spaces and gaps take what is left. - let kashidas = if justify && !cx.vertical && room > line.render_width + EPS { - kashida::plan(cx.text, sg, line.index..line.body, para.kashida, room - line.render_width, |byte| cx.style_at(byte).kashidas) + // On a line with a tab, the kashidas go in the text after its last tab, when that text is one + // run of characters. + let kashida_range = if fixed.is_empty() { + Some(line.index..line.body) + } else { + let start = (line.index..line.body).find(|&j| free(j)).unwrap_or(line.body); + let end = (line.index..line.body).rev().find(|&j| free(j)).map_or(start, |j| j + 1); + (start..end).all(free).then_some(start..end) + }; + // A justified line with a tab is as wide as it is drawn: in a right-to-left line its tabs are + // drawn in another order than the one the line was measured in. + let natural = if justify && !fixed.is_empty() { tabbed_natural_width(paragraph, line, &fixed) } else { line.render_width }; + let kashidas = if let Some(range) = kashida_range.filter(|r| justify && !cx.vertical && !r.is_empty() && room > natural + EPS) { + kashida::plan(cx.text, sg, range, para.kashida, room - natural, |byte| cx.style_at(byte).kashidas) } else { Vec::new() }; - let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); + let width = natural + kashidas.iter().map(|k| k.1).sum::(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); let mut squeeze = 0.0; @@ -882,13 +961,17 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { let ems: f64 = (line.index..line.body) - .filter(|&j| is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) + .filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) .map(|j| squeeze_em(&sg[j], cx.vertical)) .sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } } else if justify && (room - width).abs() > EPS { + // What justification may change of each glyph: nothing of one drawn up to the last tab. + let line_flex = || -> Vec { + (line.index..line.body).map(|j| if free(j) { flow_line_flex(j, ¶graph.flex, held, hung) } else { Flex::default() }).collect() + }; if cjk_gaps > 0 && room > width { // Spread over the CJK gaps and the word spaces alike. per_space = (room - width) / (cjk_gaps + spaces) as f64; @@ -896,18 +979,20 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin } else if spaces > 0 { // Word spacing, letter spacing and glyph scaling within their ranges, then the // word spaces alone; composed lines may shrink word spaces (never below zero). - let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); - let floor = - (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + let flex = line_flex(); + let floor = (line.index..line.body) + .filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j])) + .map(|j| sg[j].adv) + .fold(f64::MAX, f64::min); fit = fit_line(&flex, room - width, spaces, floor); } else if room > width { // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. - let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + let flex = line_flex(); fit = fit_line(&flex, room - width, 0, 0.0); let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); let rest = room - width - used; let hyphen = line.hyphen.is_some(); - let gaps = (line.index..line.body).filter(|&j| is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); + let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); if gaps > 0 && rest > EPS { per_gap = rest / gaps as f64; } @@ -939,7 +1024,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed } } /// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop @@ -1017,22 +1102,20 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut leader = ""; if glyph.ch == '\t' { (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); - } else if glyph_index < body { + } else if glyph_index < body && !is_tab_fixed(&justification.fixed, index, glyph_index) { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { - advance += justification.fit.space(&flex); + advance += justification.fit.space(&flex) + justification.per_space; } else { advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); scale = justification.fit.glyph_scale(&flex); - } - if is_justified_space(glyph) { - advance += justification.per_space; - } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { - if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); + if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { + if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { + advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); + } + } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { + advance += justification.per_gap; } - } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { - advance += justification.per_gap; } } // The kashida before a cluster in the text is drawn at the cluster's right, between diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 9303d4c0fb..6272e54d71 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -73,6 +73,52 @@ fn poly_intervals(polys: &[Vec], y: f64) -> Vec<(f64, f64)> { xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect() } +/// The part of the horizontal line at `y` closer than `r` to the segment `a`–`b`: the line's +/// intersection with the segment's two end disks and the band between them, which together make +/// a convex shape, so the result is one interval. +fn near_segment(a: Point, b: Point, y: f64, r: f64) -> Option<(f64, f64)> { + if y <= a.y.min(b.y) - r || y >= a.y.max(b.y) + r { + return None; + } + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for p in [a, b] { + let h = r * r - (y - p.y) * (y - p.y); + if h > 0.0 { + lo = lo.min(p.x - h.sqrt()); + hi = hi.max(p.x + h.sqrt()); + } + } + let d = b - a; + let len = d.hypot(); + if len > 0.0 { + let n = kurbo::Vec2::new(-d.y, d.x) * (r / len); + let band = [a + n, b + n, b - n, a - n]; + for i in 0..4 { + let (p, q) = (band[i], band[(i + 1) % 4]); + if (p.y <= y) != (q.y <= y) { + let x = p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x); + lo = lo.min(x); + hi = hi.max(x); + } + } + } + (hi > lo).then_some((lo, hi)) +} + +/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`, less the points +/// closer than `inset` to any edge of them (holes included), measured from the edge itself. +fn inset_intervals(polys: &[Vec], y: f64, inset: f64) -> Vec<(f64, f64)> { + let iv = poly_intervals(polys, y); + if inset <= 0.0 || iv.is_empty() { + return iv; + } + if !inset.is_finite() { + return vec![]; + } + let near = polys.iter().flat_map(|p| (0..p.len()).filter_map(move |i| near_segment(p[i], p[(i + 1) % p.len()], y, inset))); + subtract_intervals(&iv, &union_intervals(near.collect())) +} + /// Union of sorted-or-not intervals. fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { v.sort_by(|a, b| a.0.total_cmp(&b.0)); @@ -168,12 +214,22 @@ impl Region { /// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or /// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally. + /// On a shaped frame the intervals keep the inset from the frame's edges, measured from the + /// edge itself. Along the line, the text also keeps the inset beside each wrap object (outside + /// it, or inside it for Invert Wrap); above and below a wrap object only its offset applies. fn intervals(&self, y: f64) -> Vec<(f64, f64)> { - let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) }; + let mut iv = + if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { inset_intervals(&self.polys, y, self.inset) }; for (polys, off, invert) in self.wraps.iter() { let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] }; let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect()); - iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) }; + iv = if *invert { + let w: Vec<_> = w.into_iter().map(|(a, b)| (a + self.inset, b - self.inset)).filter(|(a, b)| b > a).collect(); + intersect_intervals(&iv, &w) + } else { + let w = union_intervals(w.into_iter().map(|(a, b)| (a - self.inset, b + self.inset)).collect()); + subtract_intervals(&iv, &w) + }; } iv } @@ -186,8 +242,10 @@ impl Region { /// Every horizontal span inside the frame (and the cell) over the band `top..bottom`, left to /// right (text wraps on both sides of an object). pub(super) fn spans(&self, top: f64, bottom: f64) -> Vec<(f64, f64)> { + // The inset from the cell's edges; `intervals` already keeps it from the frame's edges and + // beside the wrap objects. let clip = |(a, b): (f64, f64)| { - let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); + let (a, b) = (a.max(self.cell.x0 + self.inset), b.min(self.cell.x1 - self.inset)); (b > a).then_some((a, b)) }; let plain = self.polys.is_empty() || self.rect; @@ -238,3 +296,100 @@ fn the_cells_of_a_frame_share_its_polygons_and_the_wrap_shapes() { assert!(!first.polys.is_empty() && !first.wraps.is_empty()); assert!(cells.iter().all(|c| c.polys.as_ptr() == first.polys.as_ptr() && c.wraps.as_ptr() == first.wraps.as_ptr())); } + +/// A path with one closed subpath through each list of points. +#[cfg(test)] +fn closed(subpaths: &[&[(f64, f64)]]) -> BezPath { + let mut path = BezPath::new(); + for pts in subpaths { + path.move_to(pts[0]); + for &p in &pts[1..] { + path.line_to(p); + } + path.close_path(); + } + path +} + +/// The spans of every cell of `path` over the band `top..bottom`. +#[cfg(test)] +fn cell_spans(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape], top: f64, bottom: f64) -> Vec> { + Region::cells(path, opts, wrap).iter().map(|c| c.spans(top, bottom)).collect() +} + +#[cfg(test)] +fn assert_spans(got: &[(f64, f64)], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len(), "{got:?} != {want:?}"); + for (g, w) in got.iter().zip(want) { + assert!((g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6, "{got:?} != {want:?}"); + } +} + +/// On a triangle, the inset keeps the line 10 pt from the sloped edge, measured from the edge +/// itself (x + y <= 100 - 10√2 at the band's bottom), not 10 pt along the line. +#[cfg(test)] +#[test] +fn inset_on_a_shaped_frame_is_measured_from_its_sloped_edge() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + let inset = |inset| cell_spans(&triangle, &LayoutOptions { inset, ..LayoutOptions::default() }, &[], 40.0, 60.0); + assert_spans(&inset(10.0)[0], &[(10.0, 40.0 - 10.0 * std::f64::consts::SQRT_2)]); + assert_spans(&inset(0.0)[0], &[(0.0, 40.0)]); +} + +/// A rectangular frame keeps its inset from each side, and its first line starts the inset below +/// its top. +#[cfg(test)] +#[test] +fn a_rectangular_frame_keeps_its_inset_from_each_side() { + let cells = Region::cells(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1), &LayoutOptions { inset: 10.0, ..LayoutOptions::default() }, &[]); + assert!(cells[0].rect); + assert_eq!((cells[0].top(), cells[0].bottom()), (10.0, 90.0)); + assert_spans(&cells[0].spans(40.0, 60.0), &[(10.0, 90.0)]); +} + +/// The inset keeps text clear of a hole's sloped edges too. +#[cfg(test)] +#[test] +fn inset_keeps_text_clear_of_a_hole_s_sloped_edges() { + let square = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + let diamond = [(50.0, 30.0), (70.0, 50.0), (50.0, 70.0), (30.0, 50.0)]; + let spans = cell_spans(&closed(&[&square, &diamond]), &LayoutOptions { inset: 5.0, ..LayoutOptions::default() }, &[], 40.0, 44.0); + let clear = 5.0 * std::f64::consts::SQRT_2; + assert_spans(&spans[0], &[(5.0, 36.0 - clear), (64.0 + clear, 95.0)]); +} + +/// In a frame of columns, the inset is kept once from a column's inner edge and once from the +/// frame's sloped edge. +#[cfg(test)] +#[test] +fn inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + let spans = cell_spans(&triangle, &LayoutOptions { inset: 10.0, columns: 2, gutter: 0.0, ..LayoutOptions::default() }, &[], 10.0, 20.0); + assert_spans(&spans[0], &[(10.0, 40.0)]); + assert_spans(&spans[1], &[(60.0, 80.0 - 10.0 * std::f64::consts::SQRT_2)]); +} + +/// An inset wider than a shaped frame leaves no room for text, whatever its size. +#[cfg(test)] +#[test] +fn an_inset_wider_than_a_shaped_frame_leaves_no_room() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + for inset in [40.0, 1e300, f64::INFINITY] { + let cells = Region::cells(&triangle, &LayoutOptions { inset, ..LayoutOptions::default() }, &[]); + assert!(cells[0].spans(40.0, 60.0).is_empty() && cells[0].span(40.0, 60.0).is_none(), "inset {inset}"); + } +} + +/// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, +/// inside it. +#[cfg(test)] +#[test] +fn text_keeps_the_inset_beside_a_wrap_object_along_the_line() { + let frame = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1); + let shape = PathData::from_bezpath(&Rect::new(40.0, 40.0, 60.0, 60.0).to_path(0.1)); + let opts = LayoutOptions { inset: 5.0, ..LayoutOptions::default() }; + for (invert, want) in [(false, vec![(5.0, 35.0), (65.0, 95.0)]), (true, vec![(45.0, 55.0)])] { + let wrap = vectorcraft_doc::WrapShape { path: shape.clone(), wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert } }; + assert_spans(&cell_spans(&frame, &opts, &[wrap], 45.0, 55.0)[0], &want); + } +} diff --git a/crates/text/src/layout/shaped.rs b/crates/text/src/layout/shaped.rs new file mode 100644 index 0000000000..72f6ed8733 --- /dev/null +++ b/crates/text/src/layout/shaped.rs @@ -0,0 +1,273 @@ +//! Every-line composition where a line's width depends on the lines above it: area type in a +//! shape other than a rectangle, or with text wrap, whose characters differ in line metrics (type +//! size, font, leading, vertical scale, an inline graphic taller than the text). A taller line +//! moves the lines below it down, to where the frame may be narrower or a wrap object splits the +//! line. Each line the composer tries is placed from where the lines above it left the pen, at its +//! own tallest metrics, as the line loop in [`super::flow`] places a composed line: the span it +//! gets is the room across its whole height. + +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; + +use super::breaking::{Metrics, Pen, word_width}; +use super::flow::line_indents; +use super::{ParaStyle, SGlyph}; +use crate::composer::{Exhausted, Measure}; + +/// The most lines the composer tries to place for one paragraph before it stops and the paragraph +/// is broken line by line. +const MAX_PROBES: usize = 1_000_000; + +/// The most baseline steps placing those lines takes in all (see [`super::breaking::MAX_TOTAL_STEPS`]). +const MAX_STEPS: usize = 2_000_000; + +/// The most lines the composer tries to place for all the paragraphs of one frame's layout. +const MAX_LAYOUT_PROBES: usize = 2 * MAX_PROBES; + +/// The most baseline steps placing them takes over all the paragraphs of one frame's layout. +const MAX_LAYOUT_STEPS: usize = 2 * MAX_STEPS; + +/// What is left of the composer's search over one frame's layout: the lines it may still try to +/// place and the baseline steps placing them may still take. Every paragraph's search takes what +/// it spends from it, and stops when it runs out, or at its own limits of [`MAX_PROBES`] and +/// [`MAX_STEPS`]. It is separate from the layout's own steps ([`Pen::steps_left`]), which the +/// search leaves to the line loop. +pub(super) struct Budget { + probes: Cell, + steps: Cell, +} + +impl Budget { + /// A budget of `probes` lines to try and `steps` baseline steps. + pub(super) fn new(probes: usize, steps: usize) -> Self { + Self { probes: Cell::new(probes), steps: Cell::new(steps) } + } + + /// The lines left to try and the baseline steps left. + #[cfg(test)] + pub(super) fn left(&self) -> (usize, usize) { + (self.probes.get(), self.steps.get()) + } +} + +impl Default for Budget { + fn default() -> Self { + Self::new(MAX_LAYOUT_PROBES, MAX_LAYOUT_STEPS) + } +} + +/// Where a line starts: glyph `pos`, with the pen where the lines above it left it. +struct Start<'r> { + pos: usize, + /// The paragraph's first line (it takes the first-line indent). + first: bool, + pen: Pen<'r>, + /// The line placed at each of the metrics it was tried with: its width and the pen after it, + /// or `None` when the frame has no room for it. + placed: Vec<(Metrics, Option<(f64, Pen<'r>)>)>, + /// The metrics of glyphs `pos..to`, measured so far. + to: usize, + m: Option, +} + +/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line, +/// and the pen's cell, baselines, pending space, narrowest word without room and further spans. +type Key = (usize, bool, usize, Option, Option, u64, u64, Vec<[u64; 3]>); + +fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key { + ( + pos, + first, + pen.ri, + pen.prev.map(f64::to_bits), + pen.next_top.map(f64::to_bits), + pen.pending.to_bits(), + pen.no_room_for.to_bits(), + pen.queued.iter().map(|&(b, x0, x1)| [b.to_bits(), x0.to_bits(), x1.to_bits()]).collect(), + ) +} + +/// What placing a line reads from the paragraph. +struct Para<'a> { + sg: &'a [SGlyph], + /// How much narrower a line ending with each glyph is set (`ParagraphData::line_end`). + line_end: &'a [f64], + para: &'a ParaStyle, + /// The width a line starting with each glyph gives back (Mojikumi's flush opening bracket). + start_credit: &'a dyn Fn(usize) -> f64, + mirror: bool, + /// The baseline steps left to the search. + steps: Rc>, +} + +/// The measures of a paragraph's lines in a frame where they depend on the lines above. +pub(super) struct Shaped<'a, 'r> { + para: Para<'a>, + continued: bool, + pen: Pen<'r>, + starts: Vec>, + known: HashMap, + /// The lines tried so far, and the most this search may try. + probes: usize, + max_probes: usize, + /// The baseline steps this search started with. + max_steps: usize, + /// The layout's budget, which takes what the search spent when it ends. + budget: Rc, + /// The tallest metrics of glyphs `k..` for each `k`, `None` past the last glyph. + rest: Vec>, +} + +impl<'a, 'r> Shaped<'a, 'r> { + /// The measures of the lines of glyphs `sg` of paragraph `para`, set from `pen`. `line_end` + /// and `start_credit` are how much narrower a line ending with each glyph is set and how much + /// a line starting with it gives back; `continued`: the paragraph's first line is in the + /// frame before; `mirror`: its indents are mirrored. The search places its lines with a copy + /// of `pen` and takes its baseline steps from the layout's search budget ([`Pen::search`]), + /// so the frame's layout keeps its own steps. + pub(super) fn new( + sg: &'a [SGlyph], + line_end: &'a [f64], + para: &'a ParaStyle, + start_credit: &'a dyn Fn(usize) -> f64, + continued: bool, + mirror: bool, + pen: &Pen<'r>, + ) -> Self { + let mut pen = pen.clone(); + let budget = Rc::clone(&pen.search); + let max_steps = budget.steps.get().min(pen.steps_left.get()).min(MAX_STEPS); + let steps = Rc::new(Cell::new(max_steps)); + pen.steps_left = Rc::clone(&steps); + let mut rest = vec![None; sg.len() + 1]; + for (k, g) in sg.iter().enumerate().rev() { + let m = Metrics::of(g); + rest[k] = Some(rest[k + 1].map_or(m, |r: Metrics| r.union(m))); + } + Self { + para: Para { sg, line_end, para, start_credit, mirror, steps }, + continued, + pen, + starts: vec![], + known: HashMap::new(), + probes: 0, + max_probes: budget.probes.get().min(MAX_PROBES), + max_steps, + budget, + rest, + } + } + + /// The start of a line at glyph `pos` with `pen`: an existing one when its placement is the same. + fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>) -> usize { + let k = key(pos, first, &pen); + if let Some(&id) = self.known.get(&k) { + return id; + } + self.starts.push(Start { pos, first, pen, placed: vec![], to: pos, m: None }); + let id = self.starts.len() - 1; + self.known.insert(k, id); + id + } +} + +impl Measure for Shaped<'_, '_> { + type State = usize; + + fn first(&mut self) -> usize { + let pen = self.pen.clone(); + self.start(0, !self.continued, pen) + } + + fn line(&mut self, &id: &usize, pos: usize, end: usize) -> Result, Exhausted> { + if self.probes >= self.max_probes { + return Err(Exhausted); + } + self.probes += 1; + let Some(start) = self.starts.get_mut(id) else { return Ok(None) }; + let Some((width, pen)) = line(&self.para, start, pos, end)? else { return Ok(None) }; + Ok(Some((width, self.start(end, false, pen)))) + } + + /// A longer line is placed as this one is, and gets the same room, unless a glyph after it + /// changes the line's tallest metrics. + fn may_widen(&mut self, &id: &usize, pos: usize, end: usize) -> bool { + let Some(start) = self.starts.get(id) else { return false }; + let m = if start.pos == pos && start.to == end { start.m } else { self.para.sg.get(pos..end).and_then(Metrics::max) }; + match (m, self.rest.get(end).copied().flatten()) { + (Some(m), Some(rest)) => m.union(rest) != m, + _ => false, + } + } +} + +/// The search's lines tried and baseline steps spent come off the layout's budget, so the +/// paragraphs of one frame share it. +impl Drop for Shaped<'_, '_> { + fn drop(&mut self) { + let spent = self.max_steps.saturating_sub(self.para.steps.get()); + self.budget.steps.set(self.budget.steps.get().saturating_sub(spent)); + self.budget.probes.set(self.budget.probes.get().saturating_sub(self.probes)); + } +} + +/// The width of the line of glyphs `pos..end` from `start`, and the pen after it; `None` when +/// the frame has no room for it. +fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result)>, Exhausted> { + if start.pos != pos || end <= pos { + return Ok(None); + } + // The line's tallest metrics, measured on from the glyphs already measured. + if end < start.to { + start.m = p.sg.get(pos..end).and_then(Metrics::max); + start.to = end; + } else if end > start.to { + let more = p.sg.get(start.to..end).and_then(Metrics::max); + start.m = match (start.m, more) { + (Some(m), Some(more)) => Some(m.union(more)), + (m, more) => m.or(more), + }; + start.to = end; + } + let Some(m) = start.m else { return Ok(None) }; + if let Some((_, placed)) = start.placed.iter().find(|(at, _)| *at == m) { + return Ok(placed.clone()); + } + let placed = place(p, start, m)?; + start.placed.push((m, placed.clone())); + Ok(placed) +} + +/// The line from `start` placed with metrics `m`: its width and the pen after it, as the line loop +/// places and settles it; `None` when no frame cell has room for it. +fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result)>, Exhausted> { + let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror); + let word = word_width(p.sg, start.pos, p.line_end, p.para.kinsoku) - (p.start_credit)(start.pos); + let mut pen = start.pen.clone(); + loop { + match pen.place(m, ind_l, ind_r, word) { + Some((baseline, x0, x1, _)) if baseline.is_finite() && (x1 - x0).is_finite() => { + if baseline + m.desc > pen.bottom() + 0.01 { + // A further span of the line above, too low for this line: the next row or column. + pen.ri += 1; + pen.prev = None; + pen.pending = 0.0; + pen.queued.clear(); + continue; + } + pen.pending = 0.0; + pen.settled(baseline, m); + // Further spans of this line band share the settled baseline. + for queued in &mut pen.queued { + queued.0 = baseline; + } + return Ok(Some((x1 - x0 - ind_l - ind_r, pen))); + } + Some(_) => return Ok(None), + // Out of steps: the frame may still have room further down. + None if p.steps.get() == 0 => return Err(Exhausted), + None => return Ok(None), + } + } +} diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 110c257c46..9d54e6a1cc 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -533,6 +533,241 @@ fn composition_counts_the_narrower_line_end() { assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); } +/// A line measure for `compose_with`: every line is `width` wide, its state is the glyph it starts +/// at, and the search stops once `calls` lines were measured. +struct Measured { + width: f64, + calls: usize, + /// A line from a start past this one finds the frame full. + full_after: usize, +} + +impl crate::composer::Measure for Measured { + type State = usize; + fn first(&mut self) -> usize { + 0 + } + fn line(&mut self, &start: &usize, _: usize, end: usize) -> Result, crate::composer::Exhausted> { + if self.calls == 0 { + return Err(crate::composer::Exhausted); + } + self.calls -= 1; + Ok((start <= self.full_after).then_some((self.width, end))) + } +} + +/// The every-line composer with a line measure that follows the lines above: it breaks as with +/// fixed widths when the widths are the same, finds no composition when the frame is full before +/// the paragraph ends, and gives up when the measure's search stops. +#[test] +fn composition_with_a_measure_from_the_lines_above() { + let text = "가나다 라마바"; + let glyphs = korean(text, false); + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let with = |calls: usize, full_after: usize| { + let mut measure = Measured { width: 50.0, calls, full_after }; + crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms) + }; + let fixed = compose(&glyphs, &|_| 50.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms); + assert_eq!(fixed, Some(vec![(4, false), (7, false)])); + assert_eq!(with(usize::MAX, usize::MAX), fixed); + assert_eq!(with(usize::MAX, 0), None, "no room for a second line"); + assert_eq!(with(1, usize::MAX), None, "the search stopped"); +} + +/// A line measure for `compose_with` whose lines are `narrow` wide, and `wide` wide when they reach +/// past glyph `from` (a line made taller there, and set lower in a frame that widens). +struct Widening { + narrow: f64, + wide: f64, + from: usize, +} + +impl crate::composer::Measure for Widening { + type State = usize; + fn first(&mut self) -> usize { + 0 + } + fn line(&mut self, _: &usize, _: usize, end: usize) -> Result, crate::composer::Exhausted> { + Ok(Some((if end > self.from { self.wide } else { self.narrow }, end))) + } + fn may_widen(&mut self, _: &usize, _: usize, end: usize) -> bool { + end <= self.from + } +} + +/// A line too long for its room does not end the composer's search from its start while a longer +/// line may get more room: the longer line is tried and set. +#[test] +fn composition_tries_a_longer_line_that_gets_more_room() { + let text = "가나다 라마바"; + let glyphs = korean(text, false); + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + // "가나다" is 30 wide and does not fit 25; the whole paragraph reaches past glyph 4 and fits 100. + let mut measure = Widening { narrow: 25.0, wide: 100.0, from: 4 }; + let lines = crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms); + assert_eq!(lines, Some(vec![(7, false)])); +} + +/// A line measure for `compose_with` whose lines can't be set until they reach past glyph `from` +/// (a line made taller there steps down the frame by its own leading, to a band the shorter +/// line's steps miss), and are `wide` wide from there. +struct Unplaced { + wide: f64, + from: usize, +} + +impl crate::composer::Measure for Unplaced { + type State = usize; + fn first(&mut self) -> usize { + 0 + } + fn line(&mut self, _: &usize, _: usize, end: usize) -> Result, crate::composer::Exhausted> { + Ok((end > self.from).then_some((self.wide, end))) + } + fn may_widen(&mut self, _: &usize, _: usize, end: usize) -> bool { + end <= self.from + } +} + +/// A line that can't be set does not end the composer's search from its start while a longer line +/// may be placed elsewhere: the longer line is tried and set. +#[test] +fn composition_tries_a_longer_line_after_one_that_cannot_be_set() { + let text = "가나다 라마바"; + let glyphs = korean(text, false); + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + // "가나다" finds no room; the whole paragraph reaches past glyph 4 and fits 100. + let mut measure = Unplaced { wide: 100.0, from: 4 }; + let lines = crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms); + assert_eq!(lines, Some(vec![(7, false)])); +} + +/// A further span beside a wrap object goes to the line set in it with the room across that line's +/// own height. A line taller than the one before it on the baseline keeps clear of an object the +/// shorter line did not reach, and moves down when the span has no room for it. +#[test] +fn a_further_span_is_measured_across_the_height_of_its_line() { + use std::cell::Cell; + use std::rc::Rc; + let frame = Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1); + let wrap = |r: Rect| vectorcraft_doc::WrapShape { + path: PathData::from_bezpath(&r.to_path(0.1)), + wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert: false }, + }; + let small = breaking::Metrics { asc: 10.0, desc: 3.0, lead: 14.0, cap: 7.0, xh: 5.0, top: 10.0 }; + let tall = breaking::Metrics { asc: 30.0, desc: 9.0, lead: 42.0, cap: 21.0, xh: 15.0, top: 30.0 }; + // An object down the middle of the frame splits every line in two, and `above` lies right of + // it over y 20 to 40. A small line after a baseline of 50 is set at 64, over y 54 to 67, clear + // of `above`; a tall line in the span right of it on that baseline covers y 34 to 73. + // The tall line starts with a word `word` wide. + let set = |above: Rect, word: f64| { + let regions = region::Region::cells(&frame, &LayoutOptions::default(), &[wrap(Rect::new(180.0, 0.0, 220.0, 400.0)), wrap(above)]); + let mut pen = breaking::Pen { + regions: Some(®ions), + ri: 0, + prev: Some(50.0), + pending: 0.0, + fb: FirstBaseline::Ascent, + fb_min: 0.0, + queued: vec![], + model: LeadingModel::RomanBaseline, + next_top: None, + no_room_for: f64::INFINITY, + steps_left: Rc::new(Cell::new(breaking::MAX_TOTAL_STEPS)), + search: Default::default(), + }; + let left = pen.place(small, 0.0, 0.0, 0.0); + pen.settled(64.0, small); + let right = pen.place(tall, 0.0, 0.0, word); + (left, right) + }; + let near = |got: Option<(f64, f64, f64, bool)>, want: (f64, f64, f64, bool)| { + got.is_some_and(|g| (g.0 - want.0).abs() < 0.01 && (g.1 - want.1).abs() < 0.01 && (g.2 - want.2).abs() < 0.01 && g.3 == want.3) + }; + // `above` covers the right end of the span: the tall line gets the part left of it. + let (left, right) = set(Rect::new(300.0, 20.0, 400.0, 40.0), 0.0); + assert!(near(left, (64.0, 0.0, 180.0, false)), "{left:?}"); + assert!(near(right, (64.0, 220.0, 300.0, true)), "{right:?}"); + // That part is 80 wide: a tall line that starts with a wider word goes on the next baseline. + let (_, right) = set(Rect::new(300.0, 20.0, 400.0, 40.0), 100.0); + assert!(near(right, (106.0, 0.0, 180.0, false)), "{right:?}"); + // `above` covers the whole span: the tall line goes on the next baseline, left of the object. + let (left, right) = set(Rect::new(220.0, 20.0, 400.0, 40.0), 0.0); + assert!(near(left, (64.0, 0.0, 180.0, false)), "{left:?}"); + assert!(near(right, (106.0, 0.0, 180.0, false)), "{right:?}"); + // `above` lies below the tall line: the span is as wide for it as for the small line, and it + // takes the span whatever its first word, as a further span always did. + let (_, right) = set(Rect::new(300.0, 300.0, 400.0, 320.0), 200.0); + assert!(near(right, (64.0, 220.0, 400.0, true)), "{right:?}"); +} + +/// The every-line composer's searches for the paragraphs of one frame share one budget: each takes +/// the lines it tried and the baseline steps it spent from it, the layout's own steps stay as they +/// were, and once the budget runs out the next paragraph's search stops (it is broken line by line). +#[test] +fn shaped_composition_searches_share_the_frame_budget() { + use super::shaped::{Budget, Shaped}; + use std::cell::Cell; + use std::rc::Rc; + let text = "aa bb cc dd ee ff gg hh ii jj"; + let mut glyphs = full_width(text); + glyphs[3].ascent = 30.0; + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + // 60 pt wide down to 40 pt, and 30 pt wide below. + let frame = BezPath::from_vec(vec![ + PathEl::MoveTo((0.0, 0.0).into()), + PathEl::LineTo((60.0, 0.0).into()), + PathEl::LineTo((60.0, 40.0).into()), + PathEl::LineTo((30.0, 40.0).into()), + PathEl::LineTo((30.0, 400.0).into()), + PathEl::LineTo((0.0, 400.0).into()), + PathEl::ClosePath, + ]); + let regions = region::Region::cells(&frame, &LayoutOptions::default(), &[]); + let para = ParaStyle::default(); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let pen = |search: Rc| breaking::Pen { + regions: Some(®ions), + ri: 0, + prev: None, + pending: 0.0, + fb: FirstBaseline::Ascent, + fb_min: 0.0, + queued: vec![], + model: LeadingModel::default(), + next_top: None, + no_room_for: f64::INFINITY, + steps_left: Rc::new(Cell::new(breaking::MAX_TOTAL_STEPS)), + search, + }; + let compose = |pen: &breaking::Pen<'_>| { + let mut measure = Shaped::new(&glyphs, &[], ¶, &|_| 0.0, false, false, pen); + crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms) + }; + // One paragraph's search, with room to spare. + let one = pen(Rc::new(Budget::default())); + let before = one.search.left(); + let lines = compose(&one); + assert!(lines.is_some()); + let after = one.search.left(); + let (probes, steps) = (before.0 - after.0, before.1 - after.1); + assert!(probes > 0 && steps > 0, "{probes} lines tried, {steps} steps"); + assert_eq!(one.steps_left.get(), breaking::MAX_TOTAL_STEPS, "the layout's own steps"); + // A budget of three such searches: three paragraphs are composed, the fourth is not. + let three = pen(Rc::new(Budget::new(3 * probes, 3 * steps))); + for k in 0..3 { + assert_eq!(compose(&three), lines, "paragraph {k}"); + } + assert_eq!(three.search.left(), (0, 0)); + assert_eq!(compose(&three), None, "the budget ran out"); + assert_eq!(three.search.left(), (0, 0)); + assert_eq!(three.steps_left.get(), breaking::MAX_TOTAL_STEPS, "the layout's own steps"); +} + /// The glyphs of `text`, each 10 pt wide, with Korean words kept whole unless `anywhere` (Korean /// Line Break Anywhere on). fn korean(text: &str, anywhere: bool) -> Vec { diff --git a/crates/text/src/tests/combos.rs b/crates/text/src/tests/combos.rs index 4145c8937f..0146871265 100644 --- a/crates/text/src/tests/combos.rs +++ b/crates/text/src/tests/combos.rs @@ -334,14 +334,14 @@ fn composer_x_mixed_sizes() { assert_eq!(x.line, 0); assert!(l.lines[0].ascent > l.lines[1].ascent + 1.0, "{:?}", l.lines); // A rectangle that wraps around an object, and a frame that isn't a rectangle (a corner cut), - // both far below the text, break mixed sizes line by line and still break uniform sizes with - // the every-line composer. + // both far below the text, break mixed sizes and uniform sizes alike with the every-line + // composer. let below = PathData::from_bezpath(&Rect::new(0.0, 180.0, 20.0, 190.0).to_path(0.1)); - for (x, want) in [(&wide, every), (&big, greedy)] { + for x in [&wide, &big] { let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, w, 200.0)); t.wrap = vec![vectorcraft_doc::WrapShape { path: below.clone(), wrap: Default::default() }]; t.para.composer = Composer::EveryLine; - assert_eq!(line_texts(&t, &layout(db(), &t)), want, "wrap, {}", x.size); + assert_eq!(line_texts(&t, &layout(db(), &t)), every, "wrap, {}", x.size); } let cut = kurbo::BezPath::from_vec(vec![ kurbo::PathEl::MoveTo((0.0, 0.0).into()), @@ -351,14 +351,176 @@ fn composer_x_mixed_sizes() { kurbo::PathEl::LineTo((0.0, 200.0).into()), kurbo::PathEl::ClosePath, ]); - for (x, want) in [(&wide, every), (&big, greedy)] { + for x in [&wide, &big] { let mut t = area(runs(x.clone()), Rect::ZERO); t.kind = TextKind::Area { frame: PathData::from_bezpath(&cut) }; t.para.composer = Composer::EveryLine; - assert_eq!(line_texts(&t, &layout(db(), &t)), want, "cut corner, {}", x.size); + assert_eq!(line_texts(&t, &layout(db(), &t)), every, "cut corner, {}", x.size); } } +/// Text with the word `X` set in `x` and the rest in 12 pt. +fn with_x(x: CharStyle) -> Vec { + let st = sans(12.0); + vec![ + run("Destroy all creatures with toughness ", st.clone()), + run("X", x), + run(" or less. Then draw a card for each creature destroyed this way.", st), + ] +} + +/// The width the text of [`with_x`] is set in. +fn x_width() -> f64 { + natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0 +} + +#[test] +fn composer_x_mixed_sizes_in_a_stepped_frame() { + // A frame as wide as the first line down to 40 pt and half as wide below. A 36 pt word makes + // the line it is on reach below the step, where the frame is narrow: the every-line composer + // breaks the paragraph with each line's measure where its height sets it, not as the + // single-line composer does, and every line keeps within its room. + let w = x_width(); + let set = |x: CharStyle, composer: Composer| { + let mut t = stepped(w, w * 0.5, 40.0, 300.0); + t.runs = with_x(x); + t.para.composer = composer; + let l = layout(db(), &t); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{composer:?}: {:?}", l.lines); + (line_texts(&t, &l), l) + }; + let (every, l) = set(sans(36.0), Composer::EveryLine); + assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each", "creature destroyed", "this way."]); + // The 36 pt line reaches below the step and is set at the narrow measure. + let tall = &l.lines[1]; + assert!(tall.ascent > 30.0 && tall.baseline + tall.descent > 40.0 && tall.avail.1 < w * 0.5 + 0.01, "{:?}", l.lines); + let (greedy, _) = set(sans(36.0), Composer::SingleLine); + assert_ne!(every, greedy); + // Uniform sizes keep the breaks they had. + let (uniform, _) = set(sans(12.0), Composer::EveryLine); + assert_eq!(uniform, ["Destroy all creatures with toughness X or", "less. Then draw a card for each creature", "destroyed this way."]); +} + +#[test] +fn composer_x_mixed_sizes_around_text_wrap() { + // A wrap object across the middle of the frame from 40 pt down splits the lines beside it in + // two. The line of the 36 pt word, which starts with a 12 pt word, falls beside it, and the + // every-line composer measures each of the two spans across the line's whole height: no + // glyph runs into the object. + let w = x_width(); + let object = Rect::new(w * 0.4, 40.0, w * 0.6, 70.0); + let set = |composer: Composer| { + let mut runs = with_x(sans(36.0)); + // A no-break space keeps the 36 pt word on the line of the 12 pt word before it. + runs[0] = run("Destroy all creatures with toughness\u{a0}", sans(12.0)); + let mut t = area(runs, Rect::new(0.0, 0.0, w, 300.0)); + t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }]; + t.para.composer = composer; + let l = layout(db(), &t); + (line_texts(&t, &l), l, t) + }; + let (every, l, t) = set(Composer::EveryLine); + assert_eq!( + every, + ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature", "destroyed this", "way."] + ); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{:?}", l.lines); + // Two spans share each baseline beside the object. + assert_eq!(l.lines[1].baseline, l.lines[2].baseline); + assert!(l.lines[1].ascent > 30.0 && l.lines[2].avail.0 > object.x1, "{:?}", l.lines); + let text = t.plain_text(); + let hits = l.glyphs.iter().filter(|g| { + let li = &l.lines[g.line]; + let across = li.baseline - li.ascent < object.y1 && li.baseline + li.descent > object.y0; + across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0 + }); + assert_eq!(hits.count(), 0, "{:?}", l.lines); + let (greedy, _, _) = set(Composer::SingleLine); + assert_ne!(every, greedy); +} + +#[test] +fn composer_x_mixed_sizes_in_a_frame_that_widens() { + // A frame as wide as "Destroy all" down to 20 pt and as wide as the first line of + // `composer_x_mixed_sizes_in_a_stepped_frame` below. The second line is too long for the narrow + // part until it takes in the 48 pt word, which sets it lower, where the frame is wide: the + // every-line composer tries that longer line and sets it there. + let w = x_width(); + let narrow = natural_width("Destroy all", 12.0) + 2.0; + let set = |composer: Composer| { + let mut t = stepped(narrow, w, 20.0, 300.0); + t.runs = with_x(sans(48.0)); + t.para.composer = composer; + let l = layout(db(), &t); + assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{composer:?}: {:?}", l.lines); + (line_texts(&t, &l), l) + }; + let (every, l) = set(Composer::EveryLine); + assert_eq!(every, ["Destroy all", "creatures with toughness X or less.", "Then draw a card for each creature", "destroyed this way."]); + let tall = &l.lines[1]; + assert!(tall.ascent > 40.0 && tall.baseline - tall.ascent > 20.0 && tall.avail.1 > narrow + 1.0, "{:?}", l.lines); + let (greedy, _) = set(Composer::SingleLine); + assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]); +} + +#[test] +fn composer_x_mixed_sizes_overflow_a_shaped_frame() { + // A stepped frame too short for the paragraph: the lines that fit are set, in order, each once + // and within its room, and the frame overflows. + let w = x_width(); + let mut t = stepped(w, w * 0.5, 40.0, 75.0); + t.runs = with_x(sans(36.0)); + t.para.composer = Composer::EveryLine; + let l = layout(db(), &t); + assert!(l.overflow && l.lines.len() >= 2, "{:?}", l.lines); + assert_eq!(l.lines[0].start, 0); + assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start && p[0].start < p[0].end), "{:?}", l.lines); + assert!(past_their_room(&t, &l).is_empty(), "{:?}", l.lines); + assert!(l.lines.iter().all(|li| li.baseline + li.descent <= 75.0 + 0.01), "{:?}", l.lines); +} + +#[test] +fn composer_x_mixed_sizes_long_paragraph_in_a_shaped_frame() { + // A long paragraph of mixed sizes in a stepped frame is laid out whole, its lines in order and + // each within its room, whether the composer's search finishes or stops at its limits. + let w = x_width(); + let mut t = stepped(w, w * 0.5, 40.0, 100_000.0); + t.runs = (0..150).flat_map(|_| with_x(sans(36.0))).collect(); + t.para.composer = Composer::EveryLine; + let l = layout(db(), &t); + assert!(!l.overflow, "{} lines", l.lines.len()); + assert_eq!(l.lines.last().map(|li| li.end), Some(t.plain_text().len())); + assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start), "lines out of order"); + assert!(past_their_room(&t, &l).is_empty()); +} + +/// A frame `w` wide down to `y`, and `narrow` wide below it. +fn stepped(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject { + let frame = kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((0.0, 0.0).into()), + kurbo::PathEl::LineTo((w, 0.0).into()), + kurbo::PathEl::LineTo((w, y).into()), + kurbo::PathEl::LineTo((narrow, y).into()), + kurbo::PathEl::LineTo((narrow, bottom).into()), + kurbo::PathEl::LineTo((0.0, bottom).into()), + kurbo::PathEl::ClosePath, + ]); + let mut t = area(vec![], Rect::ZERO); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t +} + +/// The lines of `l` whose glyphs reach past either end of the room they were set in. +fn past_their_room(t: &TextObject, l: &TextLayout) -> Vec { + let text = t.plain_text(); + let past = |i: usize| { + let (a, b) = l.lines[i].avail; + let mut glyphs = l.glyphs.iter().filter(|g| g.line == i && !text[g.byte..].starts_with(char::is_whitespace)); + glyphs.any(|g| g.origin.x < a - 0.01 || g.origin.x + g.advance > b + 0.01) + }; + (0..l.lines.len()).filter(|&i| past(i)).collect() +} + #[test] fn composer_x_mixed_line_metrics() { // In a rectangle the every-line composer also breaks a paragraph whose characters differ in diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index dd80eed25b..8212a652e3 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -4,7 +4,7 @@ use crate::*; use kurbo::{Affine, Shape}; use vectorcraft_doc::text::Kashida; -use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind, TextRun}; +use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TabAlign, TabStop, TextKind, TextRun}; use vectorcraft_geom::PathData; fn db() -> &'static FontDb { @@ -412,6 +412,146 @@ fn single_word_lines_follow_single_word_justification() { assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}"); } +/// Word spacing at 100% with no range: the line's room goes on its word spaces alone. +fn no_ranges() -> JustificationOptions { + JustificationOptions { word_spacing: [100.0, 100.0, 100.0], ..JustificationOptions::default() } +} + +/// `text` in a 300 pt wide frame with left tab stops at `stops` (a leader of `leader` on each). +fn tabbed(text: &str, stops: &[f64], leader: &str, justify: Justify, j: JustificationOptions) -> TextLayout { + let mut t = area(text, 300.0, justify, j); + t.para.tabs = stops.iter().map(|&position| TabStop { position, align: TabAlign::Left, leader: leader.to_string(), align_on: '.' }).collect(); + layout(db(), &t) +} + +/// Where the glyph of the character at byte `b` ends. +fn end(l: &TextLayout, b: usize) -> f64 { + at(l, b).origin.x + at(l, b).advance +} + +/// A justified line with a tab: the text before its last tab keeps its spacing and the tab its +/// stop, and the word spaces after the tab take the line's room, so the line ends on the far edge. +#[test] +fn a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge() { + // A (0), A (1), space (2), B (3), B (4), tab (5), C (6), C (7), space (8), D (9), D (10); a + // soft return ends the line, so Justify Left justifies it. + let text = "AA BB\tCC DD\u{2028}x"; + for j in [no_ranges(), JustificationOptions::default()] { + let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone()); + let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j); + for b in 0..5 { + assert!((at(&l, b).origin.x - at(&natural, b).origin.x).abs() < 1e-6, "byte {b} stays where it was"); + assert!((at(&l, b).advance - at(&natural, b).advance).abs() < 1e-6, "byte {b} keeps its advance"); + } + assert!((end(&l, 5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 5)); + assert!((end(&l, 10) - 300.0).abs() < 1e-6, "the last word ends on the edge: {}", end(&l, 10)); + assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "the line's record ends there too: {}", l.lines[0].x1); + assert!(at(&l, 8).advance > at(&natural, 8).advance + 1.0, "the word space after the tab takes the room"); + } +} + +/// A leader stays inside its tab's gap on a justified line. +#[test] +fn a_justified_tab_keeps_its_leader_inside_its_gap() { + let l = tabbed("AA BB\tCC DD", &[150.0], ".", Justify::JustifyAll, no_ranges()); + let tab = at(&l, 5); + let dots = tab.outline.bounding_box(); + assert!(!tab.outline.elements().is_empty(), "the leader is drawn"); + assert!(dots.x0 >= tab.origin.x - 1e-6 && dots.x1 <= 150.0 + 1e-6, "{dots:?} inside {}..150", tab.origin.x); + assert!((end(&l, 10) - 300.0).abs() < 1e-6, "{}", end(&l, 10)); +} + +/// Two words with a tab between them and no word space: the word after the tab is spread +/// between its letters to the edge, whatever Single Word Justification says, since a tab +/// separates two words. +#[test] +fn a_justified_word_tab_word_line_reaches_the_edge() { + // A (0), A (1), tab (2), B (3), B (4). + let text = "AA\tBB\u{2028}x"; + for single_word in [SingleWordJustify::FullJustify, SingleWordJustify::AlignLeft, SingleWordJustify::AlignRight] { + let j = JustificationOptions { single_word, ..JustificationOptions::default() }; + let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone()); + let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j); + assert!(at(&l, 0).origin.x.abs() < 1e-6, "{single_word:?}: starts at the left edge: {}", at(&l, 0).origin.x); + assert!((at(&l, 0).advance - at(&natural, 0).advance).abs() < 1e-6, "{single_word:?}: the word before the tab keeps its width"); + assert!((end(&l, 2) - 150.0).abs() < 1e-6, "{single_word:?}: the tab keeps its stop: {}", end(&l, 2)); + assert!((end(&l, 4) - 300.0).abs() < 1e-6, "{single_word:?}: ends on the edge: {}", end(&l, 4)); + } +} + +/// The last line of a paragraph: Justify All spreads the text after its tab to the edge, and +/// Justify Left leaves it aligned left. +#[test] +fn the_last_line_with_a_tab_fills_the_measure_only_with_justify_all() { + let text = "AA BB\tCC DD"; + let all = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges()); + assert!((end(&all, 5) - 150.0).abs() < 1e-6 && (end(&all, 10) - 300.0).abs() < 1e-6, "{} {}", end(&all, 5), end(&all, 10)); + let left = tabbed(text, &[150.0], "", Justify::JustifyLeft, no_ranges()); + let natural = tabbed(text, &[150.0], "", Justify::Left, no_ranges()); + assert!((end(&left, 10) - end(&natural, 10)).abs() < 1e-6 && end(&left, 10) < 250.0, "{}", end(&left, 10)); +} + +/// A line with two tabs: both keep their stops, the word space between them keeps its width, and +/// the text after the last tab takes the room. +#[test] +fn a_justified_line_takes_its_room_after_its_last_tab() { + // A (0), tab (1), B (2), space (3), C (4), tab (5), D (6), space (7), E (8). + let text = "A\tB C\tD E"; + let natural = tabbed(text, &[80.0, 160.0], "", Justify::Left, no_ranges()); + let l = tabbed(text, &[80.0, 160.0], "", Justify::JustifyAll, no_ranges()); + assert!((end(&l, 1) - 80.0).abs() < 1e-6 && (end(&l, 5) - 160.0).abs() < 1e-6, "{} {}", end(&l, 1), end(&l, 5)); + assert!((at(&l, 3).advance - at(&natural, 3).advance).abs() < 1e-6, "the space between the tabs keeps its width"); + assert!((end(&l, 8) - 300.0).abs() < 1e-6, "{}", end(&l, 8)); +} + +/// Vertical area type: the tab keeps its stop down the column, and the text after it reaches the +/// column's end. +#[test] +fn a_justified_vertical_line_with_a_tab_reaches_the_end_of_its_column() { + let mut t = area("AA BB\tCC DD", 100.0, Justify::JustifyAll, no_ranges()); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 300.0).to_path(0.1)) }; + t.vertical = true; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let l = layout(db(), &t); + let down = |b: usize| at(&l, b).origin.y + at(&l, b).advance; + assert!(at(&l, 0).origin.y.abs() < 1e-6, "{}", at(&l, 0).origin.y); + assert!((down(5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", down(5)); + assert!((down(10) - 300.0).abs() < 1e-6, "the last word ends at the column's end: {}", down(10)); +} + +/// Right-to-left area type: the glyphs drawn up to the last tab drawn keep their advances, the tab +/// its stop, and the text drawn after it reaches the right edge. +#[test] +fn a_justified_right_to_left_line_with_a_tab_spans_its_measure() { + // א (0), ב (2), space (4), ג (5), ד (7), tab (9), ה (10), ו (12), space (14), ז (15), ח (17). + let text = "אב גד\tהו זח"; + let natural = tabbed(text, &[150.0], "", Justify::Right, no_ranges()); + let l = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges()); + let mut drawn: Vec<&PositionedGlyph> = l.glyphs.iter().collect(); + drawn.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + assert!(drawn[0].origin.x.abs() < 1e-6, "starts at the left edge: {:?}", drawn[0].origin); + let tab = drawn.iter().position(|g| g.byte == 9).unwrap(); + for g in &drawn[..tab] { + assert!((g.advance - at(&natural, g.byte).advance).abs() < 1e-6, "byte {} keeps its advance", g.byte); + } + assert!((end(&l, 9) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 9)); + let last = drawn.last().unwrap(); + assert!((last.origin.x + last.advance - 300.0).abs() < 1e-6, "ends on the right edge: {}", last.origin.x + last.advance); + assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "{}", l.lines[0].x1); +} + +/// Point type has no measure to fill: a justified line with a tab keeps its natural width. +#[test] +fn justified_point_type_with_a_tab_keeps_its_width() { + let mut t = point("AA BB\tCC DD", no_ranges()); + t.para.justify = Justify::JustifyAll; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let l = layout(db(), &t); + t.para.justify = Justify::Left; + let natural = layout(db(), &t); + assert!((end(&l, 10) - end(&natural, 10)).abs() < 1e-6, "{} {}", end(&l, 10), end(&natural, 10)); +} + /// An OpenType (GSUB) Arabic face with a tatweel: craft-fonts' Noto Sans Arabic when the build has /// craft-fonts (`CRAFT_FONTS_DIR`), else an installed one. None skips the test. fn arabic_face(test: &str) -> Option> { diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index cfdd41cc78..b78527378d 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -5,9 +5,9 @@ //! with [`distribute`]. use vectorcraft_doc::text::ListCounts; -use vectorcraft_doc::{ParaStyle, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun}; -use crate::edit::{normalize, runs_len, slice_runs, style_at}; +use crate::edit::{floor_char, normalize, runs_len}; use crate::{FontDb, layout}; /// The story: every frame's runs joined in thread order. @@ -23,16 +23,26 @@ pub fn story(frames: &[&TextObject]) -> Vec { /// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break). /// An empty frame that was just restyled keeps its style against unedited empty frames after it. pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec { + story_paras_restyled(frames, later_wins, &[]) +} + +/// [`story_paras`] that also knows which frames' last paragraph was just restyled +/// (`earlier_wins[k]`, read for frames with text). Such a frame's last paragraph keeps its style +/// against the unedited first paragraph of the frame after it, and of frames after unedited empty +/// frames, even when the frame ends with a paragraph break. When the later frame's first paragraph +/// was restyled too (`later_wins`), the later frame's style wins. +pub fn story_paras_restyled(frames: &[&TextObject], later_wins: &[bool], earlier_wins: &[bool]) -> Vec { let mut out: Vec = vec![]; let mut prev_empty_tail = true; let mut empty_tail_restyled = false; + let mut tail_restyled = false; for (k, f) in frames.iter().enumerate() { let text = f.plain_text(); let restyled = later_wins.get(k).copied().unwrap_or(false); let mut styles = f.paragraph_styles().into_iter(); if k > 0 && let Some(first) = styles.next() - && (restyled || (prev_empty_tail && !(text.is_empty() && empty_tail_restyled))) + && (restyled || (prev_empty_tail && !tail_restyled && !(text.is_empty() && empty_tail_restyled))) && let Some(last) = out.last_mut() { *last = first; @@ -43,6 +53,7 @@ pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec Vec usize { - let mut probe = frame.clone(); - probe.runs = runs.to_vec(); - let lay = layout(db, &probe); - let text: String = runs.iter().map(|r| r.text.as_str()).collect(); +/// overflows as any character does, it starts the next frame), and for area type that overflows, +/// where its first line that does not fit starts. +fn fit(db: &FontDb, probe: &TextObject) -> (usize, Option) { + #[cfg(test)] + FIT_BYTES.set(FIT_BYTES.get() + runs_len(&probe.runs)); + let lay = layout(db, probe); + let text: String = probe.runs.iter().map(|r| r.text.as_str()).collect(); if !lay.overflow { - return text.len(); + return (text.len(), None); } let mut end = lay.lines.last().map_or(0, |l| l.end); if lay.on_path { - return end; + return (end, None); } + let unplaced = end; let bytes = text.as_bytes(); while end < bytes.len() && bytes[end] == b' ' { end += 1; @@ -79,7 +92,198 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { if end < bytes.len() && bytes[end] == b'\n' { end += 1; } - end + (end, Some(unplaced)) +} + +#[cfg(test)] +thread_local! { + /// The bytes of text [`fit`] has laid out on this thread ([`fit_bytes`]). + static FIT_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The bytes of text [`fit`] has laid out on this thread. +#[cfg(test)] +fn fit_bytes() -> usize { + FIT_BYTES.get() +} + +#[cfg(test)] +thread_local! { + /// The bytes of frames' own text copied on this thread to measure them ([`probe_copied`]). + static PROBE_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The bytes of frames' own text copied on this thread to measure them. +#[cfg(test)] +fn probe_copied() -> usize { + PROBE_COPIED.get() +} + +/// A copy of `frame` to measure text in, its own text counted as copied ([`probe_copied`]). +fn copy_to_measure(frame: &TextObject) -> TextObject { + #[cfg(test)] + PROBE_COPIED.set(PROBE_COPIED.get() + runs_len(&frame.runs)); + frame.clone() +} + +/// The fewest bytes of story a frame is measured with before the measured text grows. +const MIN_MEASURE: usize = 512; + +/// What is left of a story while [`distribute_continuing_levels`] hands it out, `len` bytes +/// borrowed from the story's runs: the runs from `run` on, the first of them without its first +/// `skip` bytes. While `raw` they are all the runs given (`run` and `skip` are 0), empty runs +/// included. Once a frame has taken a share they are the story after it as +/// [`crate::edit::slice_runs`] cuts it: no empty runs, and a run cut after its first character +/// without its glyph choice. +#[derive(Clone, Copy)] +struct Rest<'a> { + runs: &'a [TextRun], + run: usize, + skip: usize, + raw: bool, + len: usize, +} + +impl<'a> Rest<'a> { + /// The runs' pieces in order: each piece's text, its run and whether the run was cut before it. + fn pieces(self) -> impl Iterator { + self.runs.get(self.run..).unwrap_or_default().iter().enumerate().filter_map(move |(k, r)| { + let at = if k == 0 { self.skip } else { 0 }; + let text = r.text.get(at..)?; + (self.raw || !text.is_empty()).then_some((text, r, at > 0)) + }) + } + + /// A piece's style: its run's, without the glyph choice when the run was cut before it. + fn style(r: &TextRun, cut: bool) -> CharStyle { + let mut style = r.style.clone(); + if cut { + style.glyph_alternate = None; + } + style + } + + /// [`crate::edit::slice_runs`] of the rest's runs. + fn slice(self, start: usize, end: usize) -> Vec { + let mut out = vec![]; + let mut off = 0; + for (text, r, cut) in self.pieces() { + let (rs, re) = (off, off + text.len()); + if rs >= end { + break; + } + let (a, b) = (start.max(rs), end.min(re)); + if a < b { + let from = floor_char(text, a - rs); + let t = &text[from..floor_char(text, b - rs)]; + if !t.is_empty() { + out.push(TextRun { text: t.to_string(), style: Self::style(r, cut || from > 0), inline: r.inline.clone() }); + } + } + off = re; + } + out + } + + /// The rest's runs before byte `end`, which follows a paragraph break, as a frame is measured + /// with them: every run that starts before it (an empty one too, while the runs are the ones + /// given), the last one cut there. + fn before(self, end: usize) -> Vec { + let mut out = vec![]; + let mut off = 0; + for (text, r, cut) in self.pieces() { + if off >= end { + break; + } + let t = text.get(..end - off).unwrap_or(text); + out.push(TextRun { text: t.to_string(), style: Self::style(r, cut), inline: r.inline.clone() }); + off += text.len(); + } + out + } + + /// [`crate::edit::style_at`] of the rest's runs. + fn style_at(self, byte: usize) -> CharStyle { + let mut off = 0; + let mut last = None; + let mut first = None; + for (text, r, cut) in self.pieces() { + first = first.or(Some((r, cut))); + let end = off + text.len(); + if byte < end || (byte == end && byte == off) { + return Self::style(r, cut); + } + if !text.is_empty() { + last = Some((r, cut)); + if byte == end { + return Self::style(r, cut); + } + } + off = end; + } + last.or(first).map(|(r, cut)| Self::style(r, cut)).unwrap_or_default() + } + + /// The end of the first paragraph break at or after byte `from`, or None when the rest has no + /// break there. + fn break_after(self, from: usize) -> Option { + let mut off = 0; + for (text, _, _) in self.pieces() { + let at = from.saturating_sub(off); + if let Some(i) = text.as_bytes().get(at..).and_then(|b| b.iter().position(|&b| b == b'\n')) { + return Some(off + at + i + 1); + } + off += text.len(); + } + None + } + + /// All of the rest's runs. + fn all(self) -> Vec { + if self.raw { self.runs.to_vec() } else { self.slice(0, self.len) } + } + + /// The rest after its first `n` bytes, as a slice of the story: the run that byte falls in + /// cut there (back to a character boundary, as [`crate::edit::slice_runs`] cuts it). + fn after(self, n: usize) -> Self { + let mut run = self.run; + let mut at = self.skip + n; + while let Some(r) = self.runs.get(run) + && at >= r.text.len() + && run + 1 < self.runs.len() + { + at -= r.text.len(); + run += 1; + } + let skip = self.runs.get(run).map_or(0, |r| floor_char(&r.text, at)); + let cut_back = at - skip; + Self { runs: self.runs, run, skip, raw: false, len: self.len + cut_back - n } + } +} + +/// How many bytes of `rest` fit in `frame`, measured as `probe` makes the frame for some runs (with +/// their paragraph styles, the list counts and the continuation mark). +/// +/// Area type without Shrink Text to Fit whose lines align to the top is first measured with the +/// whole paragraphs of the rest that end at or after byte `want`, and then with twice as many bytes +/// each time, while the rest goes on past them. The layout places lines in order and stops at the +/// first line that does not fit, and each paragraph is shaped and broken on its own text. So when +/// that line starts before the last paragraph break measured, the frame holds the same bytes with +/// the whole rest. Other type, and a rest that ends first, is measured with the whole rest. +fn fit_rest(db: &FontDb, probe: impl Fn(Vec) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize) -> usize { + let by_paragraph = matches!(frame.kind, TextKind::Area { .. }) + && frame.area.fit.min_scale().is_none() + && frame.area.vertical_align == vectorcraft_doc::VerticalAlign::Top; + if by_paragraph { + while let Some(end) = rest.break_after(want.saturating_sub(1)).filter(|&end| end < rest.len) { + let (n, unplaced) = fit(db, &probe(rest.before(end))); + if unplaced.is_some_and(|u| u + 1 < end) { + return n; + } + want = end.saturating_mul(2); + } + } + fit(db, &probe(rest.all())).0 } /// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the @@ -175,31 +379,56 @@ pub fn distribute_continuing_levels( continued: bool, mut counts: ListCounts, ) -> Vec { - let mut rest = runs.to_vec(); + let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: runs_len(runs) }; + // Once the story is used up, what is left is one empty run with the style there. + let mut used_up: Option = None; let mut out = Vec::with_capacity(frames.len()); let mut next_para = 0; let mut continues = continued; + let mut want = MIN_MEASURE; for (i, f) in frames.iter().enumerate() { let first_para = next_para; - let len = runs_len(&rest); + let empty_story: [TextRun; 1]; + let rest = match used_up.clone() { + Some(empty) => { + empty_story = [empty]; + Rest { runs: &empty_story, run: 0, skip: 0, raw: true, len: 0 } + } + None => story_rest, + }; + let len = rest.len; let own = [f.para.clone()]; let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) }; let n = if i + 1 == frames.len() { len } else { - let mut probe = (*f).clone(); - probe.runs = rest.clone(); - probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); - probe.set_list_counts(counts); - probe.first_para_continues = continues; - fit(db, &probe, &rest) + // The frame without its own text, copied once and then for each measurement. + let mut base = copy_to_measure(f); + base.runs = Vec::new(); + base.paras = Vec::new(); + let probe = |runs: Vec| { + let mut probe = copy_to_measure(&base); + let styles = slice_paras(paras, first_para, &runs); + probe.runs = runs; + probe.set_paragraph_styles(styles); + probe.set_list_counts(counts); + probe.first_para_continues = continues; + probe + }; + fit_rest(db, probe, f, rest, want) }; - let mut head = slice_runs(&rest, 0, n); + want = n.saturating_mul(2).max(MIN_MEASURE); + let mut head = rest.slice(0, n); if head.is_empty() { - head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None }); + head.push(TextRun { text: String::new(), style: rest.style_at(n), inline: None }); + } + if used_up.is_none() { + if n >= len { + used_up = Some(TextRun { text: String::new(), style: rest.style_at(len), inline: None }); + } else { + story_rest = story_rest.after(n); + } } - let tail = slice_runs(&rest, n, len); - rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); let next_counts = count_list_paragraphs(&head, &head_paras, counts); diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index c3f05cef42..943aeb2973 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -1,5 +1,6 @@ use super::*; -use vectorcraft_doc::{CharStyle, Justify, TextKind}; +use crate::edit::{slice_runs, style_at}; +use vectorcraft_doc::{Justify, TextKind}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; fn frame(x: f64) -> TextObject { @@ -196,6 +197,42 @@ fn a_restyled_empty_frame_keeps_its_style_after_a_paragraph_break() { assert_eq!(tail_style(&[&first, &restyled, &right], &[false, true, true]), Some(Justify::Right)); } +/// A frame whose empty last paragraph (after a final paragraph break) was just restyled keeps that +/// style against the unedited first paragraph of the next frame, empty or with text, and across an +/// unedited empty frame. A restyled first paragraph in the next frame still wins. +#[test] +fn restyled_empty_last_paragraph_wins_over_unedited_next_frame() { + let style = |j| ParaStyle { justify: j, ..Default::default() }; + let mut first = frame(0.0); + first.runs = vec![TextRun { text: "a\n".into(), style: CharStyle::default(), inline: None }]; + first.set_paragraph_styles(vec![style(Justify::Left), style(Justify::Center)]); + let empty = frame(200.0); + let mut with_text = frame(400.0); + with_text.runs = vec![TextRun { text: "def".into(), style: CharStyle::default(), inline: None }]; + let justify = |frames: &[&TextObject], later: &[bool], earlier: &[bool]| { + story_paras_restyled(frames, later, earlier).iter().map(|p| p.justify).collect::>() + }; + use Justify::*; + + assert_eq!(justify(&[&first, &empty], &[false, false], &[true, false]), [Left, Center]); + assert_eq!(justify(&[&first, &with_text], &[false, false], &[true, false]), [Left, Center]); + assert_eq!(justify(&[&first, &empty, &with_text], &[false, false, false], &[true, false, false]), [Left, Center]); + assert_eq!(justify(&[&first, &empty, &empty], &[false, false, false], &[true, false, false]), [Left, Center]); + // Without the edit, the next frame gives the paragraph its style, as before. + assert_eq!(justify(&[&first, &empty], &[false, false], &[false, false]), [Left, Left]); + assert_eq!(story_paras(&[&first, &with_text], &[]).iter().map(|p| p.justify).collect::>(), [Left, Left]); + // A restyled first paragraph in the next frame wins over the restyled tail. + let mut right = with_text.clone(); + right.set_all_paras(style(Right)); + assert_eq!(justify(&[&first, &right], &[false, true], &[true, true]), [Left, Right]); + assert_eq!(justify(&[&first, &empty, &right], &[false, false, true], &[true, false, true]), [Left, Right]); + // A middle frame is the later side at one boundary and the earlier side at the next. + let mut middle = frame(200.0); + middle.runs = vec![TextRun { text: "b\n".into(), style: CharStyle::default(), inline: None }]; + middle.set_paragraph_styles(vec![style(Right), style(JustifyAll)]); + assert_eq!(justify(&[&first, &middle, &with_text], &[false, true, false], &[false, true, false]), [Left, Right, JustifyAll]); +} + /// Numbered list paragraphs count on from one frame to the next: the second frame's first /// whole paragraph takes the next number. A paragraph split between the frames has its number /// in the first frame only, and the paragraph after it takes the next number. @@ -412,6 +449,298 @@ fn list_levels_count_on_through_the_frames() { assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1])); } +/// The shares the frames would take if each one were measured with the whole rest of the story: +/// what [`distribute_continuing_levels`] gives, measuring each frame with less of it. +fn whole_rest_shares( + db: &FontDb, + frames: &[&TextObject], + runs: &[TextRun], + paras: &[ParaStyle], + continued: bool, + mut counts: ListCounts, +) -> Vec { + let fit_whole = |frame: &TextObject, runs: &[TextRun]| { + let lay = layout(db, frame); + let text: String = runs.iter().map(|r| r.text.as_str()).collect(); + if !lay.overflow { + return text.len(); + } + let mut end = lay.lines.last().map_or(0, |l| l.end); + if lay.on_path { + return end; + } + let bytes = text.as_bytes(); + while end < bytes.len() && bytes[end] == b' ' { + end += 1; + } + if end < bytes.len() && bytes[end] == b'\n' { + end += 1; + } + end + }; + let mut rest = runs.to_vec(); + let mut out = Vec::with_capacity(frames.len()); + let mut next_para = 0; + let mut continues = continued; + for (i, f) in frames.iter().enumerate() { + let first_para = next_para; + let len = runs_len(&rest); + let own = [f.para.clone()]; + let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) }; + let n = if i + 1 == frames.len() { + len + } else { + let mut probe = (*f).clone(); + probe.runs = rest.clone(); + probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); + probe.set_list_counts(counts); + probe.first_para_continues = continues; + fit_whole(&probe, &rest) + }; + let mut head = slice_runs(&rest, 0, n); + if head.is_empty() { + head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None }); + } + let tail = slice_runs(&rest, n, len); + rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; + let head_paras = slice_paras(paras, first_para, &head); + next_para += head_paras.len().saturating_sub(1); + let next_counts = count_list_paragraphs(&head, &head_paras, counts); + let starts_continued = continues; + continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues); + let (list_count, list_level_counts) = counts.parts(); + out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued }); + counts = next_counts; + } + out +} + +/// `frames` share `runs` as they would if each frame were measured with the whole rest, from the +/// story's start and continuing from text before it; returns how many frames took text. +fn same_as_whole_rest(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle], case: &str) -> usize { + let got = distribute(db, frames, runs, paras); + assert_eq!(got, whole_rest_shares(db, frames, runs, paras, false, ListCounts::default()), "{case}"); + let counts = ListCounts::from_parts(3, &[2, 1]); + let continuing = distribute_continuing_levels(db, frames, runs, paras, true, counts); + assert_eq!(continuing, whole_rest_shares(db, frames, runs, paras, true, counts), "{case}, continuing"); + got.iter().filter(|s| runs_len(&s.runs) > 0).count() +} + +/// An area frame `w` by `h` at `x`. +fn sized(x: f64, w: f64, h: f64) -> TextObject { + let mut t = frame(x); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + w, h)) }; + t +} + +/// Numbered `n` short paragraphs. +fn paragraphs(n: usize) -> String { + (1..=n).map(|i| format!("Item {i}: the quick brown fox jumps.")).collect::>().join("\n") +} + +/// Each frame takes the same share of a long story when it is measured with only the paragraphs +/// it needs as when it is measured with the whole rest of the story, for every kind of frame, +/// text and paragraph style that changes how much a frame holds. +#[test] +fn frames_take_the_shares_the_whole_rest_gives_them() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let db = FontDb::global(); + let plain = CharStyle::default(); + let big = CharStyle { size: 20.0, ..Default::default() }; + let small = CharStyle { size: 7.0, ..Default::default() }; + let story = |text: &str| vec![TextRun::new(text, plain.clone())]; + let frames: Vec = (0..6).map(|k| frame(200.0 * k as f64)).collect(); + fn refs(v: &[TextObject]) -> Vec<&TextObject> { + v.iter().collect() + } + let mut took = 0; + + // Many short paragraphs, every one of its own style. + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &[], "short paragraphs"); + let spaced: Vec = (0..120).map(|i| ParaStyle { space_before: (i % 4) as f64 * 3.0, ..Default::default() }).collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &spaced, "spaced paragraphs"); + + // Frames of different sizes, one with no height and one too narrow for a word. + let uneven = vec![ + sized(0.0, 120.0, 30.0), + sized(200.0, 120.0, 0.0), + sized(400.0, 200.0, 140.0), + sized(700.0, 4.0, 200.0), + sized(800.0, 60.0, 90.0), + frame(1000.0), + ]; + took += same_as_whole_rest(db, &refs(&uneven), &story(¶graphs(120)), &[], "uneven frames"); + + // A word longer than a line, and runs of spaces and empty paragraphs where frames end. + let long_word = format!("{}\n{}\n{}", "x".repeat(700), paragraphs(30), "y".repeat(900)); + took += same_as_whole_rest(db, &refs(&frames), &story(&long_word), &[], "long words"); + let breaks = (0..200).map(|i| if i % 5 == 0 { String::new() } else { format!("Line {i} ends here ") }).collect::>().join("\n"); + took += same_as_whole_rest(db, &refs(&frames), &story(&breaks), &[], "spaces and empty paragraphs"); + for k in [0usize, 1, 2, 3] { + let tall: Vec = (0..4).map(|i| sized(200.0 * i as f64, 120.0, 14.0 * (k + i + 1) as f64)).collect(); + took += same_as_whole_rest(db, &refs(&tall), &story(&breaks), &[], &format!("frame heights {k}")); + } + + // Style runs and inline graphics cut where frames end, and empty runs as given. + // Some runs start with a glyph choice, which a run cut after its first character loses. + let mut styled = vec![]; + let text = paragraphs(90); + let words: Vec<&str> = text.split_inclusive(' ').collect(); + for (i, three) in words.chunks(3).enumerate() { + let text = three.concat(); + let mut style = [&plain, &big, &small][i % 3].clone(); + if i % 4 == 0 { + let ch = text.chars().next().unwrap_or('I'); + style.glyph_alternate = Some(vectorcraft_doc::GlyphChoice { ch, gid: 1, font: "Test".into(), version: String::new() }); + } + styled.push(TextRun::new(text, style)); + if i % 7 == 0 { + styled.push(TextRun::inline(vectorcraft_doc::InlineArt::new("sym"), plain.clone())); + } + if i % 11 == 0 { + styled.push(TextRun::new("", big.clone())); + } + } + took += same_as_whole_rest(db, &refs(&frames), &styled, &[], "style runs"); + + // Numbered lists at two levels, counted on from text before. + let at = |level| ParaStyle { + list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)), + left_indent: 18.0, + first_line_indent: -18.0, + ..Default::default() + }; + let listed: Vec = (0..120).map(|i| at(1 + i % 3 / 2)).collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &listed, "lists"); + + // Justified and hyphenated paragraphs, long enough to run from frame to frame. + let text = "The quick brown fox jumps over the lazy dog, extraordinarily unbelievably. ".repeat(6); + let long_paras = (0..14).map(|_| text.trim_end()).collect::>().join("\n"); + let justified: Vec = (0..14) + .map(|i| ParaStyle { justify: [Justify::JustifyAll, Justify::JustifyLeft][i % 2], hyphenate: i % 3 != 0, ..Default::default() }) + .collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(&long_paras), &justified, "justified and hyphenated"); + // Paragraphs longer than a frame holds, each composed as a whole. + let tall: Vec = (0..5).map(|k| sized(200.0 * k as f64, 160.0, 160.0 + 40.0 * k as f64)).collect(); + let very_long = (0..6).map(|_| text.repeat(4).trim_end().to_string()).collect::>().join("\n"); + for justify in [Justify::Left, Justify::JustifyLeft] { + let composed = [ParaStyle { justify, ..Default::default() }]; + took += same_as_whole_rest(db, &refs(&tall), &story(&very_long), &composed, &format!("long paragraphs, {justify:?}")); + } + + // Columns, vertical type, Shrink Text to Fit, lines aligned to the center, type on a path, + // and a last frame the story overflows. + let mut columns = frames.clone(); + for f in &mut columns { + f.area.columns = 2; + f.area.gutter = 6.0; + } + took += same_as_whole_rest(db, &refs(&columns), &story(¶graphs(120)), &[], "columns"); + let mut vertical = frames.clone(); + for f in &mut vertical { + f.vertical = true; + } + took += same_as_whole_rest(db, &refs(&vertical), &story(¶graphs(80)), &[], "vertical type"); + let mut mixed = frames.clone(); + mixed[1].area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 60.0 }; + mixed[2].area.vertical_align = vectorcraft_doc::VerticalAlign::Center; + mixed[3] = path_frame(300.0); + took += same_as_whole_rest(db, &refs(&mixed), &story(¶graphs(120)), &[], "other kinds of frame"); + let two = [&frames[0], &frames[1]]; + took += same_as_whole_rest(db, &two, &story(¶graphs(200)), &[], "overflowing last frame"); + + // Short stories, an empty one and one of empty runs. + took += same_as_whole_rest(db, &refs(&frames), &story("Hi"), &[], "short story"); + took += same_as_whole_rest(db, &refs(&frames), &story(""), &[], "empty story"); + took += same_as_whole_rest(db, &refs(&frames), &[TextRun::new("", big.clone()), TextRun::new("", small.clone())], &[], "empty runs"); + assert!(took > 40, "the frames took text: {took}"); +} + +/// A seeded mix of run splits, sizes, paragraph styles and frame sizes: each frame takes the share +/// the whole rest gives it. +#[test] +fn seeded_stories_take_the_shares_the_whole_rest_gives_them() { + let db = FontDb::global(); + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + let words = ["fox", "a", "jumps", "extraordinary", "dog,", "the", "lazy", "over", " ", "quick"]; + for case in 0..12 { + let mut runs = vec![]; + let mut paras = 1; + for _ in 0..(150 + next(250)) { + let mut text = words[next(words.len() as u64) as usize].to_string(); + text.push(if next(6) == 0 { + paras += 1; + '\n' + } else { + ' ' + }); + let style = CharStyle { size: [12.0, 9.0, 16.0][next(3) as usize], tracking: [0.0, 50.0][next(2) as usize], ..Default::default() }; + runs.push(TextRun::new(text, style)); + } + normalize(&mut runs); + let styles: Vec = (0..paras) + .map(|_| ParaStyle { + justify: [Justify::Left, Justify::Center, Justify::JustifyLeft][next(3) as usize], + first_line_indent: [0.0, 12.0][next(2) as usize], + space_after: [0.0, 4.0][next(2) as usize], + ..Default::default() + }) + .collect(); + let frames: Vec = (0..2 + next(6)).map(|k| sized(200.0 * k as f64, 40.0 + next(120) as f64, 10.0 + next(120) as f64)).collect(); + same_as_whole_rest(db, &frames.iter().collect::>(), &runs, &styles, &format!("case {case}")); + } +} + +/// A long story of short paragraphs through many frames: each frame is measured with about the +/// text it takes and a little more, not with the whole rest of the story. +#[test] +fn many_frames_measure_about_the_text_they_take() { + let db = FontDb::global(); + let text = paragraphs(1500); + let story = vec![TextRun::new(text.clone(), CharStyle::default())]; + let frames: Vec = (0..40).map(|k| frame(200.0 * k as f64)).collect(); + let refs: Vec<&TextObject> = frames.iter().collect(); + let before = fit_bytes(); + let parts = distribute(db, &refs, &story, &[]); + let measured = fit_bytes() - before; + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let placed: usize = parts[..parts.len() - 1].iter().map(|p| runs_len(&p.runs)).sum(); + let longest = text.split('\n').map(str::len).max().unwrap_or(0); + assert!(placed > 0 && placed < text.len() / 4, "the frames hold part of the story: {placed} of {}", text.len()); + let bound = 4 * placed + frames.len() * 2 * (MIN_MEASURE + longest); + assert!(measured <= bound, "measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len()); +} + +/// Frames that already hold text and take a story over several measurements each: each frame's own +/// text is copied once to measure it, not once for each measurement. +#[test] +fn frames_with_text_copy_it_once_to_measure_it() { + let db = FontDb::global(); + let text = paragraphs(600); + let story = vec![TextRun::new(text.clone(), CharStyle::default())]; + let mut frames: Vec = (0..4).map(|k| sized(400.0 * k as f64, 300.0, 600.0)).collect(); + for f in &mut frames { + f.runs = vec![TextRun::new("Old text. ".repeat(500), CharStyle::default())]; + } + let refs: Vec<&TextObject> = frames.iter().collect(); + let (fit_before, copied_before) = (fit_bytes(), probe_copied()); + let parts = distribute(db, &refs, &story, &[]); + let (measured, copied) = (fit_bytes() - fit_before, probe_copied() - copied_before); + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let first = runs_len(&parts[0].runs); + assert!(measured > 2 * first, "the first frame is measured more than once: {measured} bytes for {first}"); + let own: usize = frames[..frames.len() - 1].iter().map(|f| runs_len(&f.runs)).sum(); + assert_eq!(copied, own, "each frame before the last copies its own text once to measure it"); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 7b778498e6..309aa77409 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -9,8 +9,8 @@ mod solid; mod visibility; pub use solid::{ - Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, inflate_each, - inflate_together, plane, plane_face_map, + Bevel, BevelShape, DepthPlane, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, + inflate_each, inflate_together, plane, plane_face_map, }; pub use visibility::{VisibleFace, visible_faces}; mod shading; @@ -23,7 +23,8 @@ pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; mod shadow; pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW}; -use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv}; +use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveDeriv}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2}; /// Resource limits shared by desktop, CLI and browser evaluation. const MAX_PROFILE_POINTS: usize = 16_384; @@ -104,6 +105,10 @@ pub struct Revolve { /// Samples for each curved profile segment, from [`revolve_curve_samples`] for the surfaces of /// one object. `None` plans them from the profile alone. pub curve_samples: Option, + /// Graphics are mapped onto the side: its faces carry a [`SurfaceCell`], and a closed profile + /// that does not touch the axis takes an anchor where its side is cut. Off by default, which + /// leaves the faces as they are without graphics. + pub map_surface: bool, } /// The default light's Rotation and Height: the light of the earlier default direction (45° to the @@ -133,6 +138,7 @@ impl Default for Revolve { smooth_shading: true, cap_ends: true, curve_samples: None, + map_surface: false, } } } @@ -362,6 +368,235 @@ pub struct Face { pub cap: bool, /// Bounded radial strips for a body face joining two curved endpoint rings. pub visibility_pieces: Option>>, + /// For a face of the swept side (not an end cap): where its corners lie on the side surface, + /// for a graphic mapped onto it. + pub surface: Option, +} + +/// Where a face of Revolve's swept side lies on the side surface, unrolled flat: x runs around the +/// axis and y along the profile, in points. x is the angle from the middle of the surface times the +/// point's own distance from the axis, so a graphic keeps its size at every distance and wraps +/// farther around the axis where the profile comes closer to it. y is the length along the profile +/// from its middle, increasing toward the profile's lower end; on a closed contour that does not +/// touch the axis the middle is where its outer side crosses the middle height of its bounds. The +/// middle of the surface around the axis is the side that faces the viewer before the rotation +/// ([`surface_middle`]). Only the faces of the longest contour of a compound profile carry a cell. +#[derive(Clone, Debug, PartialEq)] +pub struct SurfaceCell { + /// The corners of the face on the page: its `points` before an edge along an end cap's ring + /// is curved. + pub page: Vec, + /// The surface point of each of `page`, in the same order. + pub uv: Vec, + /// The face shows the outside of the surface (the side away from the axis, or out of the + /// region a closed profile encloses) to the viewer. + pub outward: bool, +} + +/// The size of Revolve's side surface unrolled ([`SurfaceCell`]), which is the side of the +/// profile's longest contour: its width around the axis at that contour's largest distance from it +/// over the part of the sweep a viewer can see from the front ([`surface_span`]: half a turn at +/// most), and the contour's length. `None` when the profile has no length or lies on the axis. +pub fn revolve_surface_size(path: &PathData, bounds: Rect, options: Revolve) -> Option<(f64, f64)> { + let o = options.sanitized(); + let axis = revolve_projection(bounds, o).origin.x; + let sp = path.subpaths.get(longest_contour(path, axis)?)?; + let length = swept_length(sp, axis); + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + let radius = (0..segments).map(|i| sp.segment(i).bounding_box()).fold(0.0f64, |r, b| r.max((b.x0 - axis).abs()).max((b.x1 - axis).abs())); + let width = surface_span(o.angle.to_radians(), o.edge) * radius; + (width.is_finite() && length.is_finite() && width > 0.0 && length > 0.0).then_some((width, length)) +} + +/// The length of contour `sp` that sweeps a surface about `axis`: every segment but those that lie +/// wholly on the axis. +fn swept_length(sp: &vectorcraft_geom::SubPath, axis: f64) -> f64 { + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + (0..segments) + .map(|i| { + // A segment sweeps nothing only when the whole of it lies on the axis: its control + // points as well as its ends. + let c = sp.segment(i); + let on_axis = [c.p0, c.p1, c.p2, c.p3].iter().all(|p| (p.x - axis).abs() <= 1e-9); + if on_axis { 0.0 } else { ParamCurveArclen::arclen(&c, 1e-3) } + }) + .sum() +} + +/// The contour of `path` with the longest [`swept_length`] about `axis` (the first of equal ones), +/// which carries Revolve's graphics. `None` when no contour sweeps a surface. +fn longest_contour(path: &PathData, axis: f64) -> Option { + path.subpaths + .iter() + .map(|sp| swept_length(sp, axis)) + .enumerate() + .filter(|(_, l)| l.is_finite() && *l > 0.0) + .fold(None, |best: Option<(usize, f64)>, (k, l)| if best.is_some_and(|(_, top)| top >= l) { best } else { Some((k, l)) }) + .map(|(k, _)| k) +} + +/// The angle of a Revolve sweep of `sweep` radians, about an axis on the profile's `edge`, at the +/// middle of its side surface ([`SurfaceCell`]). Before the rotation the viewer looks straight at +/// the angle 90° with the axis on the left and 270° with it on the right, and sees the half turn +/// around it. The middle is the middle of the part of the sweep in that half turn, or half the +/// sweep when no part is in it. +fn surface_middle(sweep: f64, edge: Edge) -> f64 { + use std::f64::consts::{FRAC_PI_2, PI}; + match edge { + Edge::Left => FRAC_PI_2.min(sweep / 2.0), + Edge::Right if sweep > PI => (3.0 * FRAC_PI_2).min((PI + sweep) / 2.0), + Edge::Right => sweep / 2.0, + } +} + +/// The angle of a Revolve sweep of `sweep` radians about an axis on `edge` that a viewer sees from +/// the front before the rotation, about [`surface_middle`]: the part of the sweep in the half turn +/// facing the viewer, or the whole sweep when no part is in it. +fn surface_span(sweep: f64, edge: Edge) -> f64 { + use std::f64::consts::PI; + match edge { + Edge::Right if sweep > PI => (sweep - PI).min(PI), + _ => sweep.min(PI), + } +} + +/// The surface y ([`SurfaceCell`]) of each end of each piece of a contour's profile `profile` +/// about `axis`: the length along the profile from its middle, counted from just after the +/// pieces that lie on the axis (which sweep no surface and add no length) on a closed contour. +fn along_profile(profile: &[Point], axis: f64, closed: bool) -> Vec<[f64; 2]> { + let n = profile.len().saturating_sub(1); + if n == 0 { + return vec![]; + } + let on_axis = |k: usize| match (profile.get(k), profile.get(k + 1)) { + (Some(a), Some(b)) => (a.x - axis).abs() <= 1e-9 && (b.x - axis).abs() <= 1e-9, + _ => true, + }; + if closed + && !(0..n).any(&on_axis) + && let Some(out) = around_ring(profile, axis, n) + { + return out; + } + let start = if closed { (0..n).find(|&k| on_axis((k + n - 1) % n) && !on_axis(k)).unwrap_or(0) } else { 0 }; + let mut out = vec![[0.0; 2]; n]; + let mut length = 0.0; + for step in 0..n { + let k = (start + step) % n; + let piece = if on_axis(k) { 0.0 } else { profile.get(k).zip(profile.get(k + 1)).map_or(0.0, |(a, b)| a.distance(*b)) }; + if let Some(slot) = out.get_mut(k) { + *slot = [length, length + piece]; + } + length += piece; + } + // y increases toward the profile's lower end on the page: the end of an open contour, or the + // point half way along a closed one. + let middle = length / 2.0; + let first = profile.get(start).map_or(0.0, |p| p.y); + let end = if closed { + (0..n) + .map(|step| (start + step) % n) + .find_map(|k| { + let [a, b] = *out.get(k)?; + let (p, q) = (profile.get(k)?, profile.get(k + 1)?); + (b >= middle).then(|| p.y + (q.y - p.y) * if b > a { (middle - a) / (b - a) } else { 0.0 }) + }) + .unwrap_or(first) + } else { + profile.last().map_or(first, |p| p.y) + }; + let sign = if end >= first { 1.0 } else { -1.0 }; + out.into_iter().map(|[a, b]| [(a - middle) * sign, (b - middle) * sign]).collect() +} + +/// [`along_profile`] of a closed contour `profile` of `n` pieces with none on `axis`: y is 0 where +/// the contour's outer side (its crossing farthest from the axis) crosses the middle height of its +/// bounds and increases from there in the direction that runs down the page. The contour is cut at +/// its anchor nearest half way around from there, on its far side. `None` when the contour has no +/// height or no length. +fn around_ring(profile: &[Point], axis: f64, n: usize) -> Option> { + let RingFrame { ends, length, at, sign } = ring_frame(profile, axis, n)?; + // The cut is at the anchor nearest half way around from the crossing, so no piece runs across + // it: the pieces run on from there, once around. [`ring_cut`] puts an anchor there. + let opposite = at + length / 2.0; + let round = |d: f64| { + let d = d.rem_euclid(length); + d.min(length - d) + }; + let cut = ends + .iter() + .map(|[a, _]| *a) + .fold(None, |best: Option, a| if best.is_some_and(|b| round(b - opposite) <= round(a - opposite)) { best } else { Some(a) })?; + let first = (cut - at).rem_euclid(length) - length; + Some( + ends.into_iter() + .map(|[a, b]| { + let s = first + (a - cut).rem_euclid(length); + [s * sign, (s + (b - a)) * sign] + }) + .collect(), + ) +} + +/// Where a closed contour `profile` with no piece on `axis` is cut for [`around_ring`]: the piece +/// that the point half way around from its outer crossing lies inside, and how far along it, when +/// that point is not an anchor already. `None` otherwise, or for a contour that [`around_ring`] +/// does not measure. +fn ring_cut(profile: &[Point], axis: f64) -> Option<(usize, f64)> { + let n = profile.len().saturating_sub(1); + let on_axis = |k: usize| match (profile.get(k), profile.get(k + 1)) { + (Some(a), Some(b)) => (a.x - axis).abs() <= 1e-9 && (b.x - axis).abs() <= 1e-9, + _ => true, + }; + if n == 0 || (0..n).any(on_axis) { + return None; + } + let RingFrame { ends, length, at, .. } = ring_frame(profile, axis, n)?; + let opposite = (at + length / 2.0).rem_euclid(length); + ends.iter().enumerate().find_map(|(k, [a, b])| { + let t = if b > a { (opposite - a) / (b - a) } else { return None }; + (t > 1e-9 && t < 1.0 - 1e-9).then_some((k, t)) + }) +} + +/// The pieces of a closed contour, measured for [`around_ring`]. +struct RingFrame { + /// Each piece's length along the contour from its first anchor, at each end. + ends: Vec<[f64; 2]>, + /// The contour's length. + length: f64, + /// The length along the contour of its crossing farthest from the axis at the middle height + /// of its bounds. + at: f64, + /// 1 when the contour runs down the page there, -1 when it runs up. + sign: f64, +} + +/// [`RingFrame`] of closed contour `profile` of `n` pieces about `axis`. `None` when the contour +/// has no height or no length. +fn ring_frame(profile: &[Point], axis: f64, n: usize) -> Option { + let (top, bottom) = profile.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(t, b), p| (t.min(p.y), b.max(p.y))); + let mid = (top + bottom) / 2.0; + let mut ends = Vec::with_capacity(n); + let mut length = 0.0; + // The crossing farthest from the axis: its distance from it, its length along the contour + // and the direction down the page there. + let mut crossing: Option<(f64, f64, f64)> = None; + for k in 0..n { + let (p, q) = (profile.get(k)?, profile.get(k + 1)?); + let piece = p.distance(*q); + if (p.y - mid) * (q.y - mid) <= 0.0 && p.y != q.y { + let t = (mid - p.y) / (q.y - p.y); + let distance = (p.x + (q.x - p.x) * t - axis).abs(); + if crossing.is_none_or(|(far, ..)| distance > far) { + crossing = Some((distance, length + piece * t, if q.y > p.y { 1.0 } else { -1.0 })); + } + } + ends.push([length, length + piece]); + length += piece; + } + let (_, at, sign) = crossing?; + (length.is_finite() && length > 0.0).then_some(RingFrame { ends, length, at, sign }) } impl Face { @@ -471,6 +706,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi outline: None, cap: false, visibility_pieces: None, + surface: None, })) } @@ -703,6 +939,13 @@ fn revolve_projection(bounds: Rect, o: Revolve) -> shadow::Projection { shadow::Projection { view: o, origin: Point::new(axis, center_y), eye } } +/// The most faces one Revolve surface with sanitized options `o` holds. With graphics mapped, a +/// closed contour may take one more edge where its side is cut ([`ring_cut`]): the surface may +/// hold that edge's faces past [`MAX_REVOLVE_FACES`], which the profile was planned within. +fn revolve_face_limit(o: Revolve) -> usize { + MAX_REVOLVE_FACES + if o.map_surface { o.steps().saturating_mul(2) } else { 0 } +} + /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, /// each turned out of the region `rule` fills. fn revolve_faces( @@ -720,10 +963,11 @@ fn revolve_faces( // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so // each part's normal turns toward a hole only where it walls one. let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; + let face_limit = revolve_face_limit(o); // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); - if mesh.len() > MAX_REVOLVE_FACES { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } + if mesh.len() > face_limit { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); @@ -734,6 +978,27 @@ fn revolve_faces( let smooth = o.shade && o.smooth_shading; let [lx, ly, lz] = o.light_direction(); let light = V3(lx, ly, lz); + // The side surface's angle at each end of step `j` ([`SurfaceCell`]): from the middle of the + // surface, increasing to the viewer's right. The middle is the middle of the part of the sweep + // that faces the viewer before the rotation, and at most the angle that faces the viewer + // straight on: 90° with the axis on the profile's left, 270° with it on the right. A sweep + // with no part facing the viewer has its middle at half the sweep. A full turn is cut at the + // back, at the edge between two steps nearest half a turn from the middle: each step is placed + // by its own middle, within half a turn of the surface's middle, so the steps meet edge to + // edge on either side of the cut. + let middle = surface_middle(sweep, o.edge); + let full = o.angle >= 360.0; + let surface_angle = |j: usize| { + let (from, to) = (sweep * j as f64 / steps as f64, sweep * (j + 1) as f64 / steps as f64); + let half = (to - from) / 2.0; + let mut start = middle - from; + if full { + start = (start - half + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI + half; + } + [start, start - (to - from)] + }; + // Only the longest contour of a compound profile carries the graphics. + let surface_contour = o.map_surface.then(|| longest_contour(path, axis)).flatten(); let mut faces = Vec::new(); let vertex = |p: Point, step: usize| { let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); @@ -748,7 +1013,7 @@ fn revolve_faces( if let Some(face) = face { faces.push(face); mesh_cost += cost; - if mesh_cost > MAX_REVOLVE_FACES { + if mesh_cost > face_limit { return Err(TOO_COMPLEX_SURFACE.into()); } } @@ -833,9 +1098,26 @@ fn revolve_faces( let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); if area < 0.0 { -1.0 } else { 1.0 } }; + // A closed contour that does not touch the axis has its side cut opposite the middle of its + // outer side; an anchor goes there, so no face runs across the cut. That one anchor is not + // counted against the limit on the profile's points. + if sp.closed + && surface_contour == Some(contour) + && let Some((k, t)) = ring_cut(&profile, axis) + && let (Some(a), Some(b), Some([ta, tb]), Some(hole)) = + (profile.get(k), profile.get(k + 1), tangents.get(k).copied(), hole_edges.get(k).copied()) + { + let point = *a + (*b - *a) * t; + let middle = ta + (tb - ta) * t; + profile.insert(k + 1, point); + tangents.splice(k..=k, [[ta, middle], [middle, tb]]); + hole_edges.insert(k + 1, hole); + } + let along = along_profile(&profile, axis, sp.closed); for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { let [a, b] = pair else { continue }; let turn = if hole { -turn } else { turn }; + let [va, vb] = along.get(i).copied().unwrap_or_default(); if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve profile contains invalid coordinates".into()); } @@ -851,20 +1133,42 @@ fn revolve_faces( } let first = (i == 0).then(|| rings.first().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); let last = (i + 2 == profile.len()).then(|| rings.last().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); - let projected = |vertices: &[V3], normals: Option<&[V3]>, edges: &[(usize, &caps::RingArc, bool)]| { + // Where the corners lie on the unrolled side surface, and whether the face shows + // its outside: the edge's outward normal swept to the middle of the step. + // x is the angle times the corner's own distance from the axis. + let [t0, t1] = surface_angle(j); + let (ra, rb) = ((a.x - axis).abs(), (b.x - axis).abs()); + let corners = [Point::new(t0 * ra, va), Point::new(t0 * rb, vb), Point::new(t1 * rb, vb), Point::new(t1 * ra, va)]; + let on_surface = surface_contour == Some(contour); + let outward = { + let (dr, dy) = ((b.x - a.x) * turn, (b.y - a.y) * turn); + let (s, c) = (sweep * (j as f64 + 0.5) / steps as f64).sin_cos(); + let n = o.rotate(V3(dy * c, -dr, dy * s)); + let middle = [v0, v1, v2, v3].iter().fold(V3::default(), |m, v| V3(m.0 + v.0 / 4.0, m.1 + v.1 / 4.0, m.2 + v.2 / 4.0)); + let toward = eye.map_or(V3(0.0, 0.0, 1.0), |e| V3(-middle.0, -middle.1, e - middle.2)); + n.dot(toward) > 0.0 + }; + let projected = |vertices: &[V3], normals: Option<&[V3]>, edges: &[(usize, &caps::RingArc, bool)], uv: &[usize]| { let mut face = project_face(vertices, normals, o, Point::new(axis, center_y), eye, light)?; if let Some(face) = &mut face { + let page = face.points.clone(); caps::curve_edges(face, edges); + if on_surface { + let uv = uv.iter().filter_map(|k| corners.get(*k).copied()).collect(); + face.surface = Some(SurfaceCell { page, uv, outward }); + } } Ok::<_, String>(face) }; if smooth { let [n0, n1, n2, n3] = [normal(ta, j), normal(tb, j), normal(tb, j + 1), normal(ta, j + 1)]; - emit(projected(&[v0, v1, v2], Some(&[n0, n1, n2]), &last.map(|arc| (1, arc, false)).into_iter().collect::>())?, 1)?; - emit(projected(&[v0, v2, v3], Some(&[n0, n2, n3]), &first.map(|arc| (2, arc, true)).into_iter().collect::>())?, 1)?; + let last: Vec<_> = last.map(|arc| (1, arc, false)).into_iter().collect(); + let first: Vec<_> = first.map(|arc| (2, arc, true)).into_iter().collect(); + emit(projected(&[v0, v1, v2], Some(&[n0, n1, n2]), &last, &[0, 1, 2])?, 1)?; + emit(projected(&[v0, v2, v3], Some(&[n0, n2, n3]), &first, &[0, 2, 3])?, 1)?; } else { let edges: Vec<_> = last.map(|arc| (1, arc, false)).into_iter().chain(first.map(|arc| (3, arc, true))).collect(); - emit(projected(&[v0, v1, v2, v3], None, &edges)?, 1)?; + emit(projected(&[v0, v1, v2, v3], None, &edges, &[0, 1, 2, 3])?, 1)?; } } } diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index f3da6f866f..49b0738d9e 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -292,12 +292,53 @@ pub struct Facet { /// The planar end of the solid the facet is: Extrude & Bevel's front or back cap, or a plane /// (its front). `None` for every other facet. pub end: Option, + /// The face's plane in view space and the projection it was drawn with, for its depth at a + /// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose + /// corners lie more than 0.001 pt off one plane (a piece of a twisted wall). + pub plane: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, } +/// A facet's plane in view space (x right, y down, z toward the viewer): the points `v` with +/// `normal · v = offset`, drawn by a projection about `center` with the eye `eye` in front of the +/// object's center plane (`None` without perspective). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DepthPlane { + pub normal: [f64; 3], + pub offset: f64, + pub center: Point, + pub eye: Option, +} + +impl DepthPlane { + /// The view-space depth (larger is nearer the viewer) of the point of the plane that projects + /// to `p`, a point of the drawing. `None` when the plane is seen edge-on there or the point is + /// not in front of the eye. + pub fn depth_at(&self, p: Point) -> Option { + let [nx, ny, nz] = self.normal; + let (x, y) = (p.x - self.center.x, p.y - self.center.y); + let z = match self.eye { + None => (nz.abs() > 1e-9).then(|| (self.offset - nx * x - ny * y) / nz)?, + Some(e) => { + // On the ray through `p`, the point at depth z is at (x, y) × (e - z) / e. + let a = (nx * x + ny * y) / e; + let den = nz - a; + let z = (den.abs() > 1e-9).then(|| (self.offset - a * e) / den)?; + (z < e).then_some(z)? + } + }; + z.is_finite().then_some(z) + } +} + impl Facet { + /// The view-space depth of the facet at `p`, a point of the drawing ([`DepthPlane::depth_at`]). + pub fn depth_at(&self, p: Point) -> Option { + self.plane.as_ref()?.depth_at(p) + } + /// About how thick the facet is across: twice its area over its perimeter (a w × l rectangle /// long against its width gives about w). pub fn thickness(&self) -> f64 { @@ -339,6 +380,7 @@ impl Facet { // A revolved cap is not one of the planar faces graphics map onto. end: None, away: false, + plane: None, #[cfg(test)] view: vec![], } diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index bd4030dfc4..359192c7f8 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -6,8 +6,8 @@ use super::rings::{ solid_rings_traced, }; use super::{ - Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, eye_distance, - perspective_scale, + Bevel, DepthPlane, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, + eye_distance, perspective_scale, }; use crate::shadow::Projection; @@ -129,6 +129,14 @@ impl Builder<'_> { } else { (V3(-n.0, -n.1, -n.2), false) }; + // A face whose corners lie off one plane (a piece of a twisted wall bent into a saddle) has + // no plane to measure its depth on. Nor has a face with a corner at or past the eye: the + // projection holds that corner just in front of the eye, so the drawing no longer matches + // the plane's projection there. + let offset = n.dot(centre); + let held = self.camera.eye.is_some_and(|e| contours.iter().flatten().any(|v| e - v.2 < e * 1e-3)); + let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); + let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] let view = contours.clone(); let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); @@ -156,6 +164,7 @@ impl Builder<'_> { side: false, away: behind, end: None, + plane, #[cfg(test)] view, }); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index be887180b2..9929a52860 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -154,6 +154,46 @@ fn taper_shrinks_the_back_and_twist_turns_it() { assert!(open.len() > 2 * 8, "{}", open.len()); } +/// A flat facet carries its plane: the depth it gives at each corner of its drawing is the corner's +/// view-space depth, with and without perspective. A piece of a twisted wall whose corners lie off +/// one plane has none. +#[test] +fn a_flat_facet_measures_its_depth_on_its_plane() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let o = Extrude { depth: 60.0, cap: true, ..Extrude::default() }; + for perspective in [0.0, 50.0] { + let view = View { rotation_x: -20.0, rotation_y: 30.0, perspective, ..front() }; + let flat = extrude(&p, FillRule::EvenOdd, b, o, view).unwrap(); + assert!(!flat.is_empty()); + for f in &flat { + assert!(f.plane.is_some(), "{perspective}"); + for (q, v) in f.contours.iter().flatten().zip(f.view.iter().flatten()) { + let d = f.depth_at(*q); + assert!(d.is_some_and(|d| close(d, v.2, 1e-6)), "{perspective}: {d:?} against {}", v.2); + } + } + let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 90.0, ..o }, view).unwrap(); + assert!(twisted.iter().any(|f| f.side && f.plane.is_none()), "{perspective}"); + } +} + +/// A flat face with a corner at or past the eye, which the projection holds just in front of the +/// eye, has no plane: its drawing no longer matches the plane's projection there. +#[test] +fn a_face_reaching_past_the_eye_has_no_plane() { + use super::build::{Builder, Order}; + let (_, b) = shape(&square(0.0, 0.0, 100.0)); + let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0); + let eye = camera.center_and_eye().1.unwrap(); + for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] { + let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None }; + let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)]; + builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap(); + assert_eq!(builder.facets.len(), 1, "{far}"); + assert_eq!(builder.facets[0].plane.is_some(), has_plane, "{far}"); + } +} + #[test] fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index 1b0ffa3ffc..fe42769ebb 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -184,6 +184,190 @@ fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() { assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap()); } #[test] +fn side_faces_lie_on_the_unrolled_surface_and_caps_do_not() { + let (p, b) = cylinder(); + // Without graphics no face carries a place on the surface. + let plain = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + assert!(plain.iter().all(|f| f.surface.is_none())); + let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + assert_eq!(faces.len(), plain.len()); + assert!(faces.iter().filter(|f| f.cap).all(|f| f.surface.is_none())); + let side: Vec<_> = faces.iter().filter(|f| !f.cap).collect(); + assert!(!side.is_empty() && side.iter().all(|f| f.surface.as_ref().is_some_and(|c| c.uv.len() == c.page.len() && c.uv.len() == 3))); + for f in &side { + let cell = f.surface.as_ref().unwrap(); + // Straight on, x around the axis is the angle from the front times the radius 40: a corner + // at page x lies at 40 asin(x / 40) on the front half; y runs 40 each way from the middle, + // downward. + for (q, uv) in cell.page.iter().zip(&cell.uv) { + assert!((uv.y - (q.y - 50.0)).abs() < 1e-9, "{q:?} {uv:?}"); + if cell.outward { + assert!((uv.x - 40.0 * (q.x / 40.0).clamp(-1.0, 1.0).asin()).abs() < 1e-6, "{q:?} {uv:?}"); + } + } + // The outside faces the viewer on the front half only. + let mean = cell.uv.iter().map(|p| p.x).sum::() / cell.uv.len() as f64; + assert_eq!(cell.outward, mean.abs() < 40.0 * std::f64::consts::FRAC_PI_2, "{mean}"); + } + // A quarter sweep's middle is the middle of the sweep, 45° around the axis. + let quarter = revolve(&p, b, Revolve { map_surface: true, angle: 90.0, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let xs: Vec = quarter.iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.x)).collect(); + let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + let reach = 40.0 * std::f64::consts::FRAC_PI_4; + assert!((lo + reach).abs() < 1e-9 && (hi - reach).abs() < 1e-9, "{lo} {hi}"); + let (w, h) = revolve_surface_size(&p, b, Revolve::default()).unwrap(); + assert!((w - 40.0 * std::f64::consts::PI).abs() < 1e-9 && (h - 80.0).abs() < 1e-3, "{w} {h}"); + assert_eq!(revolve_surface_size(&p, Rect::new(40.0, 10.0, 40.0, 90.0), Revolve::default()), None); + // A curve that starts and ends on the axis but bows away from it sweeps a side with a length. + let mut bow = BezPath::new(); + bow.move_to((0.0, 0.0)); + bow.curve_to((40.0, 0.0), (40.0, 80.0), (0.0, 80.0)); + let (w, h) = revolve_surface_size(&PathData::from_bezpath(&bow), Rect::new(0.0, 0.0, 30.0, 80.0), Revolve::default()).unwrap(); + assert!((w - 30.0 * std::f64::consts::PI).abs() < 1e-9 && h > 80.0, "{w} {h}"); +} +#[test] +fn the_side_keeps_its_length_around_the_axis_at_every_distance_from_it() { + // A step: 40 from the axis, 20 between y 40 and 60, then 40 again. + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + for p in [(40.0, 40.0), (20.0, 40.0), (20.0, 60.0), (40.0, 60.0), (40.0, 90.0)] { + bp.line_to(p); + } + let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)); + let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let (mut narrow, mut wide) = (0.0f64, 0.0f64); + for cell in faces.iter().filter_map(|f| f.surface.as_ref()).filter(|c| c.outward) { + // The faces of the narrow part run from y 40 to 60; those of the wide parts reach past + // them; the steps between lie at y 40 or 60 only. + let at = |y: f64| cell.page.iter().any(|q| (q.y - y).abs() < 1e-9); + let radius = if at(40.0) && at(60.0) { + Some(20.0) + } else if cell.page.iter().any(|q| q.y < 40.0 - 1e-9 || q.y > 60.0 + 1e-9) { + Some(40.0) + } else { + None + }; + for (q, uv) in cell.page.iter().zip(&cell.uv) { + // Straight on, a corner at page x that is r from the axis lies r asin(x / r) around it. + let fits = |r: f64| (uv.x - r * (q.x / r).clamp(-1.0, 1.0).asin()).abs() < 1e-6; + match radius { + Some(r) => { + assert!(fits(r), "{r}: {q:?} {uv:?}"); + let reach = if r < 30.0 { &mut narrow } else { &mut wide }; + *reach = reach.max(uv.x.abs()); + } + None => assert!(fits(20.0) || fits(40.0), "{q:?} {uv:?}"), + } + } + } + // The half seen from the front reaches a quarter turn each way: 10π at 20 from the axis, 20π + // at 40. + let quarter = std::f64::consts::FRAC_PI_2; + assert!((narrow - 20.0 * quarter).abs() < 1.0 && (wide - 40.0 * quarter).abs() < 2.0, "{narrow} {wide}"); +} +#[test] +fn with_the_axis_on_the_right_the_middle_of_the_side_faces_the_viewer() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 10.0)); + bp.line_to((0.0, 90.0)); + let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)); + let q = 40.0 * std::f64::consts::FRAC_PI_4; + // The side's reach around the axis either way of its middle: for 270° the middle is 225°, in + // the middle of the part from 180° to 270° that faces the viewer; a quarter turn faces away, + // and its middle is half the sweep. + for (angle, lo_expected, hi_expected) in [(270.0, -q, 5.0 * q), (90.0, -q, q)] { + let o = Revolve { map_surface: true, edge: Edge::Right, angle, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }; + let xs: Vec = revolve(&p, b, o).unwrap().iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.x)).collect(); + let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + assert!((lo - lo_expected).abs() < 1e-6 && (hi - hi_expected).abs() < 1e-6, "{angle}: {lo} {hi}"); + } + // A new graphic fits the part of the sweep that faces the viewer: 90° of a 270° sweep, 20° of + // a 200° one, all of a quarter turn that faces away. + for (angle, seen) in [(270.0f64, 90.0f64), (200.0, 20.0), (90.0, 90.0), (360.0, 180.0)] { + let (w, _) = revolve_surface_size(&p, b, Revolve { edge: Edge::Right, angle, ..Default::default() }).unwrap(); + assert!((w - 40.0 * seen.to_radians()).abs() < 1e-9, "{angle}: {w}"); + } + // A full turn: straight on, the axis is at x 40, a corner at page x lies 40 asin((x - 40) / 40) + // around it, and the front half shows its outside. + let faces = revolve(&p, b, Revolve { map_surface: true, edge: Edge::Right, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let cells: Vec<_> = faces.iter().filter_map(|f| f.surface.as_ref()).collect(); + assert!(cells.iter().any(|c| c.outward)); + for cell in &cells { + let mean = cell.uv.iter().map(|p| p.x).sum::() / cell.uv.len() as f64; + assert_eq!(cell.outward, mean.abs() < 40.0 * std::f64::consts::FRAC_PI_2, "{mean}"); + if cell.outward { + for (q, uv) in cell.page.iter().zip(&cell.uv) { + assert!((uv.x - 40.0 * ((q.x - 40.0) / 40.0).clamp(-1.0, 1.0).asin()).abs() < 1e-6, "{q:?} {uv:?}"); + } + } + } +} +#[test] +fn only_the_longest_contour_of_a_compound_profile_carries_the_side_surface() { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.line_to((40.0, 90.0)); + bp.move_to((20.0, 30.0)); + bp.line_to((20.0, 50.0)); + let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)); + let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let short = |f: &Face| f.points.iter().all(|q| q.y > 29.0 && q.y < 51.0); + assert!(faces.iter().any(|f| !f.cap && short(f)) && faces.iter().any(|f| !f.cap && !short(f))); + for f in faces.iter().filter(|f| !f.cap) { + assert_eq!(f.surface.is_some(), !short(f), "{:?}", f.points); + } + // The side's size is that contour's: with the longest contour 10 from the axis and a shorter + // one 40 from it, the width is half a turn at 10 and the length the longest contour's 80. + let mut bp = BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.line_to((10.0, 90.0)); + bp.move_to((40.0, 30.0)); + bp.line_to((40.0, 50.0)); + let (w, h) = revolve_surface_size(&PathData::from_bezpath(&bp), b, Revolve::default()).unwrap(); + assert!((w - 10.0 * std::f64::consts::PI).abs() < 1e-9 && (h - 80.0).abs() < 1e-3, "{w} {h}"); +} +/// The square ring of [`a_ring_profile_is_measured_from_its_outer_side_at_mid_height`] with an +/// anchor in the middle of its inner side. +fn split_square() -> [Point; 6] { + [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0), (20.0, 40.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y)) +} +#[test] +fn a_ring_profile_is_measured_from_its_outer_side_at_mid_height() { + // A square from 20 to 40 from the axis, starting at its top inner corner and running to the + // right first (down the outer side). + let ring = [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(along_profile(&ring, 0.0, true), vec![[-30.0, -10.0], [-10.0, 10.0], [10.0, 30.0], [30.0, 50.0]]); + // Wound the other way the outer side still runs down the page as y increases. The cut is at + // an inner corner, so each piece's y runs one way across it. + let back = [(20.0, 30.0), (20.0, 50.0), (40.0, 50.0), (40.0, 30.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(along_profile(&back, 0.0, true), vec![[50.0, 30.0], [30.0, 10.0], [10.0, -10.0], [-10.0, -30.0]]); + // The cut falls half way along a long inner side: Revolve puts an anchor there first, and y + // then runs half the contour's length each way. + let tall = [(20.0, 0.0), (40.0, 0.0), (40.0, 100.0), (20.0, 100.0), (20.0, 0.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(ring_cut(&tall, 0.0), Some((3, 0.5))); + assert_eq!(ring_cut(&ring, 0.0), Some((3, 0.5))); + assert_eq!(ring_cut(&split_square(), 0.0), None); + assert_eq!(along_profile(&split_square(), 0.0, true), vec![[-30.0, -10.0], [-10.0, 10.0], [10.0, 30.0], [30.0, 40.0], [-40.0, -30.0]]); + let split = [(20.0, 0.0), (40.0, 0.0), (40.0, 100.0), (20.0, 100.0), (20.0, 50.0), (20.0, 0.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(along_profile(&split, 0.0, true), vec![[-70.0, -50.0], [-50.0, 50.0], [50.0, 70.0], [70.0, 120.0], [-120.0, -70.0]]); + let mut bp = BezPath::new(); + bp.move_to((20.0, 0.0)); + for p in [(40.0, 0.0), (40.0, 100.0), (20.0, 100.0)] { + bp.line_to(p); + } + bp.close_path(); + let (path, bounds, o) = + (PathData::from_bezpath(&bp), Rect::new(20.0, 0.0, 40.0, 100.0), Revolve { map_surface: true, offset: 20.0, ..Default::default() }); + let faces = revolve(&path, bounds, o).unwrap(); + // Without graphics the profile takes no anchor at the cut: its inner side stays one edge. + let plain = revolve(&path, bounds, Revolve { map_surface: false, ..o }).unwrap(); + assert!(plain.len() < faces.len() && plain.iter().all(|f| f.surface.is_none()), "{} {}", plain.len(), faces.len()); + let ys: Vec = faces.iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.y)).collect(); + assert!(!ys.is_empty() && ys.iter().all(|y| y.abs() <= 120.0 + 1e-9), "{ys:?}"); + let (w, h) = revolve_surface_size(&path, bounds, o).unwrap(); + assert!((h - 240.0).abs() < 1e-6 && (w - 40.0 * std::f64::consts::PI).abs() < 1e-9, "{w} {h}"); +} +#[test] fn malformed_and_degenerate_inputs_are_bounded() { let (p, b) = cylinder(); let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 148cf4fccc..e785c959d8 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -103,6 +103,35 @@ fn the_profiles_of_one_object_share_one_budget() { assert_eq!(revolve_curve_samples([&both], o), Err(refused)); } +#[test] +fn with_graphics_a_ring_at_the_face_limit_still_takes_the_edge_its_cut_adds() { + // A closed many-sided ring, 50 clear of the axis, with the middle of an edge at the middle of + // its outer side and, with an even number of edges, at the middle of its inner side. + let ring = |edges: usize| { + let anchors = (0..edges) + .map(|i| { + let (s, c) = ((i as f64 + 0.5) * std::f64::consts::TAU / edges as f64).sin_cos(); + Anchor::corner(Point::new(300.0 + 100.0 * c, 100.0 * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) + }; + // Tilted, so the flat edges at the top and bottom draw faces too. + let plain = Revolve { offset: 50.0, rotation_x: -20.0, ..Revolve::default() }; + let mapped = Revolve { map_surface: true, ..plain }; + // 1,280 edges of 128 faces each are the most that fit, with graphics or without. + for o in [plain, mapped] { + assert_eq!(revolve_curve_samples([&ring(1280)], o), Ok(CURVE_SAMPLES)); + assert!(revolve_curve_samples([&ring(1281)], o).unwrap_err().contains("reduce Segments")); + } + let faces = revolved(&ring(1280), plain).unwrap(); + assert_eq!(faces.len(), MAX_REVOLVE_FACES); + assert!(faces.iter().all(|f| f.surface.is_none())); + // With graphics the side is cut in the middle of the inner edge, which makes one edge more: + // its faces are taken past the limit. + assert_eq!(revolved(&ring(1280), mapped).unwrap().len(), MAX_REVOLVE_FACES + 128); +} + #[test] fn deeply_nested_contours_stop_the_search_for_holes() { // Concentric triangles: each tests every contour outside it. diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index db8f5b0df7..424242828b 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -302,6 +302,7 @@ mod tests { outline: None, cap: false, visibility_pieces: None, + surface: None, } } diff --git a/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs index af1194d78b..e09df663bb 100644 --- a/crates/tools/src/blendwidget.rs +++ b/crates/tools/src/blendwidget.rs @@ -5,8 +5,11 @@ //! the canvas draws from the halfway point to the widget grows with it. The upper one sets the //! blend's color shift (`colorShift`, -100 to 100) and the lower one its easing strength (`ramp`, //! 0 to 100). The lower one shows only when the steps follow a spine (a replaced or edited spine, -//! or Align to Path), where easing moves them; a blend made without a spine shows neither. A drag along the spine's direction at the press -//! changes the value by one per screen pixel, starting from the value at the press, and the arrow +//! or Align to Path), where easing moves them. A blend made without a spine shows the upper one +//! alone, placed in the same way against the straight lines between its key centers that its steps +//! are laid out along ([`blend_layout_line`]); the canvas does not draw those lines. A drag along +//! the spine's direction at the press changes the value by one per screen pixel, starting from the +//! value at the press, and the arrow //! keys during the drag change it by one (Shift: by ten); Escape or a switch to another tool //! cancels the drag and puts the value back. Each drag previews //! `object.blend.setEasing` and is one undo step, or none when the value ends where it started; a @@ -14,7 +17,7 @@ //! from the same geometry. use serde_json::json; -use vectorcraft_doc::live::{Spine, blend_spine}; +use vectorcraft_doc::live::{Spine, blend_layout_line}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Point, Rect, Vec2}; @@ -69,7 +72,8 @@ impl BlendControl { pub struct BlendWidget { pub id: NodeId, pub control: BlendControl, - /// The spine's halfway point, in the document. + /// The spine's halfway point, in the document (for a blend without a spine, the halfway point of + /// the line its steps are laid out along). pub on: Point, /// The widget's center in the document: [`OFFSET_PX`] from `on`, or further out where other /// artwork crowds that spot. @@ -84,9 +88,10 @@ impl BlendWidget { /// The widgets of the selection at `zoom` (screen px per document point), with `up` the /// document direction of the screen's up: the Appearance widget, then the Easing widget. Empty /// unless the selection is one whole blend, visible and unlocked, with two or more keys and a - /// spine of some length, and no anchors are selected. Each widget sits at the first spot out - /// from the spine, in [`OFFSET_PX`] steps, that the boxes of fewer than three other visible - /// objects hold, at most nine steps out; where every spot is crowded it stays at the first. + /// spine (or, without one, a step layout line) of some length, and no anchors are selected. + /// Each widget sits at the first spot out from the spine, in [`OFFSET_PX`] steps, that the + /// boxes of fewer than three other visible objects hold, at most nine steps out; where every + /// spot is crowded it stays at the first. pub fn of(doc: &Document, selection: &Selection, zoom: f64, up: Vec2) -> Vec { let up_len = up.hypot(); let usable = |v: f64| v.is_finite() && v > 0.0; @@ -98,7 +103,7 @@ impl BlendWidget { if children.len() < 2 || !doc.is_editable(*id) { return vec![]; } - let Some(spine) = blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)) else { return vec![] }; + let Some(spine) = blend_layout_line(children, spec).and_then(|path| Spine::new(&path)) else { return vec![] }; if !usable(spine.length()) { return vec![]; } @@ -430,9 +435,60 @@ mod tests { // One key. let (d, _, s) = blend_doc(&[(100.0, 200.0)], eased()); assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "one key"); - // A blend made without a spine (Create Spine › Never), Align to Path included. - let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); - assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine"); + // Without a spine: locked or hidden, or keys on one spot. + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + for hide in [false, true] { + let mut d = d.clone(); + let n = d.node_mut(id).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine, hidden: {hide}"); + } + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine, no length"); + } + + /// A blend made without a spine (Create Spine › Never), Align to Path included, shows the + /// Appearance widget alone, at the halfway point by length of the straight lines between its + /// key centers. + #[test] + fn a_blend_without_a_spine_shows_the_appearance_widget_alone() { + let no_spine = || BlendSpec { no_spine: true, ..eased() }; + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert_eq!(w.iter().map(|w| w.control).collect::>(), [BlendControl::Appearance], "{w:?}"); + assert!(w[0].id == id && near(w[0].on, Point::new(200.0, 200.0)) && near(w[0].at, Point::new(200.0, 184.0)), "{w:?}"); + assert_eq!(w[0].value, -20.0); + // Halfway along by length, not the middle key: (0, 0), (100, 0), (100, 300) is 400 long. + let (d, _, s) = blend_doc(&[(0.0, 0.0), (100.0, 0.0), (100.0, 300.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(w.len() == 1 && near(w[0].on, Point::new(100.0, 100.0)), "{w:?}"); + // There the line runs down, so a drag down adds. + assert!(near(Point::new(w[0].along.x, w[0].along.y), Point::new(0.0, 1.0)), "{w:?}"); + // Two keys on one spot and a third away: the line still has length. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0), (300.0, 200.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(w.len() == 1 && near(w[0].on, Point::new(200.0, 200.0)), "{w:?}"); + } + + /// With the Selection or the Blend tool, a drag of a spineless blend's Appearance widget + /// previews the color shift alone, in one undo step. + #[test] + fn dragging_the_appearance_widget_of_a_blend_without_a_spine() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 184.0); + assert!(over_widget(&c, at) && !over_widget(&c, Point::new(200.0, 216.0)), "no Easing widget below"); + let tools: [Box; 2] = [Box::new(SelectionTool::default()), Box::new(crate::meshblend::BlendTool::default())]; + for mut t in tools { + assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(30.0, 0.0))); + assert_eq!( + a, + vec![Action::Begin(LABEL.into()), Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": 10.0}))] + ); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at + Vec2::new(30.0, 0.0))), vec![Action::Commit]); + } } /// A drag of the Easing widget along the spine changes the ramp by one per pixel from its diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 010e423743..2b6cef4532 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -49,6 +49,42 @@ fn lp_part_at<'a>(doc: &'a Document, p: Point, tol: f64, edge: bool, takes: &dyn best.map(|(_, g, e)| (g, Some(e))) } +/// The face and edge of a Live Paint group at `p`, among the groups `takes` takes, by the rules of +/// [`lp_part_at`], in one walk of the document. Inside a visible face of a group, the face is the +/// front-most such group's visible face under `p`, and with `edge` the edge is that group's visible +/// edge nearest `p` within `tol`. Outside every visible face there is no face, and with `edge` the +/// edge is the visible edge nearest `p` within `tol` among the groups (the front-most group's on a +/// tie). +fn lp_face_and_edge<'a>( + doc: &'a Document, + p: Point, + tol: f64, + edge: bool, + takes: &dyn Fn(NodeId) -> bool, +) -> (Option<&'a Arc>, Option<&'a Arc>) { + let mut in_face: Option<(&Arc, Option<&Arc>)> = None; + let mut best: Option<(f64, &Arc)> = None; + doc.walk(|n| { + if !is_live_paint(n) || !takes(n.id) { + return; + } + let near = if edge { visible_edge_near(n, p, tol) } else { None }; + if let Some(f) = visible_face_at(n, p) { + in_face = Some((f, near)); + } + if let Some(e) = near + && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && best.is_none_or(|b| d <= b.0) + { + best = Some((d, e)); + } + }); + match in_face { + Some((f, e)) => (Some(f), e), + None => (None, best.map(|b| b.1)), + } +} + /// 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 { @@ -62,17 +98,25 @@ fn lp_selectable(cx: &ToolContext, id: NodeId) -> bool { cx.doc.is_editable(id) && crate::direct::in_scope(cx.doc, cx.isolation, id) } +/// Whether every selected object is a Live Paint group (true with nothing selected). A selection +/// that also holds other paths has regions outside its groups' faces that a click fills, making +/// one Live Paint group of the selection. +fn lp_sel_all_live(cx: &ToolContext) -> bool { + cx.selection.objects.iter().all(|id| cx.doc.node(*id).is_some_and(is_live_paint)) +} + /// 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. +/// the groups `takes` takes), else with `cache` the region of the selection under `p` when the +/// selection is not only Live Paint groups ([`lp_sel_all_live`]). 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) { let path = part.and_then(|n| n.path_data()).map(|pd| pd.to_bezpath()); return path.map(|bp| if edge { LpHover::Edge(bp) } else { LpHover::Part(bp) }).unwrap_or_default(); } - let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); - if !sel_is_live && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) { + if !lp_sel_all_live(cx) + && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) + { return LpHover::Region(i); } LpHover::None @@ -125,7 +169,8 @@ enum LpPaint { /// Shift-click fills the face. With both on, a click paints the edge nearest the pointer when one /// is within 4 pixels, else it fills the face, and Shift-click fills the face. With both off, a /// click paints nothing. With Highlight off, the face or edge under the pointer is not -/// highlighted. +/// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints +/// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -202,21 +247,68 @@ impl LivePaintBucketTool { } /// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with - /// Highlight off, else the face or edge a click there paints. + /// Highlight off, else the face or edge a click there paints. With Highlight off the selection's + /// map is still made, for the cursor ([`Self::paints_at`]). fn hover_at(&mut self, cx: &ToolContext, p: Point, shift: bool, alt: bool) -> LpHover { - if alt || !self.highlight { + if alt { return LpHover::None; } - let takes = |id| lp_visible(cx, id); - match self.paints(shift) { - LpPaint::Nothing => LpHover::None, - LpPaint::Fills => lp_hover(cx, Some(&mut self.cache), p, false, &takes), - // A click paints no region of the selection, so none is highlighted. - LpPaint::Strokes => lp_hover(cx, None, p, true, &takes), - LpPaint::Both => match lp_part_at(cx.doc, p, cx.tol(4.0), true, &takes) { - Some((_, Some(e))) => e.path_data().map(|pd| LpHover::Edge(pd.to_bezpath())).unwrap_or_default(), - _ => lp_hover(cx, Some(&mut self.cache), p, false, &takes), - }, + let target = self.target_at(cx, p, shift); + if self.highlight { target } else { LpHover::None } + } + + /// What a click at `p` reaches, Shift held or not: whether it fills a face and paints an edge, + /// with the face and the edge of a Live Paint group there ([`lp_face_and_edge`]). The document + /// is walked once: the highlight and the cursor are asked for on every pointer update. + fn parts_at<'a>(&self, cx: &ToolContext<'a>, p: Point, shift: bool) -> (bool, Option<&'a Arc>, Option<&'a Arc>) { + let (wants_face, wants_edge) = match self.paints(shift) { + LpPaint::Nothing => return (false, None, None), + LpPaint::Fills => (true, false), + LpPaint::Strokes => (false, true), + LpPaint::Both => (true, true), + }; + let (face, edge) = lp_face_and_edge(cx.doc, p, cx.tol(4.0), wants_edge, &|id| lp_visible(cx, id)); + (wants_face, face, edge) + } + + /// The face or edge a click at `p` paints, Shift held or not. With Paint Strokes alone, a click + /// paints no region of the selection, so none is highlighted. + fn target_at(&mut self, cx: &ToolContext, p: Point, shift: bool) -> LpHover { + let (wants_face, face, edge) = self.parts_at(cx, p, shift); + let path = |n: &Arc| n.path_data().map(|pd| pd.to_bezpath()); + if let Some(e) = edge { + return path(e).map(LpHover::Edge).unwrap_or_default(); + } + if !wants_face { + return LpHover::None; + } + if let Some(f) = face { + return path(f).map(LpHover::Part).unwrap_or_default(); + } + if !lp_sel_all_live(cx) + && let Some(i) = self.cache.ready(cx, true).and_then(|m| m.region_at(p)) + { + return LpHover::Region(i); + } + LpHover::None + } + + /// The cursor for a click at `p`, Shift held or not: [`Cursor::LivePaintBucketEdge`] where it + /// paints an edge of a Live Paint group, [`Cursor::LivePaintBucketFace`] where it fills a face + /// of one or a region of the selected paths, else [`Cursor::LivePaintBucket`]. A region counts + /// once the selection's map is made (the pointer moving over it makes it), as it is highlighted. + fn paints_at(&self, cx: &ToolContext, p: Point, shift: bool) -> Cursor { + let (wants_face, face, edge) = self.parts_at(cx, p, shift); + let edge = edge.is_some(); + let region = + || !lp_sel_all_live(cx) && self.cache.key.as_ref() == Some(&RegionCache::key_for(cx, true)) && self.cache.map.region_at(p).is_some(); + let face = wants_face && !edge && (face.is_some() || region()); + if edge { + Cursor::LivePaintBucketEdge + } else if face { + Cursor::LivePaintBucketFace + } else { + Cursor::LivePaintBucket } } } @@ -315,8 +407,8 @@ impl Tool for LivePaintBucketTool { lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32) } - fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { - if m.alt { Cursor::Eyedropper } else { Cursor::LivePaintBucket } + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + if m.alt { Cursor::Eyedropper } else { self.paints_at(cx, p, m.shift) } } fn options(&self) -> Value { diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index eeaf8f0ea7..89ce04eb3d 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1140,6 +1140,103 @@ fn bucket_options_choose_what_a_click_paints() { assert_eq!(t.options()["paintFills"], false); } +/// The cursor says what a click paints: a filled bucket over a face it fills, a brush over an +/// edge it paints, an empty bucket where it paints nothing, under each Paint Fills / Paint Strokes +/// setting and Shift; Alt keeps the Eyedropper, and Use Precise Cursors turns each into the +/// crosshair. +#[test] +fn bucket_cursor_shows_what_a_click_paints() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let at = |t: &LivePaintBucketTool, x: f64, m: Mods| t.cursor(&cx, Point::new(x, 25.0), m); + let (face, edge, none) = (Cursor::LivePaintBucketFace, Cursor::LivePaintBucketEdge, Cursor::LivePaintBucket); + let mut t = LivePaintBucketTool::default(); + // Paint Fills alone: the face, and with Shift the edge in reach. + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default()), at(&t, 400.0, Mods::default())], [face, face, none]); + assert_eq!([at(&t, 51.0, shift), at(&t, 75.0, shift)], [edge, none]); + assert_eq!([at(&t, 51.0, alt), at(&t, 400.0, alt)], [Cursor::Eyedropper, Cursor::Eyedropper]); + // Paint Strokes alone: the edge in reach, nothing farther away; Shift fills the face. + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(true)); + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default())], [edge, none]); + assert_eq!([at(&t, 51.0, shift), at(&t, 75.0, shift)], [face, face]); + // Both: the edge in reach, else the face; Shift fills the face. + t.set_option("paintFills", &json!(true)); + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default()), at(&t, 400.0, Mods::default())], [edge, face, none]); + assert_eq!(at(&t, 51.0, shift), face); + // Both off: a click paints nothing. + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(false)); + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, shift)], [none, none]); + // Highlight off: the cursor still says what a click paints. + let mut t = LivePaintBucketTool::default(); + t.set_option("highlight", &json!(false)); + assert_eq!([at(&t, 75.0, Mods::default()), at(&t, 51.0, shift)], [face, edge]); + for c in [face, edge, none] { + assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); + assert_eq!(c.stand_in(), None, "{c:?} is drawn"); + } +} + +/// Over a region of selected ordinary paths the cursor is the filled bucket once the pointer has +/// moved there (the move makes the selection's map), with Highlight on or off; with Paint Strokes +/// alone, a click there paints nothing and the bucket stays empty. +#[test] +fn bucket_cursor_fills_a_region_of_the_selected_paths() { + 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 pos = Point::new(75.0, 50.0); + for highlight in [true, false] { + let mut t = LivePaintBucketTool::default(); + t.set_option("highlight", &json!(highlight)); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket, "no map yet, highlight {highlight}"); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + t.poll(&cx, true); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace, "highlight {highlight}"); + assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::LivePaintBucket, "highlight {highlight}"); + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(true)); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket, "highlight {highlight}"); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)).is_empty()); + } +} + +/// With a Live Paint group and ordinary paths selected, a region of the paths outside the group +/// is highlighted and shows the filled bucket, as a click there fills it; with only the group +/// selected, the same point shows the empty bucket. +#[test] +fn bucket_fills_a_region_of_a_selection_that_mixes_a_group_and_paths() { + let (mut d, a, b) = two_rects_at(200.0); + let (g, _) = live_square(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0)); + let p = paint(); + let pos = Point::new(275.0, 50.0); + let mut s = Selection::default(); + s.set([g, a, b]); + let mixed = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&mixed, &ev(PointerKind::Move, pos.x, pos.y)); + t.poll(&mixed, true); + assert!(!t.overlays(&mixed).is_empty(), "the region is highlighted"); + assert_eq!(t.cursor(&mixed, pos, Mods::default()), Cursor::LivePaintBucketFace); + let acts = t.pointer(&mixed, &ev(PointerKind::Down, pos.x, pos.y)); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, v)] if id == "livePaint.fill" && v.get("ids").is_some()), "{acts:?}"); + let mut s = Selection::default(); + s.set([g]); + let live = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&live, &ev(PointerKind::Move, pos.x, pos.y)); + t.poll(&live, true); + assert!(t.overlays(&live).is_empty()); + assert_eq!(t.cursor(&live, pos, Mods::default()), Cursor::LivePaintBucket); +} + #[test] fn bucket_strokes_alone_paints_and_highlights_no_region_of_the_selection() { let (d, a, b) = two_rects(); @@ -1269,7 +1366,7 @@ fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() { let alt = Mods { alt: true, ..Default::default() }; let both = Mods { alt: true, shift: true, ..Default::default() }; let pos = Point::new(75.0, 25.0); - assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); assert!(!t.overlays(&cx).is_empty()); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0).with_mods(alt)); @@ -1282,7 +1379,7 @@ fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() { ); assert!(t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0).with_mods(both)).is_empty()); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); - assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace); assert!(!t.overlays(&cx).is_empty()); } diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 741e962290..e6db6069c7 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -117,6 +117,53 @@ impl GlyphSnap { } } +/// The Pixel Snapping Options (`view.snapToPixel.options`): which edits View › Snap to Pixel lands +/// on whole pixels. All on by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PixelSnap { + /// While Drawing: points placed by the drawing tools, ruler origins and measurements. + pub drawing: bool, + /// While Moving: the selection, its anchors and segments, artboards and ruler guides moved. + pub moving: bool, + /// While Scaling: the edges a bounding-box, Free Transform, Scale tool, artboard or slice + /// handle drags. + pub scaling: bool, +} + +impl Default for PixelSnap { + fn default() -> Self { + Self { drawing: true, moving: true, scaling: true } + } +} + +impl PixelSnap { + /// These options with the ones `p` names (booleans), or why `p` can't set them. + pub fn merged(self, p: &serde_json::Value) -> Result { + let mut o = serde_json::to_value(self).map_err(|e| e.to_string())?; + match (p, o.as_object_mut()) { + (serde_json::Value::Null, _) => {} + (serde_json::Value::Object(given), Some(cur)) => { + for (k, v) in given { + let slot = cur.get_mut(k).ok_or_else(|| format!("unknown Pixel Snapping option `{k}`"))?; + *slot = serde_json::Value::Bool(v.as_bool().ok_or_else(|| format!("`{k}` must be true or false"))?); + } + } + _ => return Err("the Pixel Snapping options must be an object".into()), + } + serde_json::from_value(o).map_err(|e| e.to_string()) + } +} + +/// The kind of edit a snap serves, which picks the Pixel Snapping option that applies to it +/// ([`ToolContext::snaps_to_pixel`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PixelOp { + Drawing, + Moving, + Scaling, +} + /// Read-only context a tool sees. pub struct ToolContext<'a> { pub doc: &'a Document, @@ -141,8 +188,11 @@ pub struct ToolContext<'a> { pub snap_to_grid: bool, /// Bounding box shown (View → Show/Hide Bounding Box). pub show_bbox: bool, - /// View → Snap to Pixel. + /// View → Snap to Pixel. Which edits it snaps is [`Self::pixel_snap`]'s choice + /// ([`Self::snaps_to_pixel`]). pub snap_to_pixel: bool, + /// The Pixel Snapping Options: whether Snap to Pixel snaps drawing, moving and scaling. + pub pixel_snap: PixelSnap, /// View → Snap to Point: with Smart Guides off, dragged selections, drawn points and picked /// points (a transform's reference point) land on anchors and ruler guides within /// [`Self::snap_tolerance`]. @@ -342,6 +392,16 @@ impl ToolContext<'_> { let (dist, ang, dx, dy) = xform::measure(a, b); format!("D: {}\nAngle: {ang:.1}°\n{}", self.len(dist), self.offset_label(dx, dy)) } + /// Does View › Snap to Pixel land an edit of kind `op` on whole pixels: is it on, with the + /// Pixel Snapping option for `op` on too? + pub fn snaps_to_pixel(&self, op: PixelOp) -> bool { + self.snap_to_pixel + && match op { + PixelOp::Drawing => self.pixel_snap.drawing, + PixelOp::Moving => self.pixel_snap.moving, + PixelOp::Scaling => self.pixel_snap.scaling, + } + } /// What Snap to Grid snaps to: the grid's subdivisions (or its lines without any). pub fn grid_step(&self) -> f64 { self.doc.grid.spacing / self.doc.grid.subdivisions.max(1) as f64 diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index a59bc28af0..d5cdd05bc5 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -141,8 +141,13 @@ pub enum Cursor { Knife, /// The Shaper tool. Shaper, - /// The Live Paint Bucket. + /// The Live Paint Bucket where a click paints nothing: an empty paint bucket. LivePaintBucket, + /// The Live Paint Bucket where a click fills a face (of a Live Paint group, or a region of + /// the selected paths): a filled paint bucket. + LivePaintBucketFace, + /// The Live Paint Bucket where a click paints an edge of a Live Paint group: a brush. + LivePaintBucketEdge, /// The Measure tool. Measure, /// The Print Tiling tool. @@ -280,6 +285,8 @@ impl Cursor { | Cursor::Knife | Cursor::Shaper | Cursor::LivePaintBucket + | Cursor::LivePaintBucketFace + | Cursor::LivePaintBucketEdge | Cursor::Measure | Cursor::PrintTiling | Cursor::SymbolSprayer @@ -320,7 +327,6 @@ impl Cursor { | Cursor::Join | Cursor::Knife | Cursor::Shaper - | Cursor::LivePaintBucket | Cursor::Measure | Cursor::ObjectsOnPath | Cursor::PrintTiling diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 362262b488..107eb34aeb 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -439,7 +439,10 @@ designer = false [[cursor]] id = "Eyedropper" group = "Colour and gradient" -shown_when = ["The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead."] +shown_when = [ + "The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead.", + "The Live Paint Bucket with Alt held (a click samples paint).", +] hotspot = "The pipette's tip." look = "A pipette." @@ -475,8 +478,32 @@ hotspot = "Where the mesh point goes." [[cursor]] id = "LivePaintBucket" group = "Colour and gradient" -shown_when = ["The Live Paint Bucket: a click fills a face (or an edge) of a Live Paint group."] -hotspot = "Where the paint pours out." +shown_when = [ + "The Live Paint Bucket where a click paints nothing: away from Live Paint groups and the selected paths' regions, or over a part the Paint Fills and Paint Strokes options (and Shift) leave unpainted.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The drop the paint pours onto." +look = "An empty paint bucket tipped toward the hotspot, a small drop on it. LivePaintBucketFace is the same bucket filled." + +[[cursor]] +id = "LivePaintBucketFace" +group = "Colour and gradient" +shown_when = [ + "The Live Paint Bucket over a face of a Live Paint group, or a region of the selected paths, that a click fills.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The drop the paint pours onto." +look = "LivePaintBucket filled, with a larger drop on the hotspot." + +[[cursor]] +id = "LivePaintBucketEdge" +group = "Colour and gradient" +shown_when = [ + "The Live Paint Bucket over an edge of a Live Paint group that a click paints with the stroke.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The brush's tip." +look = "A brush pointing down-left: bristles at the hotspot, then a white ferrule and a dark handle." # ── Width, blend, slice and shape builder ───────────────────────────────────────────────────────── @@ -599,14 +626,14 @@ look = "PencilStraight's badge with a small filled square at the line's lower en [[cursor]] id = "Paintbrush" group = "Drawing and editing tools" -shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it."] +shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it, except that Alt held at the press with a path selected that the Smooth tool can act on smooths that path (see Smooth)."] hotspot = "The bristle tip." [[cursor]] id = "PaintbrushClose" group = "Drawing and editing tools" shown_when = [ - "The Paintbrush tool with Alt held, before a press and while drawing: releasing the mouse button with Alt still held closes the path.", + "The Paintbrush tool with Alt held while drawing, or before a press when no path is selected that the Smooth tool can act on (with one selected, Smooth shows): releasing the mouse button with Alt still held closes the path.", "The Paintbrush tool while a stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.", ] hotspot = "The bristle tip." @@ -632,6 +659,8 @@ group = "Drawing and editing tools" shown_when = [ "The Smooth tool: a drag along a selected path smooths it.", "The Pencil tool with Alt key toggles to Smooth Tool on: with Alt held before a stroke begins to draw, and through a drag that smooths.", + "The Paintbrush tool with Alt held before a press while a path is selected that the Smooth tool can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), and through a drag that smooths.", + "The Blob Brush tool with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.", ] hotspot = "The tip." diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 7a3d3de765..14f7f85881 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -242,6 +242,8 @@ impl DimensionTool { /// a link to its path); else where Smart Guides put it (another object's anchor or centre, in /// line with one, or for the second point on a construction guide through the first), its /// guides showing; else on the closest point of the line under the pointer; else `p` itself. + /// A point the guides leave where the pointer is (right on a guide line) stays there, and with + /// Snap to Pixel or Snap to Grid on a point stays where they place it. fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option) { let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false)); let guided = self.snap.hover(cx, p, &[], leave.as_ref()); @@ -249,7 +251,7 @@ impl DimensionTool { self.snap.clear(); return (point, Some(reference)); } - if guided.distance(p) > 1e-9 { + if guided.distance(p) > 1e-9 || !self.snap.guides().is_empty() || cx.snap_to_pixel || cx.snap_to_grid { return (guided, None); } // Not on an anchor or a guide: snap to the closest point of the line (#2432). @@ -844,6 +846,54 @@ mod tests { } } + /// A point that Smart Guides leave where the pointer already is (the pointer right on a + /// construction guide or in line with a target) stays on the guide the overlays show. It + /// does not move to the nearest point of a segment within the pick's reach. Off the guides, + /// a point near a segment still lands on it. + #[test] + fn a_point_on_a_guide_stays_on_the_guide() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let click = |t: &mut DimensionTool, x: f64, y: f64| { + t.pointer(&c, &PointerEvent::new(PointerKind::Move, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)); + }; + let mut t = DimensionTool::default(); + // In line with the rectangle's center, 6 units right of its right edge. + assert_eq!(t.snap_linear(&c, Point::new(206.0, 150.0)), (Point::new(206.0, 150.0), None)); + assert!(!t.snap.guides().is_empty()); + // Off the guides, beside the edge: the closest point of the edge. + let (on_edge, reference) = t.snap_linear(&c, Point::new(206.0, 170.5)); + assert!(on_edge.distance(Point::new(200.0, 170.5)) < 1e-4 && reference.is_none(), "{on_edge:?}"); + // The first point, above the rectangle, then straight down along the vertical + // construction guide through it, past the right edge. + click(&mut t, 206.0, 50.0); + assert_eq!(t.points, vec![(Point::new(206.0, 50.0), None)]); + for y in [150.0, 170.5, 190.0] { + assert_eq!(t.snap_linear(&c, Point::new(206.0, y)), (Point::new(206.0, y), None), "on the construction guide at y = {y}"); + assert!(!t.snap.guides().is_empty()); + } + click(&mut t, 206.0, 170.5); + let dim = t.pending.as_ref().expect("both points were picked"); + assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 170.5))); + // The second point, off the edge, is held against the rectangle's anchors. + let Some(DimensionSource::Points { free_b: Some(free), .. }) = &dim.source else { panic!("{:?}", dim.source) }; + assert!(matches!(free, FreePoint::Anchors { .. }), "{free:?}"); + // A pointer already on a gridline crossing or on a whole pixel stays there too, and one + // off the grid lands on the grid, not on the edge. + let grid = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let step = grid.grid_step(); + let on_grid = Point::new((200.0 / step).ceil() * step, (170.0 / step).round() * step); + assert!(on_grid.x > 200.0 + 1e-6 && on_grid.x - 200.0 < 8.0, "{on_grid:?} is off the edge, within the pick's reach"); + assert_eq!(DimensionTool::default().snap_linear(&grid, on_grid), (on_grid, None)); + let off_grid = on_grid + Vec2::new(-step * 0.3, 0.2); + assert_eq!(DimensionTool::default().snap_linear(&grid, off_grid), (on_grid, None)); + let pixel = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) }; + assert_eq!(DimensionTool::default().snap_linear(&pixel, Point::new(206.0, 170.0)), (Point::new(206.0, 170.0), None)); + } + #[test] fn placement_press_previews_without_a_prior_move() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 3addd48785..3030414b38 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -66,7 +66,7 @@ use crate::meshedit::{MeshEdit, grid_of}; use crate::pathtype::{self, BracketDrag, near_bracket}; use crate::rulerguide::GuideEdit; use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at, selection_bounds}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, Mods, Overlay, PixelOp, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Debug)] enum State { @@ -1465,11 +1465,11 @@ impl DirectSelectionTool { } out.push(Action::Begin("Move".into())); self.state = State::MoveAnchors { start, grab, began: true, segment }; - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.anchor_snap = Some(PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); self.line_end = line_end_leave(cx, grab); // A segment held by a point along it lands its anchors on the grid (the box round // them, an edge on a gridline on each axis), not that point (#3835). - let grid = segment && (cx.snap_to_grid || cx.snap_to_pixel); + let grid = segment && (cx.snap_to_grid || cx.snaps_to_pixel(PixelOp::Moving)); self.partial_grid = grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); } @@ -1505,8 +1505,8 @@ impl DirectSelectionTool { // 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_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snaps_to_pixel(PixelOp::Moving)); self.partial_grid = grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); let copy = ev.mods.alt && self.partial_snap.is_none(); @@ -1514,8 +1514,8 @@ impl DirectSelectionTool { 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. - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); - self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + self.anchor_snap = Some(PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.copy_snap = Some(PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); // As a move with the Selection tool does ([`MoveSnap::new`]). let back = cx.smart_guides && !cx.snap_to_grid && cx.snap_to_last_location; self.last_location = back.then(|| (Targets::last_location(cx), selection_bounds(cx))); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 0ac443c731..7ce48bdc1e 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -634,6 +634,33 @@ fn snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor( assert!(labels.is_empty(), "{labels:?}"); } +/// Pixel Snapping Options › While Moving off: a direct-selected anchor dragged with Snap to Pixel +/// on keeps its fractional offset; with the option on it lands on whole pixels. +#[test] +fn an_anchor_drag_follows_the_while_moving_option() { + use crate::PixelSnap; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let s = anchors_of(id, &[(0, 0)]); + let p = paint(); + let moved = |pixel_snap: PixelSnap| { + let c = ToolContext { snap_to_pixel: true, pixel_snap, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.3, 100.4)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 106.3, 105.4)); + let Some(Action::Preview(command, v)) = a.last() else { panic!("{a:?}") }; + assert_eq!(command, "path.moveAnchors"); + Vec2::new(v["dx"].as_f64().unwrap_or(f64::NAN), v["dy"].as_f64().unwrap_or(f64::NAN)) + }; + let m = moved(PixelSnap { moving: false, ..PixelSnap::default() }); + assert!((m - Vec2::new(6.0, 5.0)).hypot() < 1e-9, "{m:?}"); + let m = moved(PixelSnap { drawing: false, scaling: false, moving: true }); + assert!((m - Vec2::new(5.7, 4.6)).hypot() < 1e-9, "{m:?}"); +} + /// A click (no drag) on an anchor that is selected with others, whether the whole path is or /// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the /// selection so a drag moves it all, the release without a drag narrows it. Shift still diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 123082aa25..f586650200 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -34,6 +34,11 @@ pub const MIN_FIDELITY: f64 = 0.01; /// Fidelity at the highest zoom (64000 %), whose screen pixels the tools send in points (#5729). pub const MIN_FIT_TOLERANCE: f64 = MIN_FIDELITY / 640.0; +/// The coarsest tolerance (points) `path.freehand` fits a Pencil or Paintbrush stroke at: the highest +/// Fidelity the tools and their Tool Options take (100 px) at the lowest zoom (3.13 %), about 3195 pt. +/// A change to either range changes this bound with it. +pub const MAX_FIT_TOLERANCE: f64 = 100.0 / 0.0313; + /// The lowest Fidelity (points) the Smooth tool takes. pub const SMOOTH_MIN_FIDELITY: f64 = 0.05; diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index e4811418c7..d426044379 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -42,6 +42,13 @@ const PREVIEW_FIDELITY_PX: f64 = 0.5; /// The Smooth tool's Fidelity until Smooth Tool Options sets another. const SMOOTH_FIDELITY: f64 = 2.5; +/// The `fidelity` (points) the Pencil and the Paintbrush send `path.freehand` for `px` screen pixels +/// at the current zoom, within the range the command takes +/// ([`super::MIN_FIT_TOLERANCE`]..[`super::MAX_FIT_TOLERANCE`]). +fn fit_tolerance(cx: &ToolContext, px: f64) -> f64 { + cx.tol(px).clamp(super::MIN_FIT_TOLERANCE, super::MAX_FIT_TOLERANCE) +} + pub struct GestureTool { /// How many points the last preview of the stroke had ([`crate::previews_at`]); 0 when it was /// fitted coarser than the stroke kept ([`PREVIEW_FIDELITY_PX`]). @@ -216,10 +223,10 @@ impl GestureTool { } /// Does a straight Knife cut snap to anchors and paths? Only with Smart Guides on, which Snap to - /// Pixel and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the Pencil's straight - /// line's do: the press, and the release unless Shift keeps it on a 45° step. + /// Pixel (for drawing) and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the + /// Pencil's straight line's do: the press, and the release unless Shift keeps it on a 45° step. fn knife_snaps(&self, cx: &ToolContext) -> bool { - self.id == "knife" && self.straight && cx.smart_guides && !cx.snap_to_pixel && !cx.snap_to_grid + self.id == "knife" && self.straight && cx.smart_guides && !cx.snaps_to_pixel(crate::PixelOp::Drawing) && !cx.snap_to_grid } /// Does the end of a straight line snap: the Pencil's Alt line's, or a straight Knife cut's @@ -460,7 +467,7 @@ impl GestureTool { pub fn freehand_params(&self, cx: &ToolContext, closed: bool) -> Value { let mut v = json!({ "points": self.stroke_json(), - "fidelity": cx.tol(self.fidelity), + "fidelity": fit_tolerance(cx, self.fidelity), "closed": closed, "style": if self.id == "paintbrush" { "brush" } else { "pencil" }, "fill": self.fill, @@ -654,7 +661,7 @@ impl Tool for GestureTool { if self.previews() && crate::previews_at(self.points.len()) { let mut v = self.freehand_params(cx, false); let fidelity = self.fidelity.max(PREVIEW_FIDELITY_PX); - v["fidelity"] = json!(cx.tol(fidelity)); + v["fidelity"] = json!(fit_tolerance(cx, fidelity)); // A preview fitted coarser than the stroke kept is stale (`deactivate`). self.previewed = if fidelity > self.fidelity { 0 } else { self.points.len() }; if !self.live_fit { diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index a0fe487289..f367040611 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -215,6 +215,52 @@ fn fidelity_is_in_screen_pixels_at_any_zoom() { } } +/// The highest Fidelity (100 px) at the lowest zoom (3.13 %) is sent, while the stroke is drawn and +/// at its release, as [`crate::draw2::MAX_FIT_TOLERANCE`], the coarsest tolerance `path.freehand` +/// reads. +#[test] +fn highest_fidelity_at_the_lowest_zoom_sends_the_coarsest_tolerance() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.zoom = 0.0313; + let stroke: Vec<_> = [(0.0, 0.0), (40.0, 20.0), (80.0, 0.0), (120.0, 20.0)].iter().map(|&(x, y)| (x / cx.zoom, y / cx.zoom)).collect(); + for id in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(id); + t.set_option("fidelity", &json!(100.0)); + let acts = drag(&mut t, &cx, &stroke, Mods::default()); + let sent: Vec<_> = + acts.concat().into_iter().filter_map(|a| if let Action::Preview(_, v) = a { v["fidelity"].as_f64() } else { None }).collect(); + assert_eq!(sent, [crate::draw2::MAX_FIT_TOLERANCE; 4], "{id}"); + } +} + +/// `path.freehand` rejects a `fidelity` outside `MIN_FIT_TOLERANCE..=MAX_FIT_TOLERANCE`, so at a zoom +/// beyond the view's range the Pencil and the Paintbrush send the nearer end of it, while the stroke +/// is drawn and at its release. +#[test] +fn fidelity_sent_stays_within_what_path_freehand_takes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let (lo, hi) = (crate::draw2::MIN_FIT_TOLERANCE, crate::draw2::MAX_FIT_TOLERANCE); + for (zoom, fidelity, release) in [(0.01, 100.0, hi), (1000.0, 0.01, lo)] { + cx.zoom = zoom; + let stroke: Vec<_> = [(0.0, 0.0), (40.0, 20.0), (80.0, 0.0), (120.0, 20.0)].iter().map(|&(x, y)| (x / zoom, y / zoom)).collect(); + for id in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(id); + t.set_option("fidelity", &json!(fidelity)); + let acts = drag(&mut t, &cx, &stroke, Mods::default()); + let sent: Vec<_> = + acts.concat().into_iter().filter_map(|a| if let Action::Preview(_, v) = a { v["fidelity"].as_f64() } else { None }).collect(); + assert!(sent.iter().all(|f| (lo..=hi).contains(f)), "{id} at {zoom}x: {sent:?}"); + assert_eq!(sent.last(), Some(&release), "{id} at {zoom}x: {sent:?}"); + } + } +} + /// The Blob Brush's Fidelity stays in points (#5729 is the Pencil's and the Paintbrush's): its /// stroke, and another tool chosen while it is drawn, send it as set at any zoom. #[test] @@ -1034,6 +1080,8 @@ fn paintbrush_alt_after_press_closes_without_smoothing_and_empty_selection_draws let selected_cx = cx(&d, &selected, &p); t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1).with_mods(alt)); + // While drawing, Alt shows the closing ring though the selection could be smoothed. + assert_eq!(t.cursor(&selected_cx, Point::new(pts[1].0, pts[1].1), alt), Cursor::PaintbrushClose); let last = t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Up, pts[2].0, pts[2].1).with_mods(alt)); assert!(matches!(last.first(), Some(Action::Preview(c, v)) if c == "path.freehand" && v["closed"] == true), "{last:?}"); let cx = cx(&d, &empty, &p); @@ -1041,7 +1089,8 @@ fn paintbrush_alt_after_press_closes_without_smoothing_and_empty_selection_draws assert!(matches!(last.last().and_then(|a| a.first()), Some(Action::Preview(c, v)) if c == "path.freehand" && v["closed"] == true), "{last:?}"); } -/// The Paintbrush shows Smooth before an Alt press only with a selected path. +/// The Paintbrush shows Smooth before an Alt press only with a selected path, and through the +/// drag that smooths it, with Alt let go too. #[test] fn paintbrush_alt_cursor_matches_selection_at_press() { let (d, id) = doc_with_rect(); @@ -1050,9 +1099,14 @@ fn paintbrush_alt_cursor_matches_selection_at_press() { let empty = Selection::default(); let p = paint(); let alt = Mods { alt: true, ..Default::default() }; - let t = GestureTool::new("paintbrush"); + let mut t = GestureTool::new("paintbrush"); assert_eq!(t.cursor(&cx(&d, &selected, &p), Point::new(50.0, 150.0), alt), Cursor::Smooth); assert_eq!(t.cursor(&cx(&d, &empty, &p), Point::new(50.0, 150.0), alt), Cursor::PaintbrushClose); + let selected_cx = cx(&d, &selected, &p); + t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt)); + t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt)); + assert_eq!(t.cursor(&selected_cx, Point::new(150.0, 150.0), alt), Cursor::Smooth); + assert_eq!(t.cursor(&selected_cx, Point::new(150.0, 150.0), Mods::default()), Cursor::Smooth); } /// A selected text object leaves Alt Paintbrush drawing a closed stroke with its close cursor. diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 6f0b50d4b5..006f498b6e 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -3,6 +3,7 @@ use super::{ Affine, GlyphSnap, GuideStyle, Handle, HitOptions, Line, NodeId, OrientedBox, Overlay, ParamCurve, ParamCurveDeriv, ParamCurveNearest, PathSeg, Point, Rect, ScreenFrame, ToolContext, Vec2, constrain_angle_from, hit_test, }; +use crate::PixelOp; /// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back /// settles where they meet (none when they don't nearby). @@ -100,11 +101,29 @@ pub struct PointSnap { /// The smart guide targets, or with Smart Guides off those of Snap to Point, and how near (in /// screen pixels) they pull; none with pixels or the grid snapping instead, or nothing on. targets: Option<(Targets, f64)>, + /// The kind of edit the point serves: whether Snap to Pixel rounds it + /// ([`ToolContext::snaps_to_pixel`]). + op: PixelOp, } impl PointSnap { + /// Snapping for a drawn point. pub fn new(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { - let targets = if cx.snap_to_pixel || cx.snap_to_grid { + Self::for_op(cx, PixelOp::Drawing, targets) + } + + /// Snapping for a moved point (an anchor, or the selection held by one). + pub fn moving(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { + Self::for_op(cx, PixelOp::Moving, targets) + } + + /// Snapping for a dragged handle that scales ([`Self::snap_scale`]). + pub fn scaling(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { + Self::for_op(cx, PixelOp::Scaling, targets) + } + + fn for_op(cx: &ToolContext, op: PixelOp, targets: impl FnOnce() -> Targets) -> Self { + let targets = if cx.snaps_to_pixel(op) || cx.snap_to_grid { None } else if cx.smart_guides { // Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812). @@ -116,7 +135,7 @@ impl PointSnap { } else { None }; - Self { targets } + Self { targets, op } } /// Snapping for a point placed anywhere, as a drawing tool's ([`DrawSnap`]) is: the art in the @@ -135,7 +154,7 @@ impl PointSnap { /// showing why. Pixels and the grid round it before Shift turns it onto its angle. pub fn snap_from(&self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> (Point, Vec) { let keep = |q: Point| from.and_then(|f| f.constrained(q)).unwrap_or(q); - if cx.snap_to_pixel { + if cx.snaps_to_pixel(self.op) { return (keep(Point::new(p.x.round(), p.y.round())), vec![]); } if cx.snap_to_grid { @@ -148,9 +167,10 @@ impl PointSnap { } /// Snap a bounding-box resize ([`Targets::snap_scale`]) as [`Self::snap`] snaps a point. With - /// Snap to Pixel on, the corner or side the handle moves lines up with whole pixels, and with - /// Snap to Grid on it lines up with the nearest gridline, however far that is; neither shows - /// guides. Otherwise the handle snaps to the targets [`Self::new`] gathered. + /// Snap to Pixel on for scaling, the corner or side the handle moves lines up with whole + /// pixels, and with Snap to Grid on it lines up with the nearest gridline, however far that + /// is; neither shows guides. Otherwise the handle snaps to the targets [`Self::scaling`] + /// gathered. pub fn snap_scale( &self, cx: &ToolContext, @@ -160,7 +180,7 @@ impl PointSnap { proportional: bool, from_center: bool, ) -> (Affine, Vec) { - let step = if cx.snap_to_pixel { + let step = if cx.snaps_to_pixel(PixelOp::Scaling) { Some(1.0) } else if cx.snap_to_grid { Some(cx.grid_step()).filter(|s| s.is_finite() && *s > 0.0) @@ -327,7 +347,15 @@ impl SnapKey { revision: cx.revision, 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], + view: [ + cx.smart_guides, + cx.snap_to_point, + cx.snaps_to_pixel(PixelOp::Drawing), + cx.snap_to_grid, + cx.guides, + cx.snap_tangent, + cx.snap_perpendicular, + ], snap_to_glyph: (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap), style: GuideStyle::of(cx), tolerances: [cx.snapping_tolerance, cx.snap_tolerance], @@ -381,7 +409,7 @@ impl DrawSnap { /// Guides snapping tolerance (the Knife's straight cut). The targets serve this press only. pub fn press_on_paths(&mut self, cx: &ToolContext, p: Point) -> Point { let targets = Targets::for_drawing(cx.doc, &[], visible(cx), None).styled(cx).anchors_and_paths(); - self.targets = Some((None, PointSnap { targets: Some((targets, cx.snapping_tolerance)) })); + self.targets = Some((None, PointSnap { targets: Some((targets, cx.snapping_tolerance)), op: PixelOp::Drawing })); self.snap(cx, p, None) } diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 41b9f517ee..cb0fdfc1dc 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -168,13 +168,13 @@ impl Targets { /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). With Smart /// Guides › Snap to Last Location, a move (not a copy, which has nothing in `exclude`) goes back - /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel is on. + /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel snaps moves. pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { let mut t = Self::collect(cx.doc, exclude, None).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } - t.last_location = cx.snap_to_last_location && !cx.snap_to_pixel && !exclude.is_empty(); + t.last_location = cx.snap_to_last_location && !cx.snaps_to_pixel(crate::PixelOp::Moving) && !exclude.is_empty(); t } @@ -189,9 +189,9 @@ impl Targets { /// Targets that hold nothing but Smart Guides › Snap to Last Location ([`Self::snap_back`]), /// styled as `cx` asks: for a move of the selection whose other snaps come from other targets, /// such as paths selected whole that Direct Selection moves held by an anchor. Snap to Pixel - /// turns it off, as it does every Smart Guides snap. + /// snapping moves turns it off, as it does every Smart Guides snap. pub fn last_location(cx: &ToolContext) -> Self { - Self { last_location: cx.snap_to_last_location && !cx.snap_to_pixel, ..Self::default() }.styled(cx) + Self { last_location: cx.snap_to_last_location && !cx.snaps_to_pixel(crate::PixelOp::Moving), ..Self::default() }.styled(cx) } /// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides › diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index d73cd2297f..ee752dd968 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -58,7 +58,7 @@ mod context; mod cursor; mod input; -pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, ScreenFrame, ToolContext}; +pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, PixelOp, PixelSnap, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; @@ -542,6 +542,7 @@ pub(crate) mod testutil { snap_to_grid: false, show_bbox: true, snap_to_pixel: false, + pixel_snap: crate::PixelSnap::default(), snap_to_point: true, snap_tangent: true, snap_perpendicular: true, @@ -641,10 +642,11 @@ mod cursor_tests { ] { assert_eq!(cursor_at(id, &d, away), c, "{id}"); assert_eq!(c.precise(), Cursor::Crosshair, "{id}"); - // The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. + // The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. The + // Live Paint Bucket's bucket is drawn. let stand_in = match id { "mesh" => Some(Cursor::PenAdd), - "blobBrush" | "eraser" => None, + "blobBrush" | "eraser" | "livePaintBucket" => None, _ => Some(Cursor::Crosshair), }; assert_eq!(c.stand_in(), stand_in, "{id}"); diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index a90770d3fb..8e037443cb 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; use crate::guides::{PointSnap, Targets}; -use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; +use crate::{Action, Cursor, Overlay, PixelOp, PointerEvent, PointerKind, ToolContext}; /// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection /// tolerance (the newest of equals). @@ -123,7 +123,7 @@ struct GuideSnap { impl GuideSnap { fn new(cx: &ToolContext) -> Self { let targets = || Targets::collect(cx.doc, &[], None); - let targets = if cx.snap_to_pixel || cx.snap_to_grid { + let targets = if cx.snaps_to_pixel(PixelOp::Moving) || cx.snap_to_grid { None } else if cx.smart_guides { Some((targets().styled(cx), cx.snapping_tolerance)) @@ -136,7 +136,8 @@ impl GuideSnap { } /// Where a guide dragged to `v` (the x of a vertical one, the y of a horizontal one) goes: - /// with Shift on the nearest ruler tick, else on whole pixels, the grid or a target. + /// with Shift on the nearest ruler tick, else on whole pixels (Snap to Pixel's While Moving + /// option), the grid or a target. fn snap(&mut self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> f64 { self.snapped.clear(); if shift { @@ -144,7 +145,7 @@ impl GuideSnap { let origin = if vertical { origin.x } else { origin.y }; return origin + cx.unit.snap_to_ruler_tick(v - origin, cx.zoom); } - if cx.snap_to_pixel { + if cx.snaps_to_pixel(PixelOp::Moving) { return v.round(); } if cx.snap_to_grid { diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs index f80f31c776..b4b7c256d3 100644 --- a/crates/tools/src/rulerguide/tests.rs +++ b/crates/tools/src/rulerguide/tests.rs @@ -253,6 +253,24 @@ fn new_ruler_guide_alt_snaps_on_the_effective_axis() { } } +/// A guide dragged out of a ruler snaps to whole pixels with Snap to Pixel only while the Pixel +/// Snapping Options' While Moving is on; While Drawing and While Scaling do not change it. +#[test] +fn new_ruler_guide_follows_the_while_moving_option() { + use crate::PixelSnap; + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let off = ToolContext { smart_guides: false, snap_to_point: false, snap_to_pixel: true, ..cx(&d, &s, &p) }; + for (pixel_snap, expected) in + [(PixelSnap { moving: false, ..PixelSnap::default() }, 226.4), (PixelSnap { drawing: false, scaling: false, moving: true }, 226.0)] + { + let c = ToolContext { pixel_snap, ..off }; + let mut g = NewGuide::new(&c, true, None); + let actions = g.pointer(&c, &ev(PointerKind::Drag, 226.4, 237.6, Mods::default()), true); + assert_eq!(actions.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": expected}))), "{pixel_snap:?}"); + } +} + /// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its /// distance), not the edges' midpoints. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 05b81ae049..70060ebc2b 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -56,7 +56,7 @@ use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; use crate::shaperwidget; use crate::typewidget; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; +use crate::{Action, Cursor, Mods, Overlay, PixelOp, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; /// Direction is read on screen, even in a turned view. Within three pixels of the press's /// horizontal position, retain the previous rule so hand jitter does not flip it. @@ -228,8 +228,9 @@ fn distance_overlays(cx: &ToolContext, to: DistanceTo) -> Vec { /// Snap to Grid lands its edges on the grid (#740), taking over from the rest; otherwise Smart /// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the /// point it was grabbed by (an anchor pressed near, else the pressed point) on an anchor or a ruler -/// guide. Snap to Pixel puts its top-left on whole pixels. A copy (Alt, which can go down or up at -/// any time during the drag) leaves the original behind, so it snaps to the original too. +/// guide. Snap to Pixel puts its top-left on whole pixels while its While Moving option is on. A +/// copy (Alt, which can go down or up at any time during the drag) leaves the original behind, so +/// it snaps to the original too. pub(crate) struct MoveSnap { bounds: Option, targets: Option, @@ -314,7 +315,7 @@ impl MoveSnap { } } } - if cx.snap_to_pixel + if cx.snaps_to_pixel(PixelOp::Moving) && let Some(b) = self.bounds { d = Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0); @@ -388,7 +389,7 @@ impl MoveSnap { guides = ov; } } - if cx.snap_to_pixel + if cx.snaps_to_pixel(PixelOp::Moving) && let Some(b) = self.bounds { let at = Point::new(b.x0, b.y0) + d; @@ -1655,7 +1656,7 @@ impl SelectionTool { // What the handle moves snaps as a dragged point does: to pixels or the // grid, else with Smart Guides to the other objects, else with Snap to // Point to their anchors; the ruler guides with Snap to Point. - self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx))); + self.targets = Some(PointSnap::scaling(cx, || Targets::for_scale(cx))); self.last_backquote = false; self.last_box_pos = None; self.state = State::Scaling { handle, bx, areas }; diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index c41056b7aa..63ca697c7d 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -1794,6 +1794,51 @@ fn handle_drag_lands_what_it_moves_on_whole_pixels() { assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}"); } +/// Pixel Snapping Options › While Scaling off: a handle drag keeps the fractional size it is +/// dragged to with Snap to Pixel on, and turned back on, the drag lands on whole pixels again; While +/// Drawing and While Moving do not change it. +#[test] +fn handle_drag_follows_the_while_scaling_option() { + use crate::PixelSnap; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let drag = |pixel_snap: PixelSnap| { + let c = ToolContext { snap_to_pixel: true, pixel_snap, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0)); + scaled(&t.pointer(&c, &ev(PointerKind::Drag, 212.4, 230.7))) + }; + let r = drag(PixelSnap { scaling: false, ..PixelSnap::default() }); + assert!(near(r, Rect::new(100.0, 100.0, 212.4, 230.7)), "{r:?}"); + let r = drag(PixelSnap { drawing: false, moving: false, scaling: true }); + assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}"); +} + +/// Pixel Snapping Options › While Moving off: a move keeps its fractional offset with Snap to +/// Pixel on, and Smart Guides › Snap to Last Location works again for it. +#[test] +fn move_follows_the_while_moving_option() { + use crate::PixelSnap; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let pixel_snap = PixelSnap { moving: false, ..PixelSnap::default() }; + let c = ToolContext { snap_to_pixel: true, pixel_snap, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let snap = MoveSnap::new(&c); + let (dv, _) = snap.snap(&c, Point::new(150.0, 150.0), Vec2::new(3.3, 4.6), false); + assert!((dv - Vec2::new(3.3, 4.6)).hypot() < 1e-9, "{dv:?}"); + let on = ToolContext { pixel_snap: PixelSnap::default(), ..c }; + let (dv, _) = MoveSnap::new(&on).snap(&on, Point::new(150.0, 150.0), Vec2::new(3.3, 4.6), false); + assert!((dv - Vec2::new(3.0, 5.0)).hypot() < 1e-9, "{dv:?}"); + let back = ToolContext { smart_guides: true, snap_to_last_location: true, snapping_tolerance: 10.0, ..c }; + assert!(Targets::for_move(&back, &back.selection.objects).snap_back(None, Vec2::new(1.0, 1.0), back.snap_tol()).is_some()); + let pixels = ToolContext { pixel_snap: PixelSnap::default(), ..back }; + assert!(Targets::for_move(&pixels, &pixels.selection.objects).snap_back(None, Vec2::new(1.0, 1.0), pixels.snap_tol()).is_none()); +} + /// #2054: with Smart Guides off, View › Snap to Point lands a dragged handle on another /// object's anchor or a ruler guide within its distance (2 px); with Smart Guides on, the /// ruler guides still pull it within the Snapping Tolerance (4 px), as a move does (#1534). diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index fb291796f7..66728b4826 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -236,7 +236,7 @@ impl ShapeTool { self.selection = if let Some((handle, bx)) = self.over_handle(cx, ev.pos) { let is_area = |id: &vectorcraft_doc::NodeId| cx.doc.node(*id).is_some_and(is_area_type); let areas = cx.selection.objects.iter().any(is_area); - Some(SelectionDrag::Scale { handle, bx, areas, snap: Box::new(PointSnap::new(cx, || Targets::for_scale(cx))), last: None }) + Some(SelectionDrag::Scale { handle, bx, areas, snap: Box::new(PointSnap::scaling(cx, || Targets::for_scale(cx))), last: None }) } else { match hit_center(cx, ev.pos, cx.point_tol()) { Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }), diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 69bbf9051f..0f804a8ef6 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -161,7 +161,7 @@ struct ResizeSnap { impl ResizeSnap { fn new(cx: &ToolContext) -> Self { - Self { art: PointSnap::new(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) } + Self { art: PointSnap::scaling(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) } } /// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle` as [`snap`] snaps diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 4ffd435af6..5af642c44b 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -37,7 +37,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; use crate::bbox::{Handle, hit_handle, scale_for_drag}; use crate::guides::{DrawSnap, Leave, Targets}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, Mods, Overlay, PixelOp, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug)] enum Drag { @@ -205,11 +205,12 @@ impl ArtboardTool { self.copy_targets = cx.smart_guides.then(|| Targets::for_artboard(cx.doc, usize::MAX, &[]).styled(cx)); } - /// Snap a dragged handle: to whole pixels, the grid, or Smart Guides (in that order, as when - /// drawing). With Smart Guides the bleed edge `bleed` beyond the handle may snap instead. + /// Snap a dragged handle: to whole pixels (Snap to Pixel's While Scaling option), the grid, or + /// Smart Guides (in that order, as when drawing). With Smart Guides the bleed edge `bleed` + /// beyond the handle may snap instead. fn snap_handle(&mut self, cx: &ToolContext, p: Point, bleed: Vec2) -> Point { self.guides.clear(); - if cx.snap_to_pixel { + if cx.snaps_to_pixel(PixelOp::Scaling) { return Point::new(p.x.round(), p.y.round()); } if cx.snap_to_grid { @@ -222,12 +223,13 @@ impl ArtboardTool { q } - /// Snap a move (or an Alt-drag `copy`) of `rect` by `d`: its top-left to whole pixels or the - /// grid, or any of its edges and centre to Smart Guides. Returns the snapped delta. + /// Snap a move (or an Alt-drag `copy`) of `rect` by `d`: its top-left to whole pixels (Snap to + /// Pixel's While Moving option) or the grid, or any of its edges and centre to Smart Guides. + /// Returns the snapped delta. fn snap_move(&mut self, cx: &ToolContext, rect: Rect, d: Vec2, copy: bool) -> Vec2 { self.guides.clear(); let tl = Point::new(rect.x0, rect.y0); - if cx.snap_to_pixel { + if cx.snaps_to_pixel(PixelOp::Moving) { return Vec2::new((tl.x + d.x).round() - tl.x, (tl.y + d.y).round() - tl.y); } if cx.snap_to_grid { diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index bd7c0c0ecd..2d137153fc 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -248,7 +248,7 @@ impl Tool for FreeTransformTool { self.drag = None; let Some(bx) = selection_box(cx) else { return vec![] }; if let Some(op) = self.classify(cx, &bx, p, ev.mods) { - self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::for_scale(cx))); + self.targets = op.handle().is_some().then(|| PointSnap::scaling(cx, || Targets::for_scale(cx))); self.drag = Some(Drag { op, bx, start: p, began: false }); } vec![] diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index cccb1ebcae..0b575c503c 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -43,7 +43,7 @@ pub(crate) fn end_point(cx: &ToolContext, snap: &mut DrawSnap, a: Point, p: Poin return a; } let u = d / len; - let to_point = cx.snap_to_point && !(cx.smart_guides || cx.snap_to_grid || cx.snap_to_pixel); + let to_point = cx.snap_to_point && !(cx.smart_guides || cx.snap_to_grid || cx.snaps_to_pixel(crate::PixelOp::Drawing)); let s = if to_point { snap.measure(cx, p, None) } else { p }; let q = a + u * (s - a).dot(u); let leave = crate::guides::Leave::segment(cx, a, true); diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 5cabbed6b8..263c0ea5e4 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -238,8 +238,11 @@ impl Tool for TransformTool { return out; } d.began = true; - // The selection moves, so only the other objects are targets. - self.snap = Some(PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None))); + // The selection moves, so only the other objects are targets. The Scale tool's + // drag scales; the others move the art about the reference point. + let targets = || Targets::collect(cx.doc, &cx.selection.objects, None); + self.snap = + Some(if self.kind == TransformKind::Scale { PointSnap::scaling(cx, targets) } else { PointSnap::moving(cx, targets) }); out.push(Action::Begin(self.kind.label().into())); } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); diff --git a/crates/trace/src/subpixel.rs b/crates/trace/src/subpixel.rs index 550fb87d2f..5dba6f8850 100644 --- a/crates/trace/src/subpixel.rs +++ b/crates/trace/src/subpixel.rs @@ -12,6 +12,8 @@ //! An image with hard edges (a GIF, a 1-bit scan) says nothing more than the cracks do: every crack //! moves by the same amount. There the staircase is read the classic way: a one-pixel step between //! two runs is a point of a sloped edge (its middle is on the edge, the corners beside it are not). +//! A staircase of longer steps is read the same way where a smooth area was split into bands (see +//! [`outline`]). use kurbo::Vec2; use vectorcraft_geom::Point; @@ -119,6 +121,10 @@ pub(crate) fn low_contrast(steps: &[[f64; 5]]) -> bool { /// flat (see [`low_contrast`]). const FLAT: f64 = 12.0; +/// The longest step (px) of a staircase read as the slope through it in a smooth area (see +/// [`outline`]). An edge longer than this keeps its corners. +const STAIR_MAX: usize = 8; + /// What `across` says of a crack that is an edge whatever the image does there: a crack on the /// image's border, or one that the image's pixels cannot be read across (see [`low_contrast`]). pub(crate) const EDGE_STEP: [f64; 5] = [1.0, 1.0, f64::NAN, f64::NAN, f64::NAN]; @@ -233,6 +239,50 @@ pub(crate) fn outline( if junctions[edges[i % n].first].is_some() { 0 } else { (a.0 * b.1 - a.1 * b.0).signum() } }; let step: Vec = (0..n).map(|i| edges[i].len == 1 && turn(i) * turn(i + 1) < 0).collect(); + // A staircase of longer steps, where a smooth area was split into bands: a run of three or more + // edges of one to `STAIR_MAX` pixels, each turning one way where it starts and the other where + // it ends, with no junction on it, whose cracks read as the contour of a smooth area (see + // `low_contrast`, applied to the run's own cracks; a run is cut at any step whose own cracks + // show half the step between the colors or more). Its edges are steps: no corner where two of + // them meet, and each gives one point, its middle. Where a run meets an edge that is not a + // step, that corner stays. A sharp edge drawn as a staircase keeps its corners. (The runs are + // the same from either side: they depend only on the edges, their turns and the junctions, + // which every region around a pixel corner shares.) + let stair = { + let candidate: Vec = (0..n) + .map(|i| { + let e = &edges[i]; + (1..=STAIR_MAX).contains(&e.len) && turn(i) * turn(i + 1) < 0 && !(0..=e.len).any(|k| junction((e.first + k) % cracks)) + }) + .collect(); + let own = |i: usize| (0..edges[i].len).filter_map(|j| steps.get(edges[i].first + j).copied()).collect::>(); + // Whether a step's own cracks show under half of the step between the two colors, as a + // contour does (an edge shows nearly all of it): a staircase that runs on from a contour + // into an edge is cut there. (Summed in sorted order: the same from either side.) + let gentle = |i: usize| { + let mut near: Vec = own(i).iter().map(|s| s[0]).filter(|s| !s.is_nan()).collect(); + near.sort_by(f64::total_cmp); + !near.is_empty() && near.iter().sum::() < 0.5 * near.len() as f64 + }; + let mut stair = vec![false; n]; + // A loop turns once round in all, so some edge is not a candidate: runs start after it. + if let Some(s0) = (0..n).find(|&i| !candidate[i]) { + let mut k = 1; + while k < n { + let run: Vec = (k..n).map(|j| (s0 + j) % n).take_while(|&i| candidate[i] && gentle(i)).collect(); + k += run.len().max(1); + if run.len() < 3 { + continue; + } + if low_contrast(&run.iter().flat_map(|&i| own(i)).collect::>()) { + for &i in &run { + stair[i] = true; + } + } + } + } + stair + }; let outward = |d: (i32, i32)| Vec2::new(f64::from(d.1), f64::from(-d.0)); // The pixel at a corner of the shape lies on both of the corner's edges, and what it lacks of // the inside's colour is the doing of both: read as one edge's alone it dents the corner. The @@ -242,7 +292,7 @@ pub(crate) fn outline( let mut shifts = shifts; for i in 0..n { let (b, e) = ((i + n - 1) % n, i); - if step[b] || step[e] || turn(i) == 0 { + if step[b] || step[e] || (stair[b] && stair[e]) || turn(i) == 0 { continue; } let (before, edge) = (&edges[b], &edges[e]); @@ -276,6 +326,9 @@ pub(crate) fn outline( && edges[b].len >= 3 && edges[s1].dir == edges[b].dir && edges[s2].dir == edges[a].dir + // The edges of a staircase of longer steps (see `stair`) make no corner here. + && !stair[a] + && !stair[b] && free(s1) && free(s2) && free(b) @@ -311,7 +364,7 @@ pub(crate) fn outline( if k == 0 && first.is_some() { // The junction itself: the one point every region around it has. pts.push(junctions[crack].unwrap_or(corner)); - } else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] { + } else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] && !(stair[i] && stair[(i + n - 1) % n]) { // A true corner: where its two edges, each moved by its own shift, meet. let before = &edges[(i + n - 1) % n]; pts.push(corner + outward(before.dir) * shifts[(crack + cracks - 1) % cracks] + outward(e.dir) * shifts[crack]); @@ -319,6 +372,13 @@ pub(crate) fn outline( if !hard && let Some(sharp) = notch[i] { // A corner whose own pixel went to the other side: the corner, not the two steps. pts.extend(sharp); + } else if stair[i] { + if along_edge == e.len / 2 { + // The middle of the step, moved by its cracks' mean shift (multiples of 1/1024, + // so the same sum from either side). + let shift = (0..e.len).map(|j| shifts[e.first + j]).sum::() / e.len as f64; + pts.push(at(i) + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * (e.len as f64 / 2.0) + outward(e.dir) * shift); + } } else if step[i] || !hard { pts.push(corner + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * 0.5 + outward(e.dir) * shifts[crack]); } @@ -494,6 +554,218 @@ mod tests { } } + #[test] + fn a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps() { + // Left and right regions meet along a staircase of steps two pixels across and three down + // (rows 0 to 8), a straight run of ten rows, and the same staircase again (rows 19 to 27). + // The cracks above row 9 read as the contour of a smooth area, the others as an edge, so + // the piece as a whole is no contour. + let (w, h) = (18, 28); + let edge = |y: usize| match y { + 0..9 => 2 + 2 * (y / 3), + 9..19 => 8, + _ => 10 + 2 * ((y - 19) / 3), + }; + let left: Vec = (0..w * h).map(|i| i % w < edge(i / w)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let step = |c: Crack| if c.at(0).unwrap().min(c.at(1).unwrap()) / w < 9 { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] }; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + assert!(!ours.contour); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + // The upper staircase: the middle of each of its steps, on one line, and none of its corners. + let middles = [(3.0, 3.0), (4.0, 4.5), (5.0, 6.0), (6.0, 7.5), (7.0, 9.0)].map(Point::from); + assert!(middles.iter().all(|m| pts.contains(m)), "{pts:?}"); + assert!([(4.0, 3.0), (4.0, 6.0), (6.0, 6.0), (6.0, 9.0)].map(Point::from).iter().all(|c| !pts.contains(c)), "{pts:?}"); + // The straight run keeps the corners at both its ends, so it stays straight. + assert!([(8.0, 9.0), (8.0, 19.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + // The lower one is a sharp edge: it keeps its corners. + assert!([(8.0, 19.0), (10.0, 19.0), (10.0, 22.0), (12.0, 22.0), (12.0, 25.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + + // Steps longer than STAIR_MAX keep their corners, in a smooth area too. + let (w, h) = (40, 40); + let left: Vec = (0..w * h).map(|i| i % w < 5 + 10 * (i / w / 10)).collect(); + let pieces = outline(&loop_of(&left, w, h), w, h, &|_| (0.0, 1, [0.1, 0.3, 4.0, 4.0, 4.0]), &grid); + let pts = &inner(&pieces).pts; + assert!([(15.0, 10.0), (15.0, 20.0), (25.0, 20.0), (25.0, 30.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + } + + #[test] + fn a_staircase_that_runs_on_from_a_smooth_area_into_an_edge_keeps_the_edges_corners() { + // One staircase of steps two pixels across and three down, from the top of the image to its + // bottom: its cracks above row 18 read as the contour of a smooth area, the others as an + // edge. Read as a whole, the run would pass as a contour. + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let step = |c: Crack| if c.at(0).unwrap().min(c.at(1).unwrap()) / w < 18 { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] }; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + assert!([(3.0, 3.0), (4.0, 4.5), (9.0, 12.0), (11.0, 15.0), (12.0, 16.5)].map(Point::from).iter().all(|m| pts.contains(m)), "{pts:?}"); + assert!([(4.0, 3.0), (10.0, 15.0)].map(Point::from).iter().all(|c| !pts.contains(c)), "{pts:?}"); + // The corner where the staircase meets the edge's first step stays. + assert!([(14.0, 18.0), (14.0, 21.0), (16.0, 21.0), (16.0, 24.0), (18.0, 24.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + } + + #[test] + fn a_staircase_whose_cracks_do_not_read_as_a_contour_keeps_its_corners() { + // Each step's cracks show a tenth of the step near them, but nothing changes within either + // side: two flat colors, not a smooth area. + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let flat = [0.1, 0.3, 4.0, 4.0, 0.0]; + let a = outline(&loop_of(&left, w, h), w, h, &|_| (0.0, 1, flat), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|_| (0.0, 2, flat), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + assert!([(4.0, 3.0), (4.0, 6.0), (6.0, 6.0), (10.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!([(4.0, 4.5), (9.0, 12.0)].map(Point::from).iter().all(|m| !pts.contains(m)), "{pts:?}"); + } + + #[test] + fn a_staircase_step_moves_by_the_mean_shift_of_its_cracks() { + // The staircase of steps two pixels across and three down, in a smooth area. Down a step, + // its cracks move 0, 1/8 and 1/4 px; across one, 0 and 1/8 px (out of the left region; the + // right region sees them negated). + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let shift = |c: Crack| { + let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap()); + let v = if p / w == q / w { 0.125 * (p / w % 3) as f64 } else { 0.125 * (p % w % 2) as f64 }; + if left[p] { v } else { -v } + }; + let gentle = [0.1, 0.3, 4.0, 4.0, 4.0]; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (shift(c), 1, gentle), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (shift(c), 2, gentle), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + assert!(!ours.hard); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + // A step down at x = 4 moves right by 1/8 px; the step across at y = 6 moves up by 1/16 px. + assert!([(4.125, 4.5), (5.0, 5.9375), (6.125, 7.5), (7.0, 8.9375)].map(Point::from).iter().all(|m| pts.contains(m)), "{pts:?}"); + } + + #[test] + fn a_staircase_of_mixed_one_pixel_and_longer_steps_is_one_slope_without_a_notch() { + // Per four rows: three down, one across, one down, three across. Each three-pixel step meets + // the next one with no one-pixel step between them, and the one-pixel steps are a notch + // between two longer ones. All of it is the contour of a smooth area, and the cracks move by + // different amounts. + let (w, h) = (26, 24); + let edge = |y: usize| 2 + 4 * (y / 4) + usize::from(y % 4 == 3); + let left: Vec = (0..w * h).map(|i| i % w < edge(i / w)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let shift = |c: Crack| { + let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap()); + let v = 0.125 * ((p + q) % 3) as f64 - 0.125; + if left[p] { v } else { -v } + }; + let gentle = [0.1, 0.3, 4.0, 4.0, 4.0]; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (shift(c), 1, gentle), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (shift(c), 2, gentle), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + assert!(!ours.hard); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + let near = |x: f64, y: f64, d: f64| pts.iter().any(|p| (p.x - x).hypot(p.y - y) < d); + for k in 1..5 { + let (c, y) = ((2 + 4 * k) as f64, (4 * k) as f64); + // The middle of each step, and no point at the corner where the three-pixel steps + // meet or at the corner the notch would make. + assert!(near(c, y + 1.5, 0.2) && near(c + 2.5, y + 4.0, 0.2), "{k}: {pts:?}"); + assert!(!near(c, y, 0.4) && !near(c, y + 4.0, 0.4), "{k}: {pts:?}"); + } + } + + /// Every point of every piece that `mask` and the region around it get in a smooth area, with + /// `junction_at` and the near share of each crack from `near`. + fn both_sides( + mask: &[bool], + w: usize, + h: usize, + near: &dyn Fn(Crack) -> [f64; 5], + junction_at: &dyn Fn(i32, i32) -> Option, + ) -> [Vec; 2] { + let other: Vec = mask.iter().map(|b| !b).collect(); + [(mask, 1), (&other[..], 2)] + .map(|(m, id)| outline(&loop_of(m, w, h), w, h, &|c| (0.0, id, near(c)), junction_at).into_iter().flat_map(|p| p.pts).collect()) + } + + #[test] + fn a_run_of_two_steps_keeps_its_corners() { + // Ten rows down, two across, three down, ten across: only two edges are short enough to + // be steps. + let (w, h) = (20, 20); + let edge = |y: usize| match y { + 0..10 => 2, + 10..13 => 4, + _ => 14, + }; + let left: Vec = (0..w * h).map(|i| i % w < edge(i / w)).collect(); + for pts in both_sides(&left, w, h, &|_| [0.1, 0.3, 4.0, 4.0, 4.0], &grid) { + assert!([(2.0, 10.0), (4.0, 10.0), (4.0, 13.0), (14.0, 13.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!(!pts.contains(&Point::new(4.0, 11.5)), "{pts:?}"); + } + } + + #[test] + fn a_junction_on_a_step_ends_the_staircase_there() { + // The staircase of steps two pixels across and three down, in a smooth area, with a + // junction at (8, 10), on the step down from (8, 9) to (8, 12). + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let junction_at = |x: i32, y: i32| (x == 8 && y == 10).then(|| Point::new(8.0, 10.0)); + for pts in both_sides(&left, w, h, &|_| [0.1, 0.3, 4.0, 4.0, 4.0], &junction_at) { + // The step keeps the corners at both its ends; the steps on either side of it are slopes. + assert!([(8.0, 9.0), (8.0, 10.0), (8.0, 12.0), (5.0, 6.0), (11.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!(!pts.contains(&Point::new(8.0, 10.5)) && !pts.contains(&Point::new(6.0, 6.0)), "{pts:?}"); + } + } + + #[test] + fn a_step_whose_cracks_read_as_an_edge_ends_the_staircase_there() { + // The same staircase in a smooth area, but the cracks of the step down from (8, 9) to + // (8, 12) cannot be read across. + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let near = |c: Crack| { + let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap()); + if p / w == q / w && (9..12).contains(&(p / w)) && (p % w).max(q % w) == 8 { EDGE_STEP } else { [0.1, 0.3, 4.0, 4.0, 4.0] } + }; + for pts in both_sides(&left, w, h, &near, &grid) { + assert!([(8.0, 9.0), (8.0, 12.0), (5.0, 6.0), (11.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!(!pts.contains(&Point::new(8.0, 10.5)) && !pts.contains(&Point::new(6.0, 6.0)), "{pts:?}"); + } + } + #[test] fn a_shift_that_is_not_a_number_moves_nothing() { let mask = [true, true, false, false, true, true, false, false]; diff --git a/crates/ui-egui/src/addons/select/link_status.rs b/crates/ui-egui/src/addons/select/link_status.rs index 0b367a7ba0..d50d650215 100644 --- a/crates/ui-egui/src/addons/select/link_status.rs +++ b/crates/ui-egui/src/addons/select/link_status.rs @@ -52,12 +52,19 @@ pub struct Present { pub has: [bool; 4], } -/// The statuses the `links.list` result `list` holds, in [`STATUSES`] order. +/// The statuses the rows of the `links.list` result `list` that can be selected hold, in +/// [`STATUSES`] order. pub(crate) fn statuses_in(list: &Value) -> [bool; 4] { - let rows = list["links"].as_array().map_or(&[][..], Vec::as_slice); + let rows = canvas_rows(list); STATUSES.map(|(status, ..)| rows.iter().any(|r| command_status(r) == status)) } +/// The rows of the `links.list` result `list` of objects in the layer tree, which can be +/// selected (a row with an `owner` is in a symbol, graph design, pattern or opacity mask). +fn canvas_rows(list: &Value) -> Vec { + list["links"].as_array().into_iter().flatten().filter(|r| r["owner"].is_null()).cloned().collect() +} + /// The status of a `links.list` row as the command names it (`ok` is `linked`). fn command_status(row: &Value) -> &str { match row["status"].as_str().unwrap_or_default() { @@ -154,11 +161,11 @@ pub(crate) fn message(statuses: &[&str], count: u64, reasons: &[&str]) -> String crate::i18n::msg_n(reasons.len() as u64, one, other, &[("selected", &selected)]) } -/// The dialog's fields to start with: every image and placed document (`__rows`, as `links.list` -/// lists them, read once), those selected chosen, All shown, Zoom to Selection on. +/// The dialog's fields to start with: every image and placed document in the layer tree (`__rows`, +/// as `links.list` lists them, read once), those selected chosen, All shown, Zoom to Selection on. pub(crate) fn fields(app: &mut VectorcraftApp) -> Value { let list = app.session.execute("links.list", &json!({})).unwrap_or_default(); - let rows: Vec = list["links"].as_array().cloned().unwrap_or_default(); + let rows = canvas_rows(&list); let selected: Vec = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default(); let chosen: Vec = rows.iter().filter_map(|r| r["id"].as_u64()).filter(|id| selected.contains(id)).collect(); json!({ "__rows": rows, "ids": chosen, "show": "all", "zoom": true }) diff --git a/crates/ui-egui/src/addons/select/link_status/tests.rs b/crates/ui-egui/src/addons/select/link_status/tests.rs index 8ad61f6e5a..c0515e048b 100644 --- a/crates/ui-egui/src/addons/select/link_status/tests.rs +++ b/crates/ui-egui/src/addons/select/link_status/tests.rs @@ -138,6 +138,27 @@ fn menu_items_are_on_for_the_statuses_present_and_shift_adds() { assert!(on("Select Embedded")); } +/// A missing link kept only in a symbol's art can't be selected: Select Missing stays off and +/// the dialog does not list it. +#[test] +fn links_inside_a_symbol_are_left_out_of_selecting_by_status() { + let dir = Folder::new("symbol"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let path = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": path, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + std::fs::remove_file(&path).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + assert_eq!(list["missing"], 1, "{list}"); + refresh(&mut app, 0.0); + let entries = menus::menu_entries(&app); + let missing = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Selection Tools"] && e.label == "Select Missing").unwrap(); + assert!(!missing.enabled); + assert_eq!(super::fields(&mut app)["__rows"], json!([])); +} + #[test] fn the_links_panel_menu_lists_the_select_items() { let dir = Folder::new("flyout"); diff --git a/crates/ui-egui/src/addons/shapes.rs b/crates/ui-egui/src/addons/shapes.rs index e7971bb888..fae45a190e 100644 --- a/crates/ui-egui/src/addons/shapes.rs +++ b/crates/ui-egui/src/addons/shapes.rs @@ -42,7 +42,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok(); } } - ui.separator(); + crate::chrome::group_separator(ui); } match active_shape.as_str() { @@ -106,7 +106,7 @@ fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, se if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true })); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -164,7 +164,7 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option) { @@ -225,7 +225,7 @@ fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, ac json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -255,7 +255,7 @@ fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sel if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true })); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -298,7 +298,7 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn mode_str(m: EllipseMode) -> &'static str { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 67e67ac444..8c8a153a94 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -344,6 +344,8 @@ impl VectorcraftApp { screen_color::poll(self, ctx); self.poll_finished_work(); crate::script_runs::poll(self); + // A panel's, a dialog's or the text input's commit journaled in the frame before. + crate::panels::actions::after_journaled_edit(self); crate::scripts_menu::poll(self, ctx); if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() { // A script in `$.sleep` goes on when its time is up; until then the app can idle. @@ -599,6 +601,8 @@ impl VectorcraftApp { // Toolbar too. toolbar::show_custom(self, ui.ctx()); } + // The lanes along the window's edges where toolbars made with New Toolbar dock. + crate::custom_toolbars::lanes(self, ui); panels::library_panel::tidy(self); // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas. egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui)); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 1d2493385e..50c7b1be30 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -224,13 +224,10 @@ impl VectorcraftApp { } // While an action records, a command that the recording would keep as a step runs only // when the step fits in the action. - let recorded = match panels::actions::before_recorded_command(self, id, ¶ms) { - Ok(recorded) => recorded, - Err(e) => { - self.ui.status = e.clone(); - return Err(e); - } - }; + if let Err(e) = panels::actions::before_recorded_command(self, id, ¶ms) { + self.ui.status = e.clone(); + return Err(e); + } // A command that changes the edited text is an application edit (undo, a panel or menu // edit, a control request). Capture the bounded committed context around it so `Other` // dominates the frame even if an IME receipt follows. Selection/view commands that leave @@ -240,7 +237,7 @@ impl VectorcraftApp { let ime_before = crate::ime::stamp(&mut self.session, ime_view); let cursor_settings = (self.session.caps_lock, self.session.prefs.use_precise_cursors); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); - let r = panels::actions::after_recorded_command(self, recorded, r); + let r = panels::actions::after_recorded_command(self, r); if r.is_ok() && cursor_settings != (self.session.caps_lock, self.session.prefs.use_precise_cursors) { self.canvas.hover_cursor = None; } diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 29baa37d23..95f719d08b 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -16,6 +16,7 @@ impl VectorcraftApp { native_menu::run(self, ctx); if self.fonts_ready { tools_float::cancel_move_on_escape(self, ctx); + crate::custom_toolbars::cancel_move_on_escape(self, ctx); shortcuts::handle(self, ctx); } // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 71f70e455b..8b368fa496 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -123,6 +123,7 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::TypeArea | Cursor::TypeOnPath => C::Text, // The system has no tip outline to go round: its crosshair aims as well. Cursor::BlobBrush | Cursor::Eraser => C::Crosshair, + Cursor::LivePaintBucket | Cursor::LivePaintBucketFace | Cursor::LivePaintBucketEdge => C::Crosshair, // Not drawn yet: the cursor each borrows. Cursor::Lasso | Cursor::MagicWand @@ -133,7 +134,6 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { | Cursor::Join | Cursor::Knife | Cursor::Shaper - | Cursor::LivePaintBucket | Cursor::Measure | Cursor::PrintTiling | Cursor::SymbolSprayer diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 1a385d5642..414746310f 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -399,7 +399,9 @@ fn pressed_with(app: &mut VectorcraftApp, index: Option, pos: Point, tool } } -/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. +/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. When an +/// edit the tool committed or ran no longer fits in the action being recorded, the edit stays and +/// the status bar names the limit ([`crate::panels::actions::after_journaled_edit`]). pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { match r { Ok(reqs) => { @@ -420,6 +422,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result app.status(e.to_string()), } diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 7eb31f7d01..aefd9253e9 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -252,7 +252,9 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: /// The Appearance and Easing widgets of one selected blend (Selection and Blend tools), each joined /// by a line to the spine's halfway point: the Appearance widget hollow above it, the Easing widget -/// filled below it. Drawn with the edges hidden too, as they are controls. +/// filled below it. For a blend without a spine the line goes to the halfway point of the straight +/// lines between its key centers, which are not drawn. Drawn with the edges hidden too, as they are +/// controls. pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { use vectorcraft_tools::blendwidget::{BlendControl, BlendWidget, RADIUS_PX}; if !matches!(app.session.tool_id(), "selection" | "blend") { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 8597607717..6bf8a6fa97 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -184,6 +184,9 @@ fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() { Cursor::PencilNew, Cursor::PencilStraight, Cursor::PencilContinue, + Cursor::LivePaintBucket, + Cursor::LivePaintBucketFace, + Cursor::LivePaintBucketEdge, ] { assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}"); assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}"); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 855d542d08..ddf0040a2c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -18,6 +18,7 @@ mod hints; mod status_bar; mod tabs; +pub(crate) use control_bar::group_separator; pub use control_bar::{ AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, control_bar_moving, control_bar_placement, read_hidden_groups, diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 9b862cf636..5ac4025c24 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -78,7 +78,8 @@ pub(super) const ISOLATION_BUTTON: &str = "cb-isolation"; /// less, the menu is left out so those controls still fit the narrowest window. const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0; -/// The Control bar's snapping quick access; every change uses its existing command. +/// The Control bar's snapping quick access; every change uses its existing command, and Pixel +/// Snapping Options… opens the dialog of `view.snapToPixel.options`. fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.menu_button(tl!("Snapping"), |ui| { for (label, cmd, on) in [ @@ -92,6 +93,10 @@ fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { crate::menus::invoke(app, cmd, json!({})); } } + if ui.button(tl!("Pixel Snapping Options…")).clicked() { + ui.close(); + crate::menus::invoke(app, "view.snapToPixel.options", json!({})); + } ui.separator(); for (key, label, on) in [ ("alignmentGuides", tl!("Alignment Guides"), app.session.prefs.alignment_guides), @@ -202,7 +207,7 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 { /// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it. fn group_end(ui: &mut Ui) { ui.add_space(GROUP_GAP); - separator(ui); + boundary(ui, separator); } fn group_end_width(ui: &Ui) -> f32 { @@ -218,6 +223,66 @@ fn separator_width(ui: &Ui) -> f32 { SEPARATOR + ui.spacing().item_spacing.x } +/// A separator between two groups of the Control bar's controls, drawn in the bar's row `ui`: the +/// pass that drew it, the range of the paint list that holds it (between two empty shapes), the +/// row's next widget id just after it, and whether it reaches the end of the row, leaving no room +/// for a control after it. +#[derive(Clone, Copy)] +struct Boundary { + pass: u64, + shapes: (usize, usize), + after: egui::Id, + at_end: bool, +} + +/// The key of the row `ui`'s last [`Boundary`]. +fn boundary_key(ui: &Ui) -> egui::Id { + ui.unique_id().with("cb-group-boundary") +} + +/// A separator between two groups of the Control bar's controls, drawn with `draw`. It stays only +/// while a control follows it in the row: [`erase_trailing`] erases it at the end of the row when no +/// control does or none has room, and the next separator erases it when no control comes between +/// them. The separator keeps its room either way. +fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) { + erase_trailing(ui); + let start = ui.painter().add(egui::Shape::Noop); + draw(ui); + let end = ui.painter().add(egui::Shape::Noop); + let at_end = ui.cursor().min.x >= ui.max_rect().right(); + let b = Boundary { pass: ui.ctx().cumulative_pass_nr(), shapes: (start.0, end.0), after: ui.next_auto_id(), at_end }; + let key = boundary_key(ui); + ui.data_mut(|d| d.insert_temp(key, b)); +} + +/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws +/// one, and left out when no control follows it ([`boundary`]). +pub(crate) fn group_separator(ui: &mut Ui) { + boundary(ui, |ui| { + ui.separator(); + }); +} + +/// Erases the row `ui`'s last group separator when no control was drawn after it in this pass, or +/// none had room in the row. The end of the Control bar's controls row calls it too. +fn erase_trailing(ui: &mut Ui) { + let key = boundary_key(ui); + let Some(b) = ui.data_mut(|d| { + let b = d.get_temp::(key); + d.remove::(key); + b + }) else { + return; + }; + if b.pass != ui.ctx().cumulative_pass_nr() || (b.after != ui.next_auto_id() && !b.at_end) { + return; + } + let painter = ui.painter(); + for i in b.shapes.0 + 1..b.shapes.1 { + painter.set(egui::layers::ShapeIdx(i), egui::Shape::Noop); + } +} + /// The room the controls at the end of the Control bar need: the Transform link, or Document Setup /// and Preferences with nothing selected, or none while the panel menu has that group off. The /// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align buttons, @@ -276,7 +341,7 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize } } if transform { - separator(ui); + boundary(ui, separator); } } transform @@ -292,7 +357,7 @@ fn artboard_align_group(app: &mut VectorcraftApp, ui: &mut Ui) { if !group_on(app, "alignDistribute") || app.session.active().is_none() || ui.available_width() < separator_width(ui) + buttons_width(ui, 7) { return; } - separator(ui); + boundary(ui, separator); crate::panels::align::align_buttons(app, ui, BUTTON); if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x { crate::panels::align::align_to_menu(app, ui, BUTTON); @@ -479,7 +544,7 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { } /// The Control bar's Fill and Stroke group: the fill and stroke chips, the Stroke link, the weight -/// and the width profile, and the separator after them. +/// and the width profile, and the separator after them while a control follows ([`group_separator`]). fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); @@ -494,12 +559,12 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("stroke.set", json!({"profile": id})).ok(); } ui.add_space(4.0); - ui.separator(); + group_separator(ui); } /// The Control bar's Opacity group (its link and field, showing `opacity`) and, with something /// `selected`, its Graphic Style group (Style, Recolor Artwork and Global Edit Options), each while -/// the panel menu has it on, and the separator after them. +/// the panel menu has it on, and the separator after them while a control follows. fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool) { let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected); if opacity_on { @@ -529,7 +594,7 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: global_edit_options(app, ui); } if opacity_on || style_on { - ui.separator(); + group_separator(ui); } } @@ -680,6 +745,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) { artboard_align_group(app, ui); + erase_trailing(ui); return; } if anchors != AnchorControls::None && group_on(app, "anchors") { @@ -713,6 +779,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, crate::panels::character::control_bar(app, ui); } if !groups_before_transform(app, ui, sel.len()) { + erase_trailing(ui); return; } // The Transform link opens the whole Transform panel (reference point, rotate, @@ -750,6 +817,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click())); } } + erase_trailing(ui); }); // Controls that keep their size can still run into the button's room in a narrow window; // the button is left out then. A floating bar clips them, so its button always shows. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 626ff8c66e..376450937c 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2247,6 +2247,82 @@ fn the_control_bar_snapping_menu_shares_glyph_and_alignment_settings() { check(&nodes, "Alignment Guides", true); } +/// The Control bar's Snapping menu opens Pixel Snapping Options with the current options; OK sets +/// the three choices the dialog shows, and a dialog closed without OK changes nothing. +#[test] +fn the_control_bar_snapping_menu_opens_pixel_snapping_options() { + struct RestoreLanguage(crate::i18n::Lang); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_current(self.0); + } + } + let _language = RestoreLanguage(crate::i18n::current()); + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("view.snapToPixel.options", json!({"moving": false})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 400.0)); + let frame = |app: &mut crate::VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default() + }; + let center = |nodes: &[(egui::accesskit::NodeId, egui::accesskit::Node)], name: &str| { + let n = nodes.iter().find(|(_, n)| n.label() == Some(name)).map(|(_, n)| n.clone()).unwrap_or_else(|| panic!("missing {name}")); + let b = n.bounds().expect("a visible menu control"); + egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) + }; + let click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| { + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, vec![egui::Event::PointerMoved(at), press(true)]); + frame(app, vec![press(false)]); + frame(app, vec![]); + frame(app, vec![]) + }; + frame(&mut app, vec![]); + let nodes = frame(&mut app, vec![]); + let menu = center(&nodes, "Snapping"); + let nodes = click(&mut app, menu); + let entry = center(&nodes, "Pixel Snapping Options…"); + click(&mut app, entry); + let d = app.ui.dialog.as_ref().expect("Pixel Snapping Options"); + assert_eq!(d.kind, crate::dialogs::pixel_snap_options::KIND); + assert_eq!(serde_json::Value::Object(d.fields.clone()), json!({"drawing": true, "moving": false, "scaling": true})); + // While View › Snap to Pixel is off, the dialog says the options wait for it. + // A new window is laid out unseen in its first frame, so the second frame is read. + let note = |app: &mut crate::VectorcraftApp| { + let mut nodes = vec![]; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default(); + } + let shown = |text: &str| nodes.iter().any(|(_, n)| n.label().or(n.value()).is_some_and(|l| l.contains(text))); + (shown("Snap to Pixel While Drawing"), shown("take effect when View › Snap to Pixel is on")) + }; + assert!(!app.ui.view.snap_to_pixel); + assert_eq!(note(&mut app), (true, true), "the note shows with Snap to Pixel off"); + app.ui.view.snap_to_pixel = true; + assert_eq!(note(&mut app), (true, false), "the note is hidden with Snap to Pixel on"); + let before = app.session.prefs.pixel_snap; + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("drawing".into(), json!(false)); + app.ui.dialog = None; + assert_eq!(app.session.prefs.pixel_snap, before, "a dialog closed without OK changes nothing"); + crate::menus::invoke(&mut app, "view.snapToPixel.options", json!({})); + let d = app.ui.dialog.as_mut().expect("Pixel Snapping Options"); + d.fields.insert("drawing".into(), json!(false)); + d.fields.insert("moving".into(), json!(true)); + d.fields.insert("scaling".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.prefs.pixel_snap, vectorcraft_tools::PixelSnap { drawing: false, moving: true, scaling: false }); +} + /// The window the Control bar docking tests draw the bar in. const DOCK_SCREEN: egui::Rect = egui::Rect { min: egui::Pos2::ZERO, max: egui::pos2(1200.0, 300.0) }; @@ -2845,6 +2921,96 @@ fn the_artboard_tool_s_control_bar_follows_its_two_groups() { assert!(has(&bar, "Name:") && has(&bar, "Move Artwork with Artboard"), "{bar:?}"); } +/// The separators the docked Control bar draws in window `screen` (vertical lines most of the bar's +/// height, left of the panel menu button), in order, with whether any other shape is drawn between +/// each one and the next, or after the last one for the last. +fn control_bar_separators(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Vec<(f32, bool)> { + control_bar_frame_in(app, ctx, screen, vec![]); + let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + let menu_left = + nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds()).map_or(screen.right(), |b| b.x0 as f32); + let separator = |s: &egui::Shape| match s { + egui::Shape::LineSegment { points: [a, b], .. } if (a.x - b.x).abs() < 0.5 && (a.y - b.y).abs() >= 16.0 => Some(a.x), + _ => None, + }; + let (mut lines, mut others) = (vec![], vec![]); + for cs in &out.shapes { + let r = cs.shape.visual_bounding_rect(); + if !r.is_finite() || !cs.clip_rect.intersects(r) || r.min.x >= menu_left || r.width() > screen.width() / 2.0 { + continue; + } + match separator(&cs.shape) { + Some(x) => lines.push(x), + None => others.push(r.center().x), + } + } + lines.sort_by(f32::total_cmp); + (0..lines.len()) + .map(|i| { + let next = lines.get(i + 1).copied().unwrap_or(f32::INFINITY); + (lines[i], others.iter().any(|&x| x > lines[i] && x < next)) + }) + .collect() +} + +/// A Control bar separator stands only between two groups that draw controls: none is left at the +/// end of the bar when the groups after it are off, do not apply or have no room, and none follows +/// another with nothing between them, in every window width. With every group on, the separators +/// between the groups stay. +#[test] +fn no_separator_is_left_at_the_end_of_the_control_bar() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let set = |app: &mut crate::VectorcraftApp, groups: &[&str], visible: bool| { + for group in groups { + app.run("window.control.options", json!({ "group": group, "visible": visible })).unwrap(); + } + }; + // The first width in each case where a separator has nothing after it. + let mut stray = vec![]; + let mut check = |app: &mut crate::VectorcraftApp, case: &str| { + for w in (300..=1500).step_by(50) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + let lines = control_bar_separators(app, &ctx, screen); + if lines.iter().any(|(_, followed)| !followed) { + stray.push(format!("{case}, {w} wide: {lines:?}")); + return; + } + } + }; + app.run("select.set", json!({ "ids": [id] })).unwrap(); + let wide = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1500.0, 300.0)); + assert!(control_bar_separators(&mut app, &ctx, wide).len() >= 2, "the groups keep their separators"); + check(&mut app, "every group on"); + set(&mut app, &["transform", "alignDistribute"], false); + assert!(!control_bar_separators(&mut app, &ctx, wide).is_empty(), "Fill and Stroke and Opacity keep theirs"); + check(&mut app, "Transform and Align off"); + set(&mut app, &["opacity", "graphicStyle"], false); + check(&mut app, "Fill and Stroke last"); + app.run("select.set", json!({ "ids": [] })).unwrap(); + set(&mut app, &["opacity", "graphicStyle"], true); + set(&mut app, &["documentOptions"], false); + check(&mut app, "nothing selected, Document Options off"); + app.select_tool("puppetWarp"); + set(&mut app, &["fillStroke", "opacity"], false); + check(&mut app, "Puppet Warp options last"); + app.select_tool("ellipse"); + check(&mut app, "Ellipse options last"); + set(&mut app, &["fillStroke", "opacity", "documentOptions", "transform", "alignDistribute"], true); + app.select_tool("artboard"); + set(&mut app, &["artboardOptions"], false); + check(&mut app, "the artboard's Transform link and Align"); + set(&mut app, &["alignDistribute"], false); + check(&mut app, "the artboard's Transform link last"); + assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}"); +} + /// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps /// its Anchor Point label. #[test] diff --git a/crates/ui-egui/src/clipboard_probe.rs b/crates/ui-egui/src/clipboard_probe.rs index d492893ce0..5028125e03 100644 --- a/crates/ui-egui/src/clipboard_probe.rs +++ b/crates/ui-egui/src/clipboard_probe.rs @@ -9,17 +9,11 @@ use std::time::{Duration, Instant}; use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; -use crate::sysclip::ClipboardProbeFactory; +use crate::sysclip::{CLIPBOARD_PROBE_STOP_WAIT as STOP_WAIT, ClipboardProbeFactory}; /// How often the worker looks at the clipboard while the UI wants to know. const PROBE_INTERVAL: Duration = Duration::from_millis(250); -/// How long dropping the probe (when the app quits) waits for the worker to let go of its clipboard -/// handle (VectorCraft's own bound). arboard's X11 reads wait up to 4 s for each text format they -/// ask for; when a look is still waiting after this bound, the app quits without waiting longer, -/// and what it copied may not reach the clipboard manager. -const STOP_WAIT: Duration = Duration::from_secs(5); - /// The background clipboard check ([`crate::VectorcraftApp`]'s `clipboard_probe`). pub(crate) struct Probe { /// Tells the worker whether Paste depends on the system clipboard; dropped to stop it. @@ -28,8 +22,14 @@ pub(crate) struct Probe { verdicts: Receiver, /// The worker's last verdict. last: bool, + /// How `Drop` waits for the worker: [`wait_for_worker`], or a controllable clock in tests. + pub(crate) stop: StopWait, } +/// Waits on the worker's verdicts, from the quit to the deadline [`stop_by`] gives, until the worker +/// is gone or the deadline passes. +pub(crate) type StopWait = Box, Instant, Instant) + Send>; + impl Probe { /// Start the worker. `None` on wasm, or when the thread can't be spawned: the caller then falls /// back to the in-line check. @@ -65,13 +65,14 @@ impl Probe { Err(RecvTimeoutError::Disconnected) => break, } } - // The handle goes before the app stops waiting (`Drop`): on X11 the last one alive - // hands what the app copied to the clipboard manager, which must finish before exit. + // The handle goes before `Drop` stops waiting, unless dropping it takes longer than + // `STOP_WAIT`: on X11 the last one alive hands what the app copied to the clipboard + // manager, which must finish before exit. drop(clipboard); drop(tx); }) .ok()?; - Some(Self { wants: Some(wants), wanted: false, verdicts, last: false }) + Some(Self { wants: Some(wants), wanted: false, verdicts, last: false, stop: Box::new(wait_for_worker) }) } /// Paste can take from the system clipboard: `wanted` (nothing copied in the app, a document @@ -96,7 +97,17 @@ impl Drop for Probe { /// never answers) until it has let go of its clipboard handle. fn drop(&mut self) { self.wants = None; - let end = Instant::now() + STOP_WAIT; - while self.verdicts.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {} + let quit = Instant::now(); + (self.stop)(&self.verdicts, quit, stop_by(quit)); } } + +/// When dropping the probe at `quit` stops waiting for its worker. +pub(crate) fn stop_by(quit: Instant) -> Instant { + quit + STOP_WAIT +} + +/// Waits until the worker has dropped its sender (it has let go of its clipboard handle) or `end`. +fn wait_for_worker(verdicts: &Receiver, _quit: Instant, end: Instant) { + while verdicts.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {} +} diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 19751bd3cd..ac8fbfa3eb 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -33,11 +33,9 @@ fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { /// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { - let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new(&format!("{tag}-root")); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden)); - let _ = std::fs::remove_dir_all(root); } /// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64`, `thumbnailBase64` @@ -73,7 +71,7 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { /// space do not keep it. #[test] fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { - let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id())); + let base = crate::tests_folder::Folder::new("continuation"); let root = base.join("in"); std::fs::create_dir_all(&root).unwrap(); let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned(); @@ -84,7 +82,6 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")}); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); - let _ = std::fs::remove_dir_all(base); assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); // A readable library's id with a space after the hidden part stays whole. // A readable id with a `..` part does not keep the hidden part. @@ -250,7 +247,7 @@ fn a_reply_hides_a_hidden_relative_path_written_as_absolute() { /// by their file names; plain text and a path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { - let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); + let base = crate::tests_folder::Folder::new("sent-paths"); let root = base.join("in"); std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); @@ -259,7 +256,6 @@ fn a_request_sends_paths_that_an_error_hides() { let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); - let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); let want = [ pair("outside/h.svg", "h.svg"), @@ -276,7 +272,7 @@ fn a_request_sends_paths_that_an_error_hides() { /// relative name and a path inside the root are not. #[test] fn an_absolute_path_outside_the_read_root_is_unreadable() { - let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id())); + let base = crate::tests_folder::Folder::new("unreadable-absolute"); std::fs::create_dir_all(base.join("in")).unwrap(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); let inside = base.join("in").join("a.svg").to_string_lossy().into_owned(); @@ -295,7 +291,6 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() { assert!(!super::unreadable_absolute("keep")); assert!(!super::unreadable_absolute(&inside)); }); - let _ = std::fs::remove_dir_all(base); } /// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps @@ -395,12 +390,10 @@ fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { #[cfg(not(windows))] #[test] fn a_sent_path_keeps_a_backslash_in_its_file_name() { - let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new("sent-backslash"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); let mut sent = Vec::new(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent)); - let _ = std::fs::remove_dir_all(root); assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]); } @@ -412,13 +405,11 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { let panel = "library:swatches:loaded//b.svg".to_string(); app.ui.color_guide_limit = "loaded//b.svg".to_string(); app.ui.open_panel = Some(panel); - let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new("top-level-library"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || { (input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({}))) }); - let _ = std::fs::remove_dir_all(root); assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}"); assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}"); assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); @@ -429,9 +420,8 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { #[test] fn a_sent_path_reads_the_same_whatever_the_outcome() { let app = VectorcraftApp::new(Session::new(), Services::default()); - let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new("sent-outcome"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); let sent = json!({"path": "/outside/b.svg"}); let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}}); let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"}); @@ -439,7 +429,6 @@ fn a_sent_path_reads_the_same_whatever_the_outcome() { super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default()); super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default()); }); - let _ = std::fs::remove_dir_all(root); assert_eq!(done["result"]["path"], "b.svg", "{done}"); assert_eq!(failed["error"], "can't read b.svg", "{failed}"); } @@ -1157,7 +1146,7 @@ fn panels_that_redraw_apply_a_merge_on_another_thread_first() { /// applies the merge first: the file holds it, and closing asks to save it. #[test] fn save_and_close_apply_a_merge_on_another_thread_first() { - let dir = vectorcraft_testkit::temp_dir("control-merge-save"); + let dir = crate::tests_folder::Folder::adopt(vectorcraft_testkit::temp_dir("control-merge-save")); for then in ["file.saveAs", "file.exportAs", "file.close"] { let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index c26cae36ac..34156f2c41 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -432,6 +432,42 @@ fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) { } } +/// The Live Paint Bucket: a paint bucket tipped toward the hotspot at its lower left, its mouth +/// down-left with a rim across it, and a drop on the hotspot. Empty (white) with a small drop where +/// a click paints nothing; filled, with a larger drop, where a click fills a face. +fn paint_bucket(p: &mut Ink, o: Pos2, filled: bool) { + // `u` runs from the hotspot up-right into the bucket, `v` across it. + let (d, n) = (vec2(1.0, -1.0).normalized(), vec2(1.0, 1.0).normalized()); + let at = |u: f32, v: f32| o + u * d + v * n; + poly(p, vec![at(5.5, -6.0), at(16.5, -4.5), at(16.5, 4.5), at(5.5, 6.0)], if filled { INK } else { HALO }, INK); + poly(p, vec![at(4.5, -7.0), at(6.5, -7.0), at(6.5, 7.0), at(4.5, 7.0)], INK, INK); + if filled { + p.circle_filled(o, 3.5, HALO); + p.circle_filled(o, 2.2, INK); + } else { + p.circle_filled(o, 2.5, HALO); + p.circle_filled(o, 1.25, INK); + } +} + +/// The Live Paint Bucket over an edge it paints: a brush, its bristles' tip on the hotspot, the +/// white ferrule and the black handle running up-right. +fn paint_brush(p: &mut Ink, o: Pos2) { + let (d, n) = (vec2(1.0, -1.0).normalized(), vec2(1.0, 1.0).normalized()); + let at = |u: f32, v: f32| o + u * d + v * n; + let bristles = vec![at(0.0, 0.0), at(4.0, -2.2), at(6.0, -2.2), at(6.0, 2.2), at(4.0, 2.2)]; + let ferrule = vec![at(6.0, -2.0), at(9.0, -2.0), at(9.0, 2.0), at(6.0, 2.0)]; + let handle = vec![at(9.0, -1.5), at(19.0, -1.2), at(19.0, 1.2), at(9.0, 1.5)]; + // Every halo first, so no part's halo covers another's outline. + for part in [&bristles, &ferrule, &handle] { + p.add(Shape::closed_line(part.clone(), Stroke::new(3.0, HALO))); + } + for (part, fill) in [(bristles, INK), (ferrule, HALO), (handle, INK)] { + p.add(Shape::convex_polygon(part.clone(), fill, Stroke::NONE)); + p.add(Shape::closed_line(part, Stroke::new(1.0, INK))); + } +} + /// The Hand tool and Space: an open hand (palm and four fingers, thumb to the left), or the same /// hand closed while it drags the view. Windows has no system hand that isn't a pointing finger /// (winit maps egui's Grab and Grabbing to the four-headed move cursor), so it is drawn like the @@ -480,7 +516,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { /// Every cursor with a glyph: its own, drawn here, or the one it borrows. #[cfg(test)] -const GLYPHS: [Cursor; 93] = [ +const GLYPHS: [Cursor; 95] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -548,6 +584,8 @@ const GLYPHS: [Cursor; 93] = [ Cursor::Knife, Cursor::Shaper, Cursor::LivePaintBucket, + Cursor::LivePaintBucketFace, + Cursor::LivePaintBucketEdge, Cursor::Measure, Cursor::PrintTiling, Cursor::SymbolSprayer, @@ -669,6 +707,9 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::LoadedText => loaded_text(ink, p), Cursor::ShapeBuilder => shape_builder(ink, p, false), Cursor::ShapeBuilderErase => shape_builder(ink, p, true), + Cursor::LivePaintBucket => paint_bucket(ink, p, false), + Cursor::LivePaintBucketFace => paint_bucket(ink, p, true), + Cursor::LivePaintBucketEdge => paint_brush(ink, p), Cursor::Hand => hand(ink, p, false), Cursor::HandGrab => hand(ink, p, true), Cursor::ZoomIn => magnifier(ink, p, false), diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index 63ab6c23a1..4a12ff91b6 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -292,6 +292,45 @@ fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point( } } +/// The Live Paint Bucket's cursors are drawn, not the crosshair: an empty bucket where a click +/// paints nothing, a filled bucket with a larger drop on the hotspot where it fills a face, and a +/// brush with its tip on the hotspot where it paints an edge. +#[test] +fn the_live_paint_bucket_shows_a_bucket_or_a_brush() { + for ppp in [1.0f32, 2.0] { + let img = |c| rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}")); + let (cross, empty, filled, brush) = + (img(Cursor::Crosshair), img(Cursor::LivePaintBucket), img(Cursor::LivePaintBucketFace), img(Cursor::LivePaintBucketEdge)); + // The pixel nearest the point `u` points up-right of the hotspot and `v` down-right across. + let at = |img: &CustomCursorImage, u: f32, v: f32| { + let s = std::f32::consts::FRAC_1_SQRT_2; + let [hx, hy] = img.hotspot.map(f32::from); + let (x, y) = ((hx + (u + v) * s * ppp - 0.5).round(), (hy + (v - u) * s * ppp - 0.5).round()); + let inside = x >= 0.0 && y >= 0.0 && x < f32::from(img.size[0]) && y < f32::from(img.size[1]); + if inside { pixel(img, x as usize, y as usize) } else { [0; 4] } + }; + let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; + let white = |p: [u8; 4]| p[3] > 200 && p[0] > 192; + assert!(white(at(&empty, 11.0, 2.0)), "the empty bucket's body is white at {ppp}"); + assert!(inked(at(&filled, 11.0, 2.0)), "the filled bucket's body is ink at {ppp}"); + assert!(at(&empty, 0.0, 0.0)[3] > 0 && inked(at(&filled, 0.0, 0.0)), "a drop on the hotspot at {ppp}"); + if ppp == 2.0 { + assert!(inked(at(&filled, 1.4, 0.0)) && !inked(at(&empty, 2.0, 0.0)), "the filled bucket's drop is larger"); + } + assert!(inked(at(&brush, 3.0, 0.0)), "the bristles by the hotspot at {ppp}"); + assert!(white(at(&brush, 7.5, 0.0)), "the white ferrule at {ppp}"); + assert!(inked(at(&brush, 14.0, 0.0)), "the handle at {ppp}"); + assert!(!inked(at(&brush, 11.0, 4.0)), "the brush is thin where the bucket is wide at {ppp}"); + assert!(white(at(&empty, 14.0, 0.0)), "the empty bucket has no handle there at {ppp}"); + for (c, i) in [(Cursor::LivePaintBucket, &empty), (Cursor::LivePaintBucketFace, &filled), (Cursor::LivePaintBucketEdge, &brush)] { + assert!(c.stand_in().is_none(), "{c:?} is drawn"); + assert_ne!(i, &cross, "{c:?} is not the crosshair at {ppp}"); + assert_eq!(rasterize(c.precise(), ppp).as_ref(), Some(&cross), "{c:?} with Use Precise Cursors at {ppp}"); + } + assert!(empty != filled && filled != brush && empty != brush, "three looks at {ppp}"); + } +} + #[test] fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() { let mut images = Images::default(); diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index f356f18b68..31f83c8bba 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -8,6 +8,17 @@ //! item shows or hides it, and is kept with the workspace it was made in (`Workspace::toolbars`). //! Window › Toolbars › Manage Toolbars (also in the drawer's menu) renames, copies and deletes them. //! +//! A toolbar docks in a lane along the window's left or right edge (on the left, right of the Tools +//! panel docked there or beside the canvas), when its title bar is dropped on the edge (lit up while it is dragged), or with +//! Dock to Left and Dock to Right in its title bar's context menu (`window.toolbar.dock`). Several +//! toolbars docked at one edge stack from the top in the order they docked, each with its share of +//! the lane's height, and the lane is as wide as its widest toolbar (a lane too short for its +//! stack wraps it into more columns, up to half the width beside it, and past that the toolbars +//! show only their title bars, which the mouse wheel scrolls through when even they do not fit, so +//! none covers another). Dragging a docked toolbar's +//! title bar out of it floats it under the pointer, as Float Toolbar does where it floated last +//! (`window.toolbar.float`); Escape during the drag puts it back. These are VectorCraft's own rules. +//! //! Behavior from the public documentation: //! (Create and manage toolbars: New Toolbar //! from Window › Toolbars or the drawer's flyout menu, a name and OK make a blank toolbar, its Edit @@ -41,6 +52,14 @@ const GRIP: f32 = 14.0; const TITLE_BUTTON: f32 = 12.0; /// Where the first toolbar made floats, right of the Tools panel; the next ones go lower. const NEW_AT: [f32; 2] = [96.0, 120.0]; +/// The space around each toolbar docked in a lane, and between two of them. +const LANE_GAP: f32 = 6.0; +/// Width of the strip along the window's edge where a dropped title bar docks its toolbar, while +/// that edge's lane is narrower or empty. +const ZONE: f32 = 48.0; +/// Points the pointer travels from its press before a drop docks (a click on the title bar of a +/// toolbar floating near the edge doesn't dock it). +const TRAVEL: f32 = 4.0; /// A toolbar's name without leading and trailing spaces, in lowercase. Names with the same key are /// the same name: lookups, the check that a name is free, sanitizing and each toolbar's area @@ -105,6 +124,8 @@ pub(crate) fn create(ui: &mut UiState, name: &str) -> Result { slots: vec![], shown: true, pos, + dock: None, + dock_right: false, slot_tool: Default::default(), double: false, floating: vec![], @@ -113,6 +134,95 @@ pub(crate) fn create(ui: &mut UiState, name: &str) -> Result { Ok(name.to_string()) } +/// Number the toolbars docked at each edge 0, 1, 2… from the top, in their order (in the list's +/// order where two have the same place). +fn renumber(bars: &mut [CustomToolbar]) { + for right in [false, true] { + let mut on: Vec<(Option, usize)> = + bars.iter().enumerate().filter(|(_, b)| b.dock.is_some() && b.dock_right == right).map(|(k, b)| (b.dock, k)).collect(); + on.sort_unstable(); + for (n, (_, k)) in on.into_iter().enumerate() { + if let Some(b) = bars.get_mut(k) { + b.dock = Some(n as u32); + } + } + } +} + +/// Dock the toolbar at `k` at the right edge or the left, at place `at` of that edge's stack (none +/// or past the end: at the end); the toolbars from that place down move down one. Returns its place. +fn dock_at(bars: &mut [CustomToolbar], k: usize, right: bool, at: Option) -> u32 { + if let Some(b) = bars.get_mut(k) { + b.dock = None; + } + renumber(bars); + let n = bars.iter().filter(|b| b.dock.is_some() && b.dock_right == right).count() as u32; + let at = at.map_or(n, |at| at.min(n)); + for b in bars.iter_mut().filter(|b| b.dock_right == right) { + if let Some(o) = b.dock.as_mut() + && *o >= at + { + *o += 1; + } + } + if let Some(b) = bars.get_mut(k) { + b.dock = Some(at); + b.dock_right = right; + } + at +} + +/// Where the toolbar is: `{toolbar, floating, side, order, pos}`, the edge it docks at (while it +/// floats, the edge it docked at last), its place in that edge's stack while docked (else null) and +/// where it floats (while docked, where it floated last). +fn placement(bar: &CustomToolbar) -> Value { + let side = if bar.dock_right { "right" } else { "left" }; + json!({ "toolbar": bar.name, "floating": bar.dock.is_none(), "side": side, "order": bar.dock, "pos": bar.pos }) +} + +/// The toolbars that show docked at the right edge or the left, by index, from the top. +fn docked(ui: &UiState, right: bool) -> Vec { + let mut on: Vec<(Option, usize)> = ui + .custom_toolbars + .iter() + .enumerate() + .filter(|(_, b)| b.shown && b.dock.is_some() && b.dock_right == right) + .map(|(k, b)| (b.dock, k)) + .collect(); + on.sort_unstable(); + on.into_iter().map(|(_, k)| k).collect() +} + +/// Width of `bar` inside its border: one column of tool buttons, or two. +fn bar_width(bar: &CustomToolbar) -> f32 { + WIDTH + if bar.double { BUTTON } else { 0.0 } +} + +/// The least height a docked toolbar draws at: its border, its title bar and the gap under it, the +/// least height its tools scroll in (`egui::ScrollArea`'s default `min_scrolled_height`, 64 points) +/// and its bottom margin. +const DOCKED_LEAST: f32 = 2.0 + GRIP + 4.0 + 64.0 + 4.0; + +/// The columns a lane `height` points tall lays `n` docked toolbars out in, at most `most` of them, +/// and the most toolbars each column holds: one column while each toolbar gets at least +/// [`DOCKED_LEAST`] points of the lane's height, else as few more columns as give every toolbar +/// that much, so none covers another. With `most` columns too few for that, the toolbars spread +/// evenly over `most` columns, and those with less than [`DOCKED_LEAST`] points show only their +/// title bars ([`docked_places`]). +fn lane_columns(n: usize, height: f32, most: usize) -> (usize, usize) { + let fit = ((height - LANE_GAP) / (DOCKED_LEAST + LANE_GAP)).floor(); + let fit = if fit.is_finite() && fit >= 1.0 { fit as usize } else { 1 }; + let per = n.div_ceil(n.div_ceil(fit).clamp(1, most.max(1))).max(1); + (n.div_ceil(per).max(1), per) +} + +/// The most columns of toolbars at most `widest` points wide inside their borders that `width` +/// points hold with [`LANE_GAP`] around each: at least one. +fn most_columns(width: f32, widest: f32) -> usize { + let most = ((width - LANE_GAP) / (widest.max(1.0) + LANE_GAP) + 1e-3).floor(); + if most.is_finite() && most >= 1.0 { most as usize } else { 1 } +} + /// Toolbar `name`, shown after Tab hid the toolbars, shows alone, as Window › Tools shows the /// Tools panel: the other toolbars Tab hid are turned off, and the next Tab hides it. fn show_alone_after_tab(ui: &mut UiState, name: &str) { @@ -131,7 +241,8 @@ fn index(ui: &UiState, name: &str) -> Result { /// The All Tools drawer beside the toolbar named `old`, and a group's flyout open on it (`ctx`), /// close when it is deleted (`new` is `None`) or renamed; a new name that changes only the case -/// leaves them open on it. +/// leaves them open on it. A drag of its title bar in progress ends with them, so Escape does not +/// put a toolbar that takes the name later back where this one was. fn close_popups(ui: &mut UiState, ctx: Option<&egui::Context>, old: &str, new: Option<&str>) { let same = new.is_some_and(|n| key(n) == key(old)); if drawer_target(ui).map(key) == Some(key(old)) { @@ -143,6 +254,9 @@ fn close_popups(ui: &mut UiState, ctx: Option<&egui::Context>, old: &str, new: O } } let Some(ctx) = ctx else { return }; + if !same && ctx.data(|d| d.get_temp::(move_id())).is_some_and(|m| key(&m.name) == key(old)) { + ctx.data_mut(|d| d.remove::(move_id())); + } let id = crate::toolbar::flyout_toolbar_id(); if ui.flyout.is_some() && ctx.data(|d| d.get_temp::>(id)).flatten().is_some_and(|t| key(&t) == key(old)) { if same { @@ -193,6 +307,8 @@ pub(crate) fn duplicate(ui: &mut UiState, name: Option<&str>) -> Result, name: &str) let k = index(ui, name)?; close_popups(ui, ctx, name, None); ui.custom_toolbars.remove(k); + renumber(&mut ui.custom_toolbars); Ok(()) } /// Keep named toolbars, each name once whatever its case and at most [`MAX_NAME`] characters, of /// known tools, each once in its toolbar, at a position that is a number, with one strip per group -/// torn off them, and each group's tool chosen last only when the group holds it (a hand-edited -/// preferences file or workspace). +/// torn off them, each group's tool chosen last only when the group holds it, and the docked ones +/// numbered 0, 1, 2… at each edge (a hand-edited preferences file or workspace). pub(crate) fn sanitize(bars: &mut Vec) { let mut names = std::collections::BTreeSet::new(); bars.retain_mut(|b| { @@ -237,6 +354,7 @@ pub(crate) fn sanitize(bars: &mut Vec) { !b.name.is_empty() && b.name.chars().count() <= MAX_NAME && names.insert(key(&b.name)) }); bars.truncate(MAX); + renumber(bars); } /// Keep the strips torn off `bar` whose group it still holds, each with the group's tools as they @@ -306,11 +424,54 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + let k = index(&app.ui, name)?; + let bar = app.ui.custom_toolbars.get(k).ok_or_else(|| format!("no toolbar named `{name}`"))?; + // Docking: at the edge it docked at last unless `side` says which. + let right = match p.get("side") { + None | Some(Value::Null) => bar.dock_right, + // `Start` and `End`, as panels take them, too. + Some(Value::String(s)) if s.eq_ignore_ascii_case("left") || s.eq_ignore_ascii_case("start") => false, + Some(Value::String(s)) if s.eq_ignore_ascii_case("right") || s.eq_ignore_ascii_case("end") => true, + Some(Value::String(s)) => return Err(format!("side is left or right, not `{s}`")), + Some(v) => return Err(format!("side is left or right, not `{v}`")), + }; + let others = app.ui.custom_toolbars.iter().enumerate().filter(|&(i, b)| i != k && b.dock.is_some() && b.dock_right == right).count(); + let at = match p.get("order") { + None | Some(Value::Null) => None, + Some(v) => match v.as_u64().filter(|&n| n <= others as u64) { + Some(n) => Some(n as u32), + None => return Err(format!("order is a whole number from 0 to {others}, not `{v}`")), + }, + }; + // Docked at that edge already, with no place asked for, it stays where it is. + if !(bar.dock.is_some() && bar.dock_right == right && at.is_none()) { + dock_at(&mut app.ui.custom_toolbars, k, right, at); + } + app.ui.custom_toolbars.get(k).map(placement).ok_or_else(|| format!("no toolbar named `{name}`")) + })(), + "window.toolbar.float" => (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + let k = index(&app.ui, name)?; + let at = crate::tools_float::position(p)?; + if let Some(b) = app.ui.custom_toolbars.get_mut(k) { + b.dock = None; + if let Some(at) = at { + b.pos = [at.x, at.y]; + } + } + renumber(&mut app.ui.custom_toolbars); + app.ui.custom_toolbars.get(k).map(placement).ok_or_else(|| format!("no toolbar named `{name}`")) + })(), "window.toolbar.list" => Ok(json!( app.ui .custom_toolbars .iter() - .map(|b| json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots})) + .map(|b| { + let side = if b.dock_right { "right" } else { "left" }; + json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots, "floating": b.dock.is_none(), "side": side, "order": b.dock}) + }) .collect::>() )), _ => return None, @@ -563,17 +724,268 @@ enum Action { Hide(String), /// The title bar's double arrow: two columns (true) or one. Columns(String, bool), + /// Dock to Left (false) or Dock to Right (true) in the title bar's context menu. + Dock(String, bool), + /// Float Toolbar in the title bar's context menu. + Float(String), } -/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged: the -/// title bar's × hides it and its double arrow lays the tools out in one column or two; then its -/// tool buttons (a group's flyout opens as the Tools panel's do), the + button whose menu adds and -/// removes tools, and the ••• button that opens the All Tools drawer beside it. +/// The lane along the window's right edge (true) or left edge where docked toolbars show, as last +/// laid out; removed while it is empty. +fn lane_id(right: bool) -> egui::Id { + egui::Id::unique(("custom-toolbar-lane", right)) +} + +/// Where a title bar dropped docks its toolbar at the right edge (true) or the left, as last laid +/// out. +fn zone_id(right: bool) -> egui::Id { + egui::Id::unique(("custom-toolbar-zone", right)) +} + +/// A drag of a toolbar's title bar in progress: the toolbar, where the press began and how far that +/// is from the toolbar's corner, where the toolbar was before the drag (to put it back on Escape) +/// and whether Escape put it back. +#[derive(Clone, Debug)] +struct Move { + name: String, + from: egui::Pos2, + grab: egui::Vec2, + dock: Option, + right: bool, + pos: [f32; 2], + cancelled: bool, +} + +fn move_id() -> egui::Id { + egui::Id::unique("custom-toolbar-move") +} + +/// The lanes along the window's left and right edges where the docked toolbars that show are drawn, +/// each as wide as its widest toolbar with [`LANE_GAP`] either side (times the columns of +/// [`lane_columns`] when the lane is too short for one, at most half the width left for the lane), +/// and the targets where a title +/// bar dropped docks its toolbar: from the window's edge across the lane, or across a strip +/// [`ZONE`] points wide when the lane is narrower or empty, and on the left across the Tools panel +/// docked there too. Laid out before the canvas and the panels, which take the space left, except +/// the left lane while Tools is docked beside the canvas ([`left_lane_beside_canvas`]). A lane +/// laid out anew draws again, since its toolbars were placed by the lane of the frame before. +pub(crate) fn lanes(app: &VectorcraftApp, ui: &mut egui::Ui) { + for right in [false, true] { + if right || !left_lane_beside_canvas(&app.ui) { + let (edge, room) = (ui.ctx().content_rect(), ui.available_rect_before_wrap().width() / 2.0); + lane(app, ui, right, edge, room); + } + } +} + +/// The width the canvas panel keeps for the left lane while it stands beside the canvas +/// ([`left_lane_beside_canvas`]): one column of its widest toolbar, none while no docked toolbar +/// shows there. +pub(crate) fn beside_canvas_reserve(ui: &UiState) -> f32 { + if !left_lane_beside_canvas(ui) || ui.tab_hid.is_some() { + return 0.0; + } + let widest = docked(ui, false).iter().filter_map(|&k| ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + if widest > 0.0 { widest + 2.0 * LANE_GAP } else { 0.0 } +} + +/// With Keep Tools Beside the Canvas on and Tools docked on the left, Tools draws inside the canvas +/// panel, right of the panels docked on the left, and the left lane goes right of it there +/// ([`lane_beside_canvas`]). +pub(crate) fn left_lane_beside_canvas(ui: &UiState) -> bool { + ui.tools_beside_canvas && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() && !ui.toolbar_right +} + +/// The left lane inside the canvas panel, right of the Tools panel docked beside the canvas +/// (`toolbar::show_beside_canvas`, after Tools). Its target reaches from the canvas's left edge, +/// across Tools, over the lane. It wraps into more columns only as far as the canvas keeps its +/// least width, 200 points (`panel_docking`'s canvas limits, which add [`beside_canvas_reserve`]). +pub(crate) fn lane_beside_canvas(app: &VectorcraftApp, ui: &mut egui::Ui) { + if left_lane_beside_canvas(&app.ui) { + let avail = ui.available_rect_before_wrap(); + let tools = ui.ctx().data(|d| d.get_temp::(crate::toolbar::docked_rect_id())); + let edge = tools.map_or(avail, |t| t.union(avail)); + lane(app, ui, false, edge, (avail.width() / 2.0).min(avail.width() - 200.0)); + } +} + +/// The lane at the right edge (true) or the left of `ui`'s space, at most `room` points wide unless +/// one column is wider, and its target, which reaches out to `edge`'s side. +fn lane(app: &VectorcraftApp, ui: &mut egui::Ui, right: bool, edge: egui::Rect, room: f32) { + let ctx = ui.ctx().clone(); + let shown = app.ui.tab_hid.is_none() && app.ui.screen_mode < 3; + let on = if shown { docked(&app.ui, right) } else { vec![] }; + let widest = on.iter().filter_map(|&k| app.ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + let avail = ui.available_rect_before_wrap(); + let most = most_columns(room, widest); + let cols = lane_columns(on.len(), avail.height(), most).0 as f32; + let width = if widest > 0.0 { cols * widest + (cols - 1.0) * LANE_GAP } else { 0.0 }; + let reach = (width + 2.0 * LANE_GAP).max(ZONE); + let zone = if right { + egui::Rect::from_min_max(pos2(avail.right() - reach, avail.top()), pos2(edge.right(), avail.bottom())) + } else { + egui::Rect::from_min_max(pos2(edge.left(), avail.top()), pos2(avail.left() + reach, avail.bottom())) + }; + let lane = (width > 0.0).then(|| { + let panel = if right { egui::Panel::right("custom-toolbar-lane-right") } else { egui::Panel::left("custom-toolbar-lane-left") }; + panel.resizable(false).exact_size(width + 2.0 * LANE_GAP).frame(egui::Frame::NONE).show(ui, |_| {}).response.rect + }); + if ctx.data(|d| d.get_temp::(lane_id(right))) != lane { + ctx.request_repaint(); + } + ctx.data_mut(|d| { + d.insert_temp(zone_id(right), zone); + match lane { + Some(lane) => { + d.insert_temp(lane_id(right), lane); + } + None => d.remove::(lane_id(right)), + } + }); +} + +/// Where each docked toolbar that shows is drawn, by index: its top-left corner and the most height +/// it takes, and whether it shows only its title bar. Each lane stacks its toolbars from the top in +/// their order, each with an equal share of its column's height; a toolbar shorter than its share +/// leaves the rest to the ones below it. A lane too short for one column wraps its stack into the +/// columns of [`lane_columns`], left to right, as many as the lane is wide enough for, so every +/// toolbar's title bar and tools stay in view; when even those columns give a toolbar less than +/// [`DOCKED_LEAST`] points, the toolbars of that column show only their title bars, which still +/// float or dock them. When a column is too short for even their title bars, it shows as many as +/// fit and the mouse wheel over it scrolls through the rest; those scrolled out of view have no +/// corner. Every toolbar stays inside the lane. A toolbar docked since the lanes were last laid +/// out is not drawn until they are. +fn docked_places(ui: &UiState, ctx: &egui::Context) -> Vec<(usize, Option, f32, bool)> { + let mut out = vec![]; + // The least height a toolbar takes: its title bar, its border and its bottom margin. + let least = GRIP + LANE_GAP; + for right in [false, true] { + let Some(lane) = ctx.data(|d| d.get_temp::(lane_id(right))) else { continue }; + let on = docked(ui, right); + let widest = on.iter().filter_map(|&k| ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + let (cols, per) = lane_columns(on.len(), lane.height(), most_columns(lane.width(), widest)); + let pitch = (lane.width() - LANE_GAP) / cols as f32; + for (c, column) in on.chunks(per).enumerate() { + let middle = lane.left() + LANE_GAP / 2.0 + pitch * (c as f32 + 0.5); + // More than even their title bars fit: the column shows as many as fit, and the wheel + // over the column scrolls through the others, one toolbar a notch. + let fits = ((lane.height() - LANE_GAP) / (least + LANE_GAP) + 1e-3).floor().max(1.0) as usize; + let column = if column.len() > fits { + let id = lane_id(right).with(("first", c)); + let left = lane.left() + LANE_GAP / 2.0 + pitch * c as f32; + let strip = egui::Rect::from_x_y_ranges(left..=left + pitch, lane.y_range()); + let notch = ctx.input(|i| { + let over = i.pointer.hover_pos().is_some_and(|p| strip.contains(p)); + let wheel = i.events.iter().filter_map(|e| if let egui::Event::MouseWheel { delta, .. } = e { Some(delta.y) } else { None }); + if over { wheel.sum::() } else { 0.0 } + }); + let mut first = ctx.data(|d| d.get_temp::(id)).unwrap_or(0); + if notch > 0.0 { + first = first.saturating_sub(1); + } else if notch < 0.0 { + first += 1; + } + let first = first.min(column.len() - fits); + ctx.data_mut(|d| d.insert_temp(id, first)); + let (before, rest) = column.split_at(first); + let (shown, after) = rest.split_at(fits); + out.extend(before.iter().chain(after).map(|&k| (k, None, 0.0, true))); + shown + } else { + column + }; + let n = column.len() as f32; + let share = (lane.height() - LANE_GAP * (n + 1.0)) / n.max(1.0); + // Too little height for its tools: the title bar alone. + let bare = share < DOCKED_LEAST; + let share = share.max(least); + let mut y = lane.top() + LANE_GAP; + for &k in column { + let Some(bar) = ui.custom_toolbars.get(k) else { continue }; + let drawn = ctx.memory(|m| m.area_rect(area_id(&bar.name))); + let w = drawn.map_or(bar_width(bar) + 2.0, |r| r.width()); + // Drawn last frame taller than its share (its tools scroll only above a least + // height), it moves up to end at the lane's bottom. + let tall = drawn.map_or(least, |r| r.height().max(least)); + let top = y.min(lane.bottom() - tall).max(lane.top()); + let room = share.min(lane.bottom() - top).max(least); + out.push((k, Some(pos2((middle - w / 2.0).round(), top)), room, bare)); + y = top + drawn.map_or(room, |r| r.height().min(room)) + LANE_GAP; + } + } + } + out +} + +/// The edge a title bar released at the pointer docks its toolbar at (true: the right), with the +/// target to light up: none until the pointer has moved [`TRAVEL`] points from the press at `from`. +fn drop_target(ctx: &egui::Context, from: egui::Pos2) -> Option<(egui::Rect, bool)> { + let at = ctx.input(|i| i.pointer.interact_pos()).filter(|at| at.distance(from) > TRAVEL)?; + [false, true].into_iter().find_map(|right| ctx.data(|d| d.get_temp::(zone_id(right))).filter(|z| z.contains(at)).map(|z| (z, right))) +} + +/// Put the toolbar being dragged back where the drag began; returns whether a drag was in progress. +fn cancel(ui: &mut UiState, ctx: &egui::Context) -> bool { + let Some(mut m) = ctx.data(|d| d.get_temp::(move_id())).filter(|m| !m.cancelled) else { return false }; + if let Ok(k) = index(ui, &m.name) { + match m.dock { + Some(at) => { + dock_at(&mut ui.custom_toolbars, k, m.right, Some(at)); + } + None => { + if let Some(b) = ui.custom_toolbars.get_mut(k) { + b.dock = None; + b.dock_right = m.right; + } + renumber(&mut ui.custom_toolbars); + } + } + if let Some(b) = ui.custom_toolbars.get_mut(k) { + b.pos = m.pos; + } + } + m.cancelled = true; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + true +} + +/// Escape while a toolbar's title bar is dragged puts the toolbar back where the drag began, and the +/// press runs no shortcut ([`crate::tools_float::escape_cancelled_move`]). +pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && cancel(&mut app.ui, ctx) { + crate::tools_float::mark_escape_cancelled(ctx); + } +} + +/// Light up the target a title bar dropped now docks its toolbar at. +fn highlight(ctx: &egui::Context, zone: egui::Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("custom-toolbar-drop"))); + painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); +} + +/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged or +/// docked in its edge's lane ([`lanes`]): the title bar's × hides it, its double arrow lays the +/// tools out in one column or two, and its context menu docks or floats it; then its tool buttons +/// (a group's flyout opens as the Tools panel's do), the + button whose menu adds and removes +/// tools, and the ••• button that opens the All Tools drawer beside it. pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + // A drag ends with the press, also for a toolbar hidden while it was dragged. + let end_move = || { + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(move_id())); + } + }; // Tab hides them, also when Window › Tools shows the Tools panel again. if app.ui.tab_hid.is_some() || app.ui.custom_toolbars.iter().all(|b| !b.shown) { + end_move(); return; } + // Losing the window's focus puts a toolbar being dragged back, as Escape does. + if !ctx.input(|i| i.focused) { + cancel(&mut app.ui, ctx); + } + let places = docked_places(&app.ui, ctx); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let active = app.session.tool_id(); @@ -592,11 +1004,27 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let id = area_id(&bar.name); let cols = if bar.double { 2 } else { 1 }; let width = WIDTH + BUTTON * (cols - 1) as f32; - // Kept on screen by its title bar and first tools: the rest scrolls. - let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0))); - let pos = crate::tools_float::clamp(bar.pos, size, screen); + // Docked: where its lane puts it, its tools scrolling in its share of the lane, or only its + // title bar when that share is too small for them. + let (pos, limit, bare) = if bar.dock.is_some() { + let Some(&(_, pos, share, bare)) = places.iter().find(|(i, ..)| *i == k) else { + // Docked since the lanes were laid out: drawn in the next frame. + ctx.request_repaint(); + continue; + }; + // Scrolled out of its column's view. + let Some(pos) = pos else { continue }; + (pos, share - LANE_GAP, bare) + } else { + // Kept on screen by its title bar and first tools: the rest scrolls. + let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0))); + let pos = crate::tools_float::clamp(bar.pos, size, screen); + (pos, screen.bottom() - pos.y - 12.0, false) + }; let edit_this = editing.as_deref() == Some(bar.name.as_str()); let mut moved = None; + // The title bar's drag this frame: started, going on, ended. + let mut drag = (false, false, false); let mut drops = None; // A Learn link locating a tool of this toolbar: its button, scrolled into view once. let locate = crate::panels::learn::locate::wanted(&app.ui, Some(&bar.name)); @@ -616,9 +1044,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| { ui.set_width(width); - // The tools and the ••• button scroll when they don't fit in the window under the - // title bar. - ui.set_max_height(screen.bottom() - pos.y - 12.0); + // The tools and the ••• button scroll when they don't fit in the window (docked: in + // its share of the lane) under the title bar. + ui.set_max_height(limit); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); let middle = ui.max_rect().center().x; // The title bar: × at its left end, the double arrow at its right end, and the grip @@ -641,14 +1069,31 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if aresp.on_hover_text(tl!("Toggle single/double column")).clicked() { actions.push(Action::Columns(bar.name.clone(), !bar.double)); } - // Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not stop on it. - let gresp = ui.interact(grip, id.with("grip"), Sense::DRAG); + // Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not + // stop on it. It senses clicks for its context menu. + let gresp = ui.interact(grip, id.with("grip"), Sense::CLICK | Sense::DRAG); for n in 0..3 { let y = grip.center().y - 2.0 + n as f32 * 2.0; let half = (grip.width() / 2.0 - 2.0).clamp(0.0, 8.0); ui.painter().line_segment([pos2(grip.center().x - half, y), pos2(grip.center().x + half, y)], Stroke::new(0.5, t.text_disabled)); } gresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &bar.name)); + // Its context menu: Dock to Left and Dock to Right, the edge it is docked at checked, + // and Float Toolbar while it is docked. + gresp.context_menu(|ui| { + for (right, label) in [(false, tl!("Dock to Left")), (true, tl!("Dock to Right"))] { + let here = bar.dock.is_some() && bar.dock_right == right; + if widgets::menu_item(ui, label, !here, here) { + actions.push(Action::Dock(bar.name.clone(), right)); + ui.close(); + } + } + if widgets::menu_item(ui, tl!("Float Toolbar"), bar.dock.is_some(), false) { + actions.push(Action::Float(bar.name.clone())); + ui.close(); + } + }); + drag = (gresp.drag_started(), gresp.dragged(), gresp.drag_stopped()); // Held where the press took it, from the toolbar's corner, while it is dragged. let grab = id.with("grab"); if gresp.dragged() { @@ -662,6 +1107,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.data_mut(|d| d.remove::(grab)); gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(&bar.name); } + if bare { + return; + } // A gap keeps a press near the grip bar's lower edge on the bar. ui.add_space(4.0); let mut buttons: Vec<(usize, egui::Rect)> = vec![]; @@ -713,11 +1161,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds, Some(&bar.name)); } }); - if let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite()) - && let Some(b) = app.ui.custom_toolbars.get_mut(k) - { - b.pos = [p.x, p.y]; - } + drag_title_bar(app, ctx, k, drag, moved); // The button is marked when it shows the located tool, else the group's flyout opens on it; // a group torn off into a strip is marked there. if did_scroll { @@ -788,8 +1232,71 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.status(e); } } + Action::Dock(name, right) => { + if let Err(e) = app.run("window.toolbar.dock", json!({ "name": name, "side": if right { "right" } else { "left" } })) { + app.status(e); + } + } + Action::Float(name) => { + if let Err(e) = app.run("window.toolbar.float", json!({ "name": name })) { + app.status(e); + } + } } } + end_move(); +} + +/// The drag of the title bar of the toolbar at `k` this frame (`drag`: started, going on, ended), +/// with the pointer holding its corner at `moved`. A floating toolbar follows the pointer, and the +/// target under the pointer lights up; released there, it docks at the end of that edge's stack. A +/// docked toolbar stays in its lane until the pointer leaves the lane, and then floats under the +/// pointer, also when the press is released in the frame it leaves the lane. +/// After Escape, the rest of the drag moves nothing. +fn drag_title_bar(app: &mut VectorcraftApp, ctx: &egui::Context, k: usize, drag: (bool, bool, bool), moved: Option) { + let (started, dragging, stopped) = drag; + let Some(bar) = app.ui.custom_toolbars.get(k) else { return }; + if started && let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) { + let grab = ctx.memory(|m| m.area_rect(area_id(&bar.name))).map_or(egui::Vec2::ZERO, |r| from - r.min); + let m = Move { name: bar.name.clone(), from, grab, dock: bar.dock, right: bar.dock_right, pos: bar.pos, cancelled: false }; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + } + let Some(m) = ctx.data(|d| d.get_temp::(move_id())).filter(|m| !m.cancelled && key(&m.name) == key(&bar.name)) else { + return; + }; + let from = m.from; + let mut docked = bar.dock.is_some(); + if docked && (dragging || stopped) { + let at = ctx.input(|i| i.pointer.interact_pos()); + // Where the pointer holds its corner; on the release frame, which reports no move, from + // where the press took it. + let corner = moved.or_else(|| at.map(|at| at - m.grab)).filter(|p| p.x.is_finite() && p.y.is_finite()); + let lane = ctx.data(|d| d.get_temp::(lane_id(bar.dock_right))).or_else(|| ctx.memory(|m| m.area_rect(area_id(&bar.name)))); + let inside = lane.zip(at).is_some_and(|(r, at)| r.contains(at)); + if !inside && let Some(p) = corner { + if let Some(b) = app.ui.custom_toolbars.get_mut(k) { + b.dock = None; + b.pos = [p.x, p.y]; + } + renumber(&mut app.ui.custom_toolbars); + docked = false; + } + } else if dragging + && let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite()) + && let Some(b) = app.ui.custom_toolbars.get_mut(k) + { + b.pos = [p.x, p.y]; + } + if docked { + return; + } + let target = drop_target(ctx, from); + if dragging && let Some((zone, _)) = target { + highlight(ctx, zone); + } + if stopped && let Some((_, right)) = target { + dock_at(&mut app.ui.custom_toolbars, k, right, None); + } } /// What a toolbar's tool buttons need from the frame: the toolbar, the active tool, whether the @@ -970,10 +1477,8 @@ mod tests { let names: Vec<&str> = toolbars_menu(&app).into_iter().filter(|(_, id, _)| *id == "window.toolbar.custom").map(|(l, ..)| l).collect(); assert_eq!(names, ["Mine", "Toolbar 1"]); let list = app.run("window.toolbar.list", json!({})).unwrap(); - assert_eq!( - list, - json!([{"name": "Mine", "shown": true, "double": false, "slots": []}, {"name": "Toolbar 1", "shown": true, "double": false, "slots": []}]) - ); + let blank = |name: &str| json!({"name": name, "shown": true, "double": false, "slots": [], "floating": true, "side": "left", "order": null}); + assert_eq!(list, json!([blank("Mine"), blank("Toolbar 1")])); } /// The New Toolbar dialog: a name typed in it and Enter make the toolbar; a name that is taken is @@ -1598,6 +2103,52 @@ mod tests { assert_eq!(app.ui.flyout, None, "deleted, then its name taken"); } + /// Deleting or renaming a toolbar while its title bar is dragged ends the drag: Escape then + /// leaves a toolbar that took its name where it is. The drag of another toolbar goes on, and so + /// does the drag of a toolbar whose new name changes only the case. + #[test] + fn deleting_or_renaming_the_toolbar_being_dragged_ends_its_drag() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A drag of the toolbar's title bar in progress, begun where the toolbar is now. + let hold = |app: &VectorcraftApp, name: &str| { + let bar = find(&app.ui, name).unwrap(); + let m = Move { + name: bar.name.clone(), + from: pos2(10.0, 10.0), + grab: egui::Vec2::ZERO, + dock: bar.dock, + right: bar.dock_right, + pos: bar.pos, + cancelled: false, + }; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + }; + let held = || ctx.data(|d| d.get_temp::(move_id())).is_some(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "Mine", "side": "right"})).unwrap(); + hold(&app, "Mine"); + delete(&mut app.ui, Some(&ctx), "Other").unwrap(); + assert!(held(), "another toolbar's delete leaves the drag going"); + delete(&mut app.ui, Some(&ctx), "Mine").unwrap(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let at = find(&app.ui, "Mine").unwrap().pos; + assert!(!cancel(&mut app.ui, &ctx), "the deleted toolbar's drag ended"); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at), "deleted, then its name taken"); + app.run("window.toolbar.dock", json!({"name": "Mine", "side": "right"})).unwrap(); + hold(&app, "Mine"); + rename(&mut app.ui, Some(&ctx), "Mine", "mine").unwrap(); + assert!(held(), "a new name that changes only the case leaves the drag going"); + rename(&mut app.ui, Some(&ctx), "mine", "Pens").unwrap(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let at = find(&app.ui, "Mine").unwrap().pos; + assert!(!cancel(&mut app.ui, &ctx), "the renamed toolbar's drag ended"); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at), "renamed, then its name taken"); + assert_eq!(place(&app, "Pens"), (Some(0), true), "the renamed toolbar stays docked"); + } + /// A toolbar deleted while Manage Toolbars has it selected leaves none selected, and a click /// on + or the trash can meant for it does nothing, so the dialog can go on. #[test] @@ -1691,6 +2242,8 @@ mod tests { slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(), shown: true, pos: [f32::NAN, 0.0], + dock: None, + dock_right: false, slot_tool: Default::default(), double: false, floating: ["pen", "nope", "pen"] @@ -1712,7 +2265,10 @@ mod tests { bars[0].slot_tool = chosen(&[("pen", "curvature"), ("curvature", "pen"), ("nope", "pen")]); bars[4].slot_tool = chosen(&[("pen", "curvature")]); bars[5].slot_tool = chosen(&[("pen", "curvature")]); + // Docked places with gaps and repeats, and one dropped with its toolbar, are numbered from 0. + (bars[0].dock, bars[1].dock, bars[4].dock, bars[5].dock) = (Some(7), Some(0), Some(7), Some(3)); sanitize(&mut bars); + assert_eq!(bars.iter().map(|b| b.dock).collect::>(), [Some(1), Some(2), Some(0)]); assert_eq!(bars.iter().map(|b| b.name.as_str()).collect::>(), ["A", "B", "C"], "a name past {MAX_NAME} characters goes too"); assert_eq!(bars[0].slot_tool, chosen(&[("pen", "curvature")])); assert!(bars[1].slot_tool.is_empty() && bars[2].slot_tool.is_empty(), "{:?} {:?}", bars[1].slot_tool, bars[2].slot_tool); @@ -1722,4 +2278,508 @@ mod tests { assert_eq!(bars[0].floating.iter().map(|f| f.tools.join(",")).collect::>(), ["pen,curvature"]); assert!(bars[1].floating.is_empty(), "B holds no group"); } + + /// Where the toolbar named `name` is: its place in its edge's stack (none: floating) and + /// whether that edge is the right one. + fn place(app: &VectorcraftApp, name: &str) -> (Option, bool) { + let b = find(&app.ui, name).unwrap(); + (b.dock, b.dock_right) + } + + /// `window.toolbar.dock` docks a toolbar at an edge, at the end of its stack or at the place + /// asked for, and `window.toolbar.float` floats it where it floated last or at x, y; bad params + /// change nothing. Hiding and renaming keep its place, a copy floats, and deleting closes the + /// stack up. The places are listed, and kept with the workspace and the preferences. + #[test] + fn toolbars_dock_and_float_by_their_commands() { + let mut app = app(); + app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap(); + for name in ["A", "B", "C"] { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + } + assert_eq!(place(&app, "A"), (None, false), "New Toolbar floats"); + let a_pos = find(&app.ui, "A").unwrap().pos; + let r = app.run("window.toolbar.dock", json!({"name": "a"})).unwrap(); + assert_eq!(r, json!({"toolbar": "A", "floating": false, "side": "left", "order": 0, "pos": a_pos})); + app.run("window.toolbar.dock", json!({"name": "B", "side": "left"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "C", "side": "start", "order": 0})).unwrap(); + assert_eq!([place(&app, "C"), place(&app, "A"), place(&app, "B")], [(Some(0), false), (Some(1), false), (Some(2), false)]); + // Docked again at the edge it is docked at, it stays where it is. + app.run("window.toolbar.dock", json!({"name": "A"})).unwrap(); + assert_eq!(place(&app, "A"), (Some(1), false)); + app.run("window.toolbar.dock", json!({"name": "A", "order": 2})).unwrap(); + assert_eq!([place(&app, "C"), place(&app, "B"), place(&app, "A")], [(Some(0), false), (Some(1), false), (Some(2), false)]); + // Bad params are errors and change nothing. + let before = app.ui.custom_toolbars.clone(); + for p in [ + json!({"name": "A", "side": "top"}), + json!({"name": "A", "side": 1}), + json!({"name": "A", "order": 3}), + json!({"name": "A", "order": -1}), + json!({"name": "A", "order": 0.5}), + json!({"name": "A", "order": "1"}), + json!({"name": "Nope"}), + json!({}), + ] { + assert!(app.run("window.toolbar.dock", p.clone()).is_err(), "{p}"); + } + for p in + [json!({"name": "A", "x": 10}), json!({"name": "A", "x": 1e7, "y": 0}), json!({"name": "A", "x": "1", "y": 2}), json!({"name": "Nope"})] + { + assert!(app.run("window.toolbar.float", p.clone()).is_err(), "{p}"); + } + assert_eq!(app.ui.custom_toolbars, before); + // At the right edge; the left edge's stack closes up. + let r = app.run("window.toolbar.dock", json!({"name": "A", "side": "RIGHT"})).unwrap(); + assert_eq!((&r["side"], &r["order"]), (&json!("right"), &json!(0))); + assert_eq!([place(&app, "C"), place(&app, "B")], [(Some(0), false), (Some(1), false)]); + // Floating where it floated last or at x, y; docked again at the edge it docked at last. + let r = app.run("window.toolbar.float", json!({"name": "A"})).unwrap(); + assert_eq!(r, json!({"toolbar": "A", "floating": true, "side": "right", "order": null, "pos": a_pos})); + app.run("window.toolbar.float", json!({"name": "A", "x": 300, "y": 200})).unwrap(); + assert_eq!(find(&app.ui, "A").unwrap().pos, [300.0, 200.0]); + assert_eq!(app.run("window.toolbar.dock", json!({"name": "A"})).unwrap()["side"], json!("right")); + // Hidden and renamed, it keeps its place; a copy floats; deleted, the stack closes up. + app.run("window.toolbar.custom", json!({"name": "C", "show": false})).unwrap(); + app.run("window.toolbar.rename", json!({"name": "C", "to": "Cee"})).unwrap(); + assert_eq!(place(&app, "Cee"), (Some(0), false)); + let copy = app.run("window.toolbar.duplicate", json!({"name": "B"})).unwrap(); + assert_eq!(place(&app, copy["toolbar"].as_str().unwrap()), (None, false)); + app.run("window.toolbar.delete", json!({"name": "Cee"})).unwrap(); + assert_eq!(place(&app, "B"), (Some(0), false)); + let list = app.run("window.toolbar.list", json!({})).unwrap(); + let rows: Vec<_> = list.as_array().unwrap().iter().map(|b| json!([b["name"], b["floating"], b["side"], b["order"]])).collect(); + assert_eq!(rows, [json!(["A", false, "right", 0]), json!(["B", false, "left", 0]), json!(["B Copy", true, "left", null])]); + // Kept with the workspace and the preferences. + let kept = app.ui.custom_toolbars.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Drawing"})).unwrap(); + assert_eq!(app.ui.custom_toolbars, kept); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().custom_toolbars, kept); + // Saved before toolbars docked, a toolbar floats. + let mut old = serde_json::to_value(&kept[0]).unwrap(); + let fields = old.as_object_mut().unwrap(); + assert!(fields.remove("dock").is_some() && fields.remove("dock_right").is_some()); + let old: CustomToolbar = serde_json::from_value(old).unwrap(); + assert_eq!((old.dock, old.dock_right), (None, false)); + } + + /// Docked toolbars show in lanes along the window's edges, the left one right of the Tools + /// panel: stacked from the top, centered, each in its share of the lane, the lane as wide as its + /// widest toolbar and gone while none shows. The canvas and the panels take the space left. + #[test] + fn docked_toolbars_stack_in_lanes_along_the_window_edges() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for name in ["A", "B", "R"] { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + } + for tool in vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).take(30) { + app.run("toolbar.addTool", json!({"tool": tool.id, "toolbar": "A"})).unwrap(); + } + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "B"})).unwrap(); + let mut time = 0.0; + let mut frames = |app: &mut VectorcraftApp| { + for _ in 0..4 { + time += 0.05; + window_frame(app, &ctx, time, 600.0); + } + }; + frames(&mut app); + let rect = |name: &str| ctx.memory(|m| m.area_rect(area_id(name))).unwrap(); + let lane = |right: bool| ctx.data(|d| d.get_temp::(lane_id(right))); + assert!(lane(false).is_none() && lane(true).is_none(), "no lane while every toolbar floats"); + for (name, side) in [("A", "left"), ("B", "left"), ("R", "right")] { + app.run("window.toolbar.dock", json!({ "name": name, "side": side })).unwrap(); + } + frames(&mut app); + let tools = ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).unwrap(); + let (left, right) = (lane(false).unwrap(), lane(true).unwrap()); + let (a, b, r) = (rect("A"), rect("B"), rect("R")); + assert!((left.left() - tools.right()).abs() < 1.0 && (left.width() - WIDTH - 2.0 * LANE_GAP).abs() < 0.5, "{left:?} {tools:?}"); + assert!(left.contains_rect(a) && left.contains_rect(b) && right.contains_rect(r), "{left:?} {a:?} {b:?} {right:?} {r:?}"); + assert!((right.right() - 1200.0).abs() < 0.5, "{right:?}"); + assert!(b.top() >= a.bottom() && (a.center().x - left.center().x).abs() < 1.0 && (b.center().x - a.center().x).abs() < 1.0, "{a:?} {b:?}"); + // A's 30 tools scroll in its half of the lane. + assert!(a.height() <= left.height() / 2.0, "{a:?} {left:?}"); + // The canvas ends left of the right lane. + let canvas_right = ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_right_id())).unwrap().right(); + assert!(canvas_right <= right.left() + 0.5, "{canvas_right} {right:?}"); + // Two columns widen the lane, and the toolbar stays docked. + app.run("window.toolbarColumns", json!({"toolbar": "B", "double": true})).unwrap(); + frames(&mut app); + assert!((lane(false).unwrap().width() - WIDTH - BUTTON - 2.0 * LANE_GAP).abs() < 0.5); + assert!(lane(false).unwrap().contains_rect(rect("B")) && place(&app, "B") == (Some(1), false)); + // Hidden, it keeps its place, and an empty lane goes. + app.run("window.toolbar.custom", json!({"name": "R", "show": false})).unwrap(); + frames(&mut app); + assert!(lane(true).is_none() && !drawn(&ctx, area_id("R"), egui::Order::Middle) && place(&app, "R") == (Some(0), true)); + // Tab hides the lanes with the toolbars. + app.run("window.dock", json!({})).unwrap(); + frames(&mut app); + assert!(lane(false).is_none() && !drawn(&ctx, area_id("A"), egui::Order::Middle)); + } + + /// With Keep Tools Beside the Canvas on, the left lane stands right of Tools, which stands right + /// of the panels docked on the left, and its target covers Tools and the lane, not the panels. In a + /// narrow window the lane leaves the canvas its least width of 200 points. With the setting off + /// again, the lane goes back to the window's edge, right of Tools. + #[test] + fn the_left_lane_follows_tools_docked_beside_the_canvas() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "A"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "A"})).unwrap(); + app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + app.run("window.toolsBesideCanvas", json!({"on": true})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "A", "side": "left"})).unwrap(); + let mut time = 0.0; + let mut frames = |app: &mut VectorcraftApp, size: egui::Vec2| { + for _ in 0..6 { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + }; + frames(&mut app, vec2(1200.0, 600.0)); + let tools = || ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).unwrap(); + let lane = || ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + let zone = ctx.data(|d| d.get_temp::(zone_id(false))).unwrap(); + let a = ctx.memory(|m| m.area_rect(area_id("A"))).unwrap(); + assert!(tools().left() > 100.0, "Tools stands right of the left dock: {:?}", tools()); + assert!((lane().left() - tools().right()).abs() < 1.0, "{:?} {:?}", lane(), tools()); + assert!(lane().contains_rect(a), "{a:?} {:?}", lane()); + assert!(zone.contains(tools().center()) && zone.right() >= lane().right() - 0.5, "{zone:?}"); + assert!(!zone.contains(pos2(4.0, tools().center().y)), "the left dock is not a target: {zone:?}"); + // In a narrow, short window, a stack that wraps leaves the canvas its 200 points. + for k in 0..12 { + let name = format!("T{k}"); + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": name})).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "left" })).unwrap(); + } + frames(&mut app, vec2(700.0, 320.0)); + // The canvas panel's right edge, and the canvas drawn right of the lane. + let right = ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_right_id())).unwrap().right(); + let canvas = app.canvas_rect.unwrap(); + assert!(right - lane().right() >= 199.5 && canvas.left() >= lane().right() - 0.5, "{right} {canvas:?} {:?} {:?}", lane(), tools()); + app.run("window.toolsBesideCanvas", json!({"on": false})).unwrap(); + frames(&mut app, vec2(1200.0, 600.0)); + assert!(tools().left() < 1.0 && (lane().left() - tools().right()).abs() < 1.0, "{:?} {:?}", lane(), tools()); + } + + /// More docked toolbars than a short lane holds in one column wrap into more columns: each stays + /// inside the lane with its title bar, and none covers another. With more than half the window's + /// width of columns needed (60 toolbars), the lane stops at half the width and the toolbars show + /// only their title bars, still each in view. + #[test] + fn a_short_lane_keeps_every_docked_toolbar_inside_it() { + for count in [12, 60] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let names: Vec = (0..count).map(|k| format!("T{k}")).collect(); + for name in &names { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": name})).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "right" })).unwrap(); + } + for k in 0..6 { + window_frame(&mut app, &ctx, 0.05 * f64::from(k + 1), 320.0); + } + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + let rects: Vec = names.iter().map(|name| ctx.memory(|m| m.area_rect(area_id(name))).unwrap()).collect(); + for (name, r) in names.iter().zip(&rects) { + assert!(lane.expand(0.5).contains_rect(*r), "{count}: {name} {r:?} {lane:?}"); + assert!(r.height() >= GRIP, "{count}: {name} {r:?}"); + } + for (i, a) in rects.iter().enumerate() { + for (j, b) in rects.iter().enumerate().skip(i + 1) { + assert!(a.intersect(*b).area() < 1.0, "{count}: {} {a:?} covers {} {b:?}", names[i], names[j]); + } + } + assert!(lane.width() > WIDTH + 2.0 * LANE_GAP + 1.0, "the stack wraps into more columns: {lane:?}"); + assert!(lane.width() <= 600.0, "{count}: the lane leaves the canvas at least half the window: {lane:?}"); + assert!((lane.right() - 1200.0).abs() < 0.5, "{lane:?}"); + let bare = rects.iter().all(|r| r.height() < DOCKED_LEAST - 1.0); + assert_eq!(bare, count == 60, "{count}: title bars alone only when the columns run out: {rects:?}"); + } + } + + /// A lone docked toolbar in a lane too short for its tools shows only its title bar, inside the + /// lane. + #[test] + fn a_lone_toolbar_in_a_short_lane_stays_inside_it() { + let mut short = 0; + for height in (120..=240).step_by(10) { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "A"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "A"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "A", "side": "right"})).unwrap(); + for k in 0..6 { + window_frame(&mut app, &ctx, 0.05 * f64::from(k + 1), height as f32); + } + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + if lane.height() >= DOCKED_LEAST + 2.0 * LANE_GAP { + continue; + } + short += 1; + let a = ctx.memory(|m| m.area_rect(area_id("A"))).unwrap(); + assert!(lane.expand(0.5).contains_rect(a), "{height}: {a:?} {lane:?}"); + assert!(a.height() >= GRIP, "{height}: the title bar shows: {a:?}"); + } + assert!(short > 0, "some window heights leave a lane shorter than one toolbar"); + } + + /// A column too short for even its toolbars' title bars shows as many as fit, none covering + /// another, and the mouse wheel over it scrolls through the rest, one toolbar a notch. + #[test] + fn a_column_too_short_for_its_title_bars_scrolls() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let names: Vec = (0..64).map(|k| format!("T{k}")).collect(); + for name in &names { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "right" })).unwrap(); + } + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 200.0)); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + for _ in 0..6 { + frame(&mut app, vec![]); + } + let shown = + |ctx: &egui::Context| -> Vec { (0..names.len()).filter(|&k| drawn(ctx, area_id(&names[k]), egui::Order::Middle)).collect() }; + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + let before = shown(&ctx); + assert!(!before.is_empty() && before.len() < names.len(), "{before:?} {lane:?}"); + let rects: Vec = before.iter().map(|&k| ctx.memory(|m| m.area_rect(area_id(&names[k]))).unwrap()).collect(); + for (i, a) in rects.iter().enumerate() { + assert!(lane.expand(0.5).contains_rect(*a), "{a:?} {lane:?}"); + for b in rects.iter().skip(i + 1) { + assert!(a.intersect(*b).area() < 1.0, "{a:?} covers {b:?}"); + } + } + assert!(before.contains(&0)); + // The wheel over the first column shows the next toolbar of that column. + let at = pos2(lane.left() + LANE_GAP + WIDTH / 2.0, lane.center().y); + let wheel = |dy: f32| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + frame(&mut app, vec![egui::Event::PointerMoved(at), wheel(-1.0)]); + frame(&mut app, vec![]); + let after = shown(&ctx); + assert!(!after.contains(&0) && after.len() == before.len(), "{before:?} {after:?}"); + frame(&mut app, vec![egui::Event::PointerMoved(at), wheel(1.0)]); + frame(&mut app, vec![]); + assert_eq!(shown(&ctx), before, "the wheel back shows the first again"); + } + + /// A lane holds one column while each docked toolbar gets [`DOCKED_LEAST`] points, and as few + /// more columns as give each that much, the toolbars spread evenly over them, up to the most + /// columns the lane may take. + #[test] + fn short_lanes_wrap_their_stack_into_columns() { + let tall = 2.0 * (DOCKED_LEAST + LANE_GAP) + LANE_GAP; + assert_eq!(lane_columns(0, 500.0, 8), (1, 1)); + assert_eq!(lane_columns(2, tall, 8), (1, 2)); + assert_eq!(lane_columns(3, tall, 8), (2, 2)); + assert_eq!(lane_columns(5, tall, 8), (3, 2)); + assert_eq!(lane_columns(4, 10.0, 8), (4, 1), "a lane shorter than one toolbar puts each in its own column"); + assert_eq!(lane_columns(3, f32::NAN, 8), (3, 1)); + assert_eq!(lane_columns(9, tall, 2), (2, 5), "at most the columns allowed"); + assert_eq!(lane_columns(9, tall, 0), (1, 9)); + assert_eq!(most_columns(3.0 * WIDTH + 4.0 * LANE_GAP, WIDTH), 3); + assert_eq!(most_columns(10.0, WIDTH), 1); + assert_eq!(most_columns(f32::NAN, WIDTH), 1); + } + + /// The frame that docks a toolbar lays its lane out after the toolbars are drawn, so it asks for + /// the next frame at once, which draws the toolbar in the lane, without other input. + #[test] + fn a_new_lane_draws_its_toolbar_in_the_next_frame() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.viewport_output.values().map(|v| v.repaint_delay).min().unwrap_or(std::time::Duration::MAX) + }; + for _ in 0..4 { + frame(&mut app); + } + app.run("window.toolbar.dock", json!({"name": "Mine"})).unwrap(); + assert_eq!(frame(&mut app), std::time::Duration::ZERO, "the frame that lays the new lane out asks for another"); + frame(&mut app); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + assert!(lane.contains_rect(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap())); + } + + /// A docked toolbar dragged inside its lane stays docked. A toolbar's title bar dropped on the + /// window's edge docks it there; dragged out of its lane, + /// it floats under the pointer; Escape during the drag puts it back; a click near the edge does + /// not dock it. The title bar's context menu docks and floats it. + #[test] + fn a_title_bar_dropped_on_an_edge_docks_the_toolbar() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap(); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0)); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + // The texts drawn, with where each is. + let texts = out.shapes.iter().filter_map(|c| { + if let egui::Shape::Text(t) = &c.shape { Some((t.galley.text().to_string(), t.visual_bounding_rect())) } else { None } + }); + texts.collect::>() + }; + let button = |pos, pressed, button| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + let primary = egui::PointerButton::Primary; + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + // Docked and dragged down its lane, past its own bottom, it stays docked. + app.run("window.toolbar.dock", json!({"name": "Mine", "side": "left"})).unwrap(); + for _ in 0..3 { + frame(&mut app, vec![]); + } + let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap(); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + let (from, below) = (pos2(r.center().x, r.top() + 1.0 + GRIP / 2.0), pos2(lane.center().x, lane.bottom() - 20.0)); + assert!(!r.contains(below), "{r:?} {lane:?}"); + frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true, primary)]); + for k in 1..=6 { + frame(&mut app, vec![egui::Event::PointerMoved(from + (below - from) * (k as f32 / 6.0))]); + assert_eq!(place(&app, "Mine"), (Some(0), false), "a drag inside the lane keeps it docked, step {k}"); + } + frame(&mut app, vec![button(below, false, primary)]); + app.run("window.toolbar.float", json!({"name": "Mine"})).unwrap(); + for _ in 0..3 { + frame(&mut app, vec![]); + } + // A press on the title bar's grip, moved in steps to `to` (`cut` after the step it names), + // then released there. + let mut drag = |app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, cut: Option| { + frame(app, vec![egui::Event::PointerMoved(from), button(from, true, primary)]); + for k in 1..=6 { + let mut events = vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]; + if cut == Some(k) { + events.push(escape.clone()); + } + frame(app, events); + } + frame(app, vec![button(to, false, primary)]); + frame(app, vec![]); + frame(app, vec![]); + }; + let grip = |ctx: &egui::Context| { + let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap(); + pos2(r.center().x, r.top() + 1.0 + GRIP / 2.0) + }; + // Dropped on the window's left edge, it docks there. + drag(&mut app, grip(&ctx), pos2(20.0, 400.0), None); + assert_eq!(place(&app, "Mine"), (Some(0), false)); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + assert!(lane.contains_rect(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap())); + // Escape while it is dragged out of the lane puts it back in its place. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), Some(4)); + assert_eq!(place(&app, "Mine"), (Some(0), false), "Escape keeps it docked"); + // Escape in the frame of the first move after the press: no drag starts. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), Some(1)); + assert_eq!(place(&app, "Mine"), (Some(0), false), "Escape with the first move keeps it docked"); + // Dragged out of the lane, it floats under the pointer. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), None); + assert_eq!(place(&app, "Mine"), (None, false)); + assert!(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap().contains(pos2(600.0, 300.0))); + // Escape while it is dragged to the right edge leaves it floating where it was. + let at = find(&app.ui, "Mine").unwrap().pos; + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), Some(5)); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at)); + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), Some(1)); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at), "Escape with the first move"); + // Dropped on the right edge, it docks there. + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), None); + assert_eq!(place(&app, "Mine"), (Some(0), true)); + // Floating over the edge, a click on its title bar does not dock it. + app.run("window.toolbar.float", json!({"name": "Mine", "x": 2, "y": 300})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let at = grip(&ctx); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![button(at, false, primary)]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), (None, true)); + // The title bar's context menu: Dock to Left docks it, and Float Toolbar floats it again. + for (item, after) in [("Dock to Left", (Some(0), false)), ("Float Toolbar", (None, false))] { + let at = grip(&ctx); + let secondary = egui::PointerButton::Secondary; + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, secondary)]); + frame(&mut app, vec![button(at, false, secondary)]); + let texts = frame(&mut app, vec![]); + let found = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == item); + let at = found.unwrap_or_else(|| panic!("no {item} in {texts:?}")).1.center(); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![button(at, false, primary)]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), after, "after {item}"); + } + // Docked, and released in the frame the pointer leaves the lane, it floats there. + app.run("window.toolbar.dock", json!({"name": "Mine"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let at = grip(&ctx); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![egui::Event::PointerMoved(at + vec2(0.0, 8.0))]); + assert_eq!(place(&app, "Mine"), (Some(0), false), "still in its lane"); + let out = pos2(600.0, 300.0); + frame(&mut app, vec![egui::Event::PointerMoved(out), button(out, false, primary)]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), (None, false), "released outside its lane"); + assert!(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap().contains(out)); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 197f8faaaa..6040ac8fb4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -88,6 +88,7 @@ pub mod perspective_grid; pub mod perspective_options; pub mod perspective_plane; pub mod perspective_presets; +pub mod pixel_snap_options; pub mod place; pub mod placement_options; pub mod plugin; @@ -452,6 +453,7 @@ registry! { LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC, NavigatorPanelOptions: [navigator_panel_options::KIND] => navigator_panel_options::SPEC, PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC, + PixelSnapOptions: [pixel_snap_options::KIND] => pixel_snap_options::SPEC, RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC, RenameArtboards: [rename_artboards::KIND] => rename_artboards::SPEC, PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC, diff --git a/crates/ui-egui/src/dialogs/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 61ef75c5e1..3bf7d2efd0 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -23,14 +23,24 @@ pub fn open(app: &mut VectorcraftApp, set: usize, action: usize) -> Result Result<(), String> { + if crate::panels::actions::has_stop(commands) { + return Err("Batch cannot play an action with a stop".into()); + } + if commands.iter().any(crate::panels::actions::is_ui_step) { + return Err("Batch cannot play an action with a View menu step".into()); + } + Ok(()) +} + fn initial_fields(set: usize, action: usize, name: &str) -> Value { json!({ "set": set, @@ -119,9 +129,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if commands.is_empty() { return Err("the action has no included steps".into()); } - if crate::panels::actions::has_stop(&commands) { - return Err("Batch cannot play an action with a stop".into()); - } + refuse_steps(&commands)?; let source = d.str("source"); if source == "folder" && d.str("folder").trim().is_empty() { return Err("choose a source folder".into()); diff --git a/crates/ui-egui/src/dialogs/action_insert_menu_item.rs b/crates/ui-egui/src/dialogs/action_insert_menu_item.rs index 2cf0ab6127..0b4bd8dc7d 100644 --- a/crates/ui-egui/src/dialogs/action_insert_menu_item.rs +++ b/crates/ui-egui/src/dialogs/action_insert_menu_item.rs @@ -207,43 +207,86 @@ mod tests { assert_eq!((d.str("note"), d.str("command")), ("none".to_string(), "object.ungroup".to_string()), "a failed Find keeps the pick"); } - /// The list holds the menu items that run an engine command without asking for values first, - /// outside the application, File, Window and Help menus, and leaves out Undo, Redo, the Pastes, - /// Object › Align, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern and the - /// saved-selection, saved-view and recent-font items. + /// The list holds the menu items that run an engine command, and the View menu items that the + /// app runs itself, without asking for values first, outside the application, File, Window and + /// Help menus, and leaves out Undo, Redo, the Pastes, Image Trace's Make and Make and Expand, + /// Pattern's Make and Edit Pattern and the saved-selection, saved-view and recent-font items. #[test] fn the_list_holds_only_menu_items_that_can_be_steps() { let items = menus::insertable_items(true); assert!(items.len() > 100, "only {} items", items.len()); for item in &items { - assert!(vectorcraft_engine::find_command(item.command).is_some(), "{item:?}"); + let in_app = menus::plays_in_app(item.command); + assert!(vectorcraft_engine::find_command(item.command).is_some() != in_app, "{item:?}"); + assert!(!in_app || item.path[0] == "View", "{item:?}"); assert!(!item.label.ends_with('…'), "{item:?}"); assert!(!["VectorCraft", "File", "Window", "Help"].contains(&item.path[0]), "{item:?}"); assert!(item.params.is_object(), "{item:?}"); } let has = |command: &str| items.iter().any(|i| i.command == command); - for command in ["object.group", "object.ungroup", "object.arrange.bringToFront"] { + for command in [ + "object.group", + "object.ungroup", + "object.arrange.bringToFront", + "object.align", + "view.outline", + "view.zoomIn", + "view.fitArtboard", + "view.rotate", + "view.rulers", + "view.snapToGrid", + "view.guides.lock", + ] { assert!(has(command), "{command}"); } + let aligns = items.iter().filter(|i| i.command == "object.align").count(); + assert_eq!(aligns, 7, "each Object › Align item, by its params"); for command in [ "edit.undo", "edit.redo", "edit.paste", "edit.pasteInFront", "object.move", - "object.align", "imageTrace.make", "imageTrace.makeAndExpand", "object.pattern.make", "object.pattern.edit", "file.save", - "view.zoomIn", + "window.panel", + "view.goto1", + "view.saved.new", "select.recall1", ] { assert!(!has(command), "{command}"); } } + /// Every View menu item that the app runs itself is listed, apart from the saved-view slots, the + /// Perspective Grid presets (`ui.` commands, whose items follow the presets in the preferences) + /// and the items that ask for values first. + #[test] + fn every_view_item_the_app_runs_is_listed() { + let items = menus::insertable_items(true); + let view = menus::menu_tree_named(true).into_iter().find(|(title, _)| *title == "View").map(|(_, items)| items).unwrap(); + fn walk(items: &[menus::Item], out: &mut Vec<(&'static str, &'static str)>) { + for it in items { + match it { + menus::Item::Cmd(label, id, _) => out.push((label, id)), + menus::Item::Sub(_, children) => walk(children, out), + _ => {} + } + } + } + let mut all = vec![]; + walk(&view, &mut all); + for (label, id) in all { + let ui = vectorcraft_engine::find_command(id).is_none() && menus::UI_COMMANDS.iter().any(|c| c.0 == id); + if ui && !label.ends_with('…') && !id.starts_with("view.goto") && !id.starts_with("ui.") { + assert!(items.iter().any(|i| i.command == id), "{label} ({id})"); + } + } + } + /// Rows show their menu path in the UI language, and Find matches the English path, the shown /// one and the command id. #[test] diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 1513efc8f2..a73d68cd1a 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -255,7 +255,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let Some((editing, bar)) = bar_of(app) else { return }; let mut run: Option<(&str, Value)> = None; - ui.separator(); + crate::chrome::group_separator(ui); for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] { if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents { run = Some(("object.envelope.editContents", json!({ "editing": contents }))); diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index 05f0e85b60..2e99f5e97e 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -112,11 +112,12 @@ pub(crate) fn find_in_folder_command(app: &mut VectorcraftApp, folder: Option bool { let Some(id) = id.as_u64() else { return false }; let st = app.session.active(); - st.and_then(|st| st.doc.node(vectorcraft_doc::NodeId(id))).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) + st.and_then(|st| st.doc.stored_node(vectorcraft_doc::NodeId(id))).is_some_and(|(n, _)| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) } /// Make the dialog's document the active one: it may be another tab's. diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 4722b8b327..0b618c7e6f 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -255,6 +255,35 @@ fn copy_into_the_documents_folder_copies_before_relinking() { assert!(!frame(&mut app).contains("Copy into the document's folder")); } +/// Copy into the document's folder copies the file of an image in a symbol's art too, picked in the +/// Links panel by its id. +#[test] +fn copy_takes_an_image_in_a_symbol_into_the_documents_folder() { + let root = folder("copy-symbol"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let orig = at(&root, "orig/a.png"); + write(&orig, &png(3)); + let placed = app.run("file.place", json!({"path": orig})).unwrap()["ids"][0].clone(); + app.run("symbol.new", json!({"name": "Mark", "ids": [placed]})).unwrap(); + let doc = at(&root, "doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + write(&at(&root, "found/a.png"), &png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image"); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found"), "ids": [row["id"]]})).unwrap(); + searched(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().map(Vec::len), &r["failed"]), (Some(1), &json!([])), "{r}"); + let copy = at(&root, "doc/a.png"); + assert_eq!(std::fs::read(©).unwrap(), png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().unwrap(); + assert_eq!(row["path"], json!(copy), "{list}"); +} + /// With Copy into the document's folder, a placed document is relinked where its file is. #[test] fn copy_leaves_placed_documents_where_they_are() { diff --git a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs index 0f57b99f46..b173deb49c 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs @@ -1,21 +1,20 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Instant; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::findfiles::Rules; use super::*; +use crate::tests_folder::Folder; fn frame(app: &mut VectorcraftApp) -> String { crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) } -/// A fresh temporary folder (canonical, as a search reports it). -fn folder(tag: &str) -> PathBuf { +/// A fresh temporary folder (canonical, as a search reports it), removed when dropped. +fn folder(tag: &str) -> Folder { let dir = vectorcraft_testkit::temp_dir(&format!("missing-fonts-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) + Folder::adopt(vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap())) } /// An app that reads files and whose searches may go anywhere in `root`, on two threads. @@ -191,7 +190,7 @@ fn closing_the_dialog_stops_its_search() { app.session.search_threads = Some(1); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); - app.run("ui.missingFontsDialog", json!({"folder": root})).unwrap(); + app.run("ui.missingFontsDialog", json!({"folder": root.as_path()})).unwrap(); let id = app.ui.dialog_search.expect("the dialog's search"); super::super::cancel(&mut app); assert!(app.ui.dialog.is_none() && app.ui.dialog_search.is_none()); @@ -275,7 +274,8 @@ fn the_question_comes_back_after_a_dialog_that_took_its_place() { /// opens without the question. #[test] fn generic_families_are_not_asked_about() { - let mut app = app_for(&folder("generic")); + let root = folder("generic"); + let mut app = app_for(&root); let svg = r#"A"#; crate::io::open_bytes(&mut app, "generic.svg", svg.as_bytes(), None).unwrap(); let listed = app.session.execute("text.fonts", &json!({})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/pixel_snap_options.rs b/crates/ui-egui/src/dialogs/pixel_snap_options.rs new file mode 100644 index 0000000000..43b369e105 --- /dev/null +++ b/crates/ui-egui/src/dialogs/pixel_snap_options.rs @@ -0,0 +1,46 @@ +//! Pixel Snapping Options (the Control bar's Snapping menu): whether View › Snap to Pixel lands +//! drawing, moving and scaling on whole pixels. OK runs `view.snapToPixel.options` (the options live +//! in the preferences); Cancel leaves them as they were. +//! +//! Fields: `drawing`, `moving` and `scaling` (bool). While Snap to Pixel is off, the options stay +//! editable and a note says they take effect once it is on. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Pixel Snapping Options. +pub const KIND: &str = "pixelSnapOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Pixel Snapping Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open the dialog with the current options. +pub fn open(app: &mut VectorcraftApp) { + app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.pixel_snap))); +} + +fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) { + let v = d.bool(key); + if widgets::check(ui, text, v, true) { + d.fields.insert(key.into(), json!(!v)); + } +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + check(ui, d, "drawing", tl!("Snap to Pixel While Drawing")); + check(ui, d, "moving", tl!("Snap to Pixel While Moving")); + check(ui, d, "scaling", tl!("Snap to Pixel While Scaling")); + if !app.ui.view.snap_to_pixel { + let dim = Tokens::get(ui.ctx()).text_dim; + ui.label(egui::RichText::new(tl!("These options take effect when View › Snap to Pixel is on.")).color(dim)); + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + super::run_and_close(app, "view.snapToPixel.options", Value::Object(d.fields.clone())) +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 8c68237b0c..ccdb4e8ca1 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -19,7 +19,13 @@ //! show the new colors on the smooth wheel, on a segmented wheel (solid patches of 24 hues by 5 //! saturations; its markers move as freely) or as color bars (one per new color, in row order: click //! one to select it for the sliders, drag one to move its row, which changes no color); the dialog -//! opens on the smooth wheel. The preset menu sets up a 1, 2 or 3 +//! opens on the smooth wheel. With art, the Prominent Colors bar under the wheel shows the new +//! colors, each part as wide as its row's share of the art's uses, the largest first; dragging the +//! edge between two parts moves width between them, and when the drag ends the rows' current +//! colors move toward those widths (`recolor.weigh`), and the art previews them. The New Color +//! Group menu beside the link button saves the new colors (Save All Colors, in row order) or the +//! bar's colors (Save Prominent Colors, in its order) as a color group right away, a step of its +//! own. The preset menu sets up a 1, 2 or 3 //! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites //! the group with the new colours (renamed by `groupName`); without art only the group changes. //! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new @@ -41,8 +47,9 @@ //! (`{saturation, brightness, temperature, luminosity}` in percent, sent as `recolor.apply`'s //! `globalAdjust` in every slider mode), `display` (the Edit tab's `smooth`, `segmented` or `bars`; //! unset: `smooth`), `themePicker` (the Color Theme Picker is on), `themePicks` (its picks, as -//! `recolor.pickTheme`'s `picks`; a new list recolors the rows). Changing `colors`, a preserve -//! flag or `limitTo` reduces the rows again. +//! `recolor.pickTheme`'s `picks`; a new list recolors the rows), `weights` (one per row, as +//! `recolor.weigh`'s; a new list moves the rows' current colors toward them). Changing `colors`, a +//! preserve flag or `limitTo` reduces the rows again (and drops `weights`). use egui::{Rect, Sense, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -81,6 +88,9 @@ const WHEEL: f32 = 220.0; /// Height of the Edit tab's color bars area (the wheel with its Brightness slider), and of a bar. const BARS_H: f32 = WHEEL + 31.0; const BAR_H: f32 = 22.0; +/// Without the Prominent Colors bar, the Edit tab's gaps above the wheel and above the sliders. +const WHEEL_GAP: f32 = 8.0; +const SLIDERS_GAP: f32 = 6.0; /// Height of the rows table, and of either tab (so switching tabs doesn't resize the dialog). const TABLE_H: f32 = 182.0; const TAB_H: f32 = 400.0; @@ -151,7 +161,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { "preserveWhite": kept("preserveWhite", true), "preserveBlack": kept("preserveBlack", true), "preserveGrays": kept("preserveGrays", false), "limitTo": limit, "group": group, "groupName": if new_group { Some(NEW_GROUP) } else { group }, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, - "preview": true, THEME_PICKER: false, THEME_PICKS: [], "__art": art, "__assign": assign, "__newGroup": new_group, + "preview": true, THEME_PICKER: false, THEME_PICKS: [], WEIGHTS: null, "__art": art, "__assign": assign, "__newGroup": new_group, }); let mut d = Dialog::new(KIND, fields); // Without art the rows are there from the start: limit them now (art's rows snap as they reduce). @@ -214,6 +224,184 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) { d.fields.insert(DONE.into(), sig); // New rows take the colors picked with the Color Theme Picker again. d.fields.remove(THEME_DONE); + // Weights given for the old rows don't apply to the new ones. + d.fields.remove(WEIGHTS); + d.fields.remove(WEIGHED); +} + +/// The fields of Prominent Colors: the weights the rows are to take (`recolor.weigh`'s `weights`, +/// one per row; a new list moves the rows' current colors), the weights they last took, each row's +/// share of the art's uses (`recolor.weigh`'s `shares`) and the rows' current colors, exclusions +/// and whether each has a new color, which those were read for; a drag of a part's edge (each part's share of the bar while it lasts); and a request +/// from the New Color Group menu (`all` or `prominent`). +const WEIGHTS: &str = "weights"; +const WEIGHED: &str = "__weighed"; +const SHARES: &str = "__shares"; +const SHARES_OF: &str = "__sharesOf"; +const BAR_DRAG: &str = "__barDrag"; +const SAVE_GROUP: &str = "__saveGroup"; +/// Height of the Prominent Colors bar, and the smallest share of it a drag leaves a part. With the +/// bar the Edit tab leaves out [`WHEEL_GAP`] and [`SLIDERS_GAP`], which keeps the tab's height. +const PROMINENT_H: f32 = 10.0; +const MIN_PART: f64 = 0.01; +/// The ids of the bar's part `n` and of the edge after part `k`, with `n` or `k`. +const PROMINENT_PART: &str = "recolor-prominent-part"; +const PROMINENT_EDGE: &str = "recolor-prominent-edge"; + +/// Cancel the preview, so commands read the art's own colors; it runs again afterwards. +fn cancel_preview(app: &mut VectorcraftApp, d: &mut Dialog) { + if d.fields.remove(super::form::PREVIEWED).is_some() { + let _ = app.session.cancel_interaction(); + } +} + +/// With art: when `weights` changed, move the rows' current colors toward them +/// (`recolor.weigh`); when the rows' current colors changed, read each row's share of the art's +/// uses again. +fn sync_weights(app: &mut VectorcraftApp, d: &mut Dialog) { + if !d.bool("__art") { + return; + } + if let Some(w) = d.fields.get(WEIGHTS).filter(|w| !w.is_null()).cloned() + && d.fields.get(WEIGHED) != Some(&w) + { + d.fields.insert(WEIGHED.into(), w.clone()); + cancel_preview(app, d); + match app.session.execute("recolor.weigh", &json!({"map": rows(d), "weights": w})) { + Ok(r) => { + if let Some(m) = r["map"].as_array() { + d.fields.insert("rows".into(), Value::Array(m.clone())); + } + } + Err(e) => app.status(e.to_string()), + } + } + let rs = rows(d); + // A row with a new color takes a color before one without, so that changes the shares too. + let of = Value::Array(rs.iter().map(|r| json!([r["from"], excluded(r), !r["to"].is_null()])).collect()); + if d.fields.get(SHARES_OF) == Some(&of) { + return; + } + cancel_preview(app, d); + let shares = match app.session.execute("recolor.weigh", &json!({"map": rs})) { + Ok(r) => r["shares"].clone(), + Err(e) => { + app.status(e.to_string()); + json!([]) + } + }; + d.fields.insert(SHARES.into(), shares); + d.fields.insert(SHARES_OF.into(), of); +} + +/// The parts of the Prominent Colors bar: each row that isn't excluded and has a share of the +/// art's uses, with that share in percent, the largest share first (on a tie, the first row). +fn prominent_parts(d: &Dialog, rows: &[Value]) -> Vec<(usize, f64)> { + let shares = d.fields.get(SHARES).and_then(Value::as_array).cloned().unwrap_or_default(); + let mut parts: Vec<(usize, f64)> = shares + .iter() + .enumerate() + .filter_map(|(i, s)| Some((i, s.as_f64().filter(|s| *s > 0.0)?))) + .filter(|(i, _)| rows.get(*i).is_some_and(|r| !excluded(r))) + .collect(); + parts.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + parts +} + +/// The Prominent Colors bar: a part per row of [`prominent_parts`] with the row's new color, as wide +/// as its share of the art's uses. A drag of the edge between two parts moves width from one to +/// the other (each keeps at least [`MIN_PART`] of the bar); when it ends, the parts' widths become +/// the rows' `weights`. +fn prominent_bar(ui: &mut Ui, d: &mut Dialog, rows: &[Value]) { + let t = Tokens::get(ui.ctx()); + let parts = prominent_parts(d, rows); + let (bar, resp) = ui.allocate_exact_size(vec2(ui.available_width(), PROMINENT_H), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, ui.is_enabled(), tl!("Prominent Colors"))); + if parts.is_empty() { + ui.painter().rect_stroke(bar, 1.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + return; + } + let total: f64 = parts.iter().map(|p| p.1).sum(); + let mut widths: Vec = match d.fields.get(BAR_DRAG).and_then(Value::as_array) { + Some(w) if w.len() == parts.len() => w.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(), + _ => parts.iter().map(|p| p.1 / total).collect(), + }; + let w = f64::from(bar.width()); + let edge = |k: usize, widths: &[f64]| bar.left() + (widths[..=k].iter().sum::() * w) as f32; + let mut left = bar.left(); + for (n, (i, _)) in parts.iter().enumerate() { + let right = if n + 1 == parts.len() { bar.right() } else { edge(n, &widths) }; + let r = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())); + let c = to_color(&rows[*i]).or_else(|| from_colors(&rows[*i]).first().copied()).unwrap_or_default(); + chip(ui, r, c, false); + let share = (widths[n] * 100.0).round(); + ui.interact(r, egui::Id::unique((PROMINENT_PART, n)), Sense::hover()).on_hover_text(format!("{} {}: {share}%", tl!("Color"), i + 1)); + left = right; + } + let mut ended = false; + for k in 0..parts.len() - 1 { + let x = edge(k, &widths); + let handle = Rect::from_center_size(pos2(x, bar.center().y), vec2(8.0, PROMINENT_H)); + let h = ui.interact(handle, egui::Id::unique((PROMINENT_EDGE, k)), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if h.dragged() && w > 0.0 { + // Each part keeps MIN_PART, or half of the two parts' width when they are narrower. + let pair = widths[k] + widths[k + 1]; + let least = MIN_PART.min(pair / 2.0); + let moved = (widths[k] + f64::from(h.drag_delta().x) / w).clamp(least, pair - least); + widths[k] = moved; + widths[k + 1] = pair - moved; + d.fields.insert(BAR_DRAG.into(), json!(widths)); + } + if h.hovered() || h.dragged() { + ui.painter().vline(x, bar.y_range(), egui::Stroke::new(2.0, t.accent)); + } + ended |= h.drag_stopped(); + } + if ended && d.fields.remove(BAR_DRAG).is_some() { + let mut weights = vec![0.0; rows.len()]; + for ((i, _), x) in parts.iter().zip(&widths) { + weights[*i] = (x * 10_000.0).round() / 100.0; + } + d.fields.insert(WEIGHTS.into(), json!(weights)); + } +} + +/// The colors a new color group takes from rows `rs`: each row's new color with the Global Adjust, +/// snapped to Limit to Library, as `recolor.apply` gives them to a group; a row without a new color +/// gives its first current color, which the art keeps. +fn group_colors<'a>(app: &VectorcraftApp, d: &Dialog, rs: impl Iterator) -> Vec { + let adjust = global_adjust(adjust_values(d)); + let limit = d.str("limitTo"); + let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)); + rs.filter_map(|r| match to_color(r) { + None => from_colors(r).first().map(key), + Some(_) if adjust.is_identity() => Some(r["to"].clone()), + Some(c) => { + let c = adjust.apply(c); + Some(key(&palette.as_ref().map_or(c, |p| p.nearest(c)))) + } + }) + .collect() +} + +/// The New Color Group menu's request: save the new colors of the rows that aren't excluded (Save +/// All Colors, in row order; a row without one gives its first current color) or of the Prominent Colors bar (Save Prominent Colors, in its order) +/// as a new color group, a step of its own (`swatch.newGroup`). +fn save_group(app: &mut VectorcraftApp, d: &mut Dialog, which: &str) { + let rs = rows(d); + let order: Vec = match which { + "prominent" => prominent_parts(d, &rs).into_iter().map(|p| p.0).collect(), + _ => (0..rs.len()).filter(|&i| !excluded(&rs[i])).collect(), + }; + let colors = group_colors(app, d, order.iter().filter_map(|&i| rs.get(i))); + if colors.is_empty() { + return; + } + // The group is added to the document itself, not to the preview, which runs again afterwards. + cancel_preview(app, d); + if let Err(e) = app.run("swatch.newGroup", json!({"colors": colors, "keepModel": true})) { + app.status(e); + } } /// The fields of the Color Theme Picker: whether a click or drag on the canvas picks colors, and @@ -401,6 +589,7 @@ fn apply_params(d: &Dialog) -> Value { fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { sync(app, d); sync_theme(app, d); + sync_weights(app, d); let t = Tokens::get(ui.ctx()); let picking = d.bool(THEME_PICKER); ui.horizontal(|ui| { @@ -444,6 +633,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { } d.fields.insert("rows".into(), Value::Array(rs)); } + sync_weights(app, d); + if let Some(which) = d.fields.remove(SAVE_GROUP) { + save_group(app, d, which.as_str().unwrap_or_default()); + } if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") { ui.add_space(6.0); ui.horizontal(|ui| { @@ -479,25 +672,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut d = d.clone(); sync(app, &mut d); sync_theme(app, &mut d); + sync_weights(app, &mut d); if d.bool("__newGroup") { - // The new colors with the Global Adjust, snapped to Limit to Library, as `recolor.apply` - // gives them to a group. - let adjust = global_adjust(adjust_values(&d)); - let limit = d.str("limitTo"); - let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)); - let colors: Vec = rows(&d) - .into_iter() - .filter(|r| !excluded(r)) - .map(|r| { - to_color(&r).filter(|_| !adjust.is_identity()).map_or_else( - || r["to"].clone(), - |c| { - let c = adjust.apply(c); - key(&palette.as_ref().map_or(c, |p| p.nearest(c))) - }, - ) - }) - .collect(); + let colors = group_colors(app, &d, rows(&d).iter().filter(|r| !excluded(r))); return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors, "keepModel": true})); } let r = form::commit_preview(app, CMD, apply_params(&d))?; @@ -1091,13 +1268,30 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() { d.fields.insert("linked".into(), json!(!linked)); } + // New Color Group: save the wheel's colors or the Prominent Colors as a color group. + let button = widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0); + let prominent = d.bool("__art") && !prominent_parts(d, rows).is_empty(); + let saved = egui::Popup::menu(&button).show(|ui| { + if widgets::menu_item_name(ui, tl!("Save All Colors"), rows.iter().any(|r| !excluded(r)), false) { + return Some("all"); + } + widgets::menu_item_name(ui, tl!("Save Prominent Colors"), prominent, false).then_some("prominent") + }); + if let Some(which) = saved.and_then(|r| r.inner) { + d.fields.insert(SAVE_GROUP.into(), json!(which)); + } ui.add_space(8.0); // The mode of the color sliders under the wheel: the selected new color in a color // mode, or Global Adjust for all of them. mode_menu(ui, d, &target, true); }); }); - ui.add_space(8.0); + // With art the Prominent Colors bar has the room of the two gaps, so the tab is no taller with + // it: on a short screen all of the tab but the chips has to fit. + let art = d.bool("__art"); + if !art { + ui.add_space(WHEEL_GAP); + } let colors: Vec = active.iter().filter_map(|&i| to_color(&rows[i])).collect(); let marked = base.and_then(|b| active.iter().position(|&i| i == b)); let display = d.str("display"); @@ -1122,6 +1316,10 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value _ => w = widgets::harmony_wheel(ui, "recolor-wheel", WHEEL, &colors, marked), }); }); + // Prominent Colors: the art's new colors, each as wide as its share of the art. + if art { + prominent_bar(ui, d, rows); + } if let Some(k) = w.pressed { d.fields.insert("__sel".into(), json!([active[k]])); } @@ -1145,7 +1343,9 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value if !ui.input(|i| i.pointer.any_down()) { d.fields.remove(DRAG); } - ui.add_space(6.0); + if !art { + ui.add_space(SLIDERS_GAP); + } if d.str("sliderMode") == GLOBAL { let sample = base.and_then(|b| to_color(&rows[b])).unwrap_or(Color::rgb(0.8, 0.45, 0.25)); global_sliders(ui, d, sample); @@ -1625,6 +1825,147 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); } + const RED: &str = "rgb 255 0 0"; + const PURPLE: &str = "rgb 128 0 204"; + const BLUE: &str = "rgb 0 0 255"; + + /// Two red squares, a purple one and a blue one, selected; Recolor Artwork open on them on the + /// Edit tab with the Exact method and the rows set: the blue to yellow, then the red and purple + /// to green (the purple's square id last). One frame drawn. + fn prominent_setup(app: &mut VectorcraftApp, ctx: &egui::Context) -> u64 { + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + let ids = [square(app, 10.0, "#ff0000"), square(app, 40.0, "#ff0000"), square(app, 70.0, "#8000cc"), square(app, 100.0, "#0000ff")]; + app.run("select.set", json!({"ids": ids})).unwrap(); + app.run("ui.recolorDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("method".into(), json!(Method::Exact.id())); + d.fields.insert("tab".into(), json!("edit")); + crate::theme::install_fonts(ctx); + frame(app, ctx, 0.0, vec![], 0); + let rows = json!([{"from": [BLUE], "to": "rgb 255 255 0"}, {"from": [RED, PURPLE], "to": "rgb 0 255 0"}]); + app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows); + frame(app, ctx, 0.01, vec![], 0); + ids[2] + } + + fn froms(app: &VectorcraftApp) -> Vec { + super::rows(app.ui.dialog.as_ref().unwrap()).iter().map(|r| r["from"].clone()).collect() + } + + /// The Prominent Colors bar holds each row's share of the art's uses, the largest first; new + /// `weights` move the rows' current colors toward them, the art previews the result, and only + /// OK commits it, as one undo step. + #[test] + fn weights_move_the_rows_colors_and_the_art_previews_them() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields[SHARES], json!([25.0, 75.0])); + assert_eq!(prominent_parts(d, &super::rows(d)), [(1, 75.0), (0, 25.0)], "the largest share first"); + assert_eq!(fill_hex(&app, purple), "#00ff00", "the art previews the rows"); + let undo = app.session.doc().unwrap().history.undo.len(); + app.ui.dialog.as_mut().unwrap().fields.insert(WEIGHTS.into(), json!([50, 50])); + frame(&mut app, &ctx, 0.1, vec![], 0); + assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([50.0, 50.0])); + assert_eq!(fill_hex(&app, purple), "#ffff00", "the moved color previews its new row's color"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "nothing is committed before OK"); + // A new count of Colors reduces the rows again and drops the weights. + app.ui.dialog.as_mut().unwrap().fields.insert("colors".into(), json!(2)); + frame(&mut app, &ctx, 0.2, vec![], 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get(WEIGHTS), None); + app.ui + .dialog + .as_mut() + .unwrap() + .fields + .insert("rows".into(), json!([{"from": [BLUE, PURPLE], "to": "rgb 255 255 0"}, {"from": [RED], "to": "rgb 0 255 0"}])); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(fill_hex(&app, purple), "#ffff00"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + } + + /// A row that takes a new color takes the colors it shares with other rows, so the bar reads + /// the shares again. + #[test] + fn a_new_color_for_a_row_reads_the_shares_again() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + prominent_setup(&mut app, &ctx); + let rows = json!([{"from": [RED, PURPLE], "to": null}, {"from": [RED, BLUE], "to": "rgb 255 255 0"}]); + app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows); + frame(&mut app, &ctx, 0.1, vec![], 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([25.0, 75.0])); + let rows = json!([{"from": [RED, PURPLE], "to": "rgb 0 255 0"}, {"from": [RED, BLUE], "to": "rgb 255 255 0"}]); + app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows); + frame(&mut app, &ctx, 0.2, vec![], 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([75.0, 25.0])); + } + + /// Dragging the edge between two parts of the bar to the left gives the right-hand part more + /// weight; when the drag ends, the rows' current colors move. + #[test] + fn dragging_a_prominent_edge_moves_colors_between_rows() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let edge = |ctx: &egui::Context| ctx.read_response(egui::Id::unique((PROMINENT_EDGE, 0usize))).map(|r| r.rect); + let mut last = None; + for n in 0..10 { + frame(&mut app, &ctx, 0.02 + f64::from(n) * 0.001, vec![], 0); + let r = edge(&ctx); + if r.is_some() && r == last { + break; + } + last = r; + } + let at = edge(&ctx).expect("the edge between the two parts").center(); + let bar = ctx.read_response(egui::Id::unique((PROMINENT_PART, 0usize))).unwrap().rect; + // From 75% to 50%: a quarter of the bar to the left. + let to = at - vec2((bar.width() / 0.75) * 0.25, 0.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at), button(at, true)], 0); + for k in 1..=5 { + frame(&mut app, &ctx, 0.1 + f64::from(k) * 0.02, vec![egui::Event::PointerMoved(at + (to - at) * (k as f32 / 5.0))], 0); + } + assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key(BAR_DRAG), "the drag is under way"); + assert_eq!(froms(&app)[0], json!([BLUE]), "the rows wait for the drag to end"); + frame(&mut app, &ctx, 0.3, vec![button(to, false)], 0); + frame(&mut app, &ctx, 0.32, vec![], 0); + let d = app.ui.dialog.as_ref().unwrap(); + let w = d.fields[WEIGHTS].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect::>(); + assert!((w[0] - 50.0).abs() < 2.0 && (w[1] - 50.0).abs() < 2.0, "{w:?}"); + assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]); + assert_eq!(fill_hex(&app, purple), "#ffff00"); + } + + /// The New Color Group menu's Save All Colors saves the new colors in row order and Save + /// Prominent Colors the bar's colors, the largest share first, each as its own step; the + /// preview of the recolor runs on. + #[test] + fn save_all_and_prominent_colors_make_color_groups() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let groups = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatch_groups.len(); + let last = |app: &VectorcraftApp| -> Vec { + let doc = &app.session.doc().unwrap().doc; + doc.swatch_groups.last().unwrap().swatches.iter().map(|w| w.paint.color().unwrap().to_hex()).collect() + }; + let (n, undo) = (groups(&app), app.session.doc().unwrap().history.undo.len()); + for (which, colors, t) in [("all", ["#ffff00", "#00ff00"], 0.1), ("prominent", ["#00ff00", "#ffff00"], 0.2)] { + app.ui.dialog.as_mut().unwrap().fields.insert(SAVE_GROUP.into(), json!(which)); + frame(&mut app, &ctx, t, vec![], 0); + frame(&mut app, &ctx, t + 0.01, vec![], 0); + assert_eq!(last(&app), colors, "{which}"); + assert_eq!(fill_hex(&app, purple), "#00ff00", "{which}: the art still previews the rows"); + } + assert_eq!(groups(&app), n + 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2, "a step for each group"); + } + /// One frame with `modifiers` held. fn frame_with(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec, modifiers: egui::Modifiers) { let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 968d79568c..7a1aeb3849 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use vectorcraft_engine::Session; use super::*; +use crate::tests_folder::Folder; use crate::{FilePick, Services, theme}; type Written = Rc)>>>; @@ -197,7 +198,7 @@ fn board_files_of(app: &VectorcraftApp, boards: [bool; 4]) -> Vec #[test] fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { let (mut app, written, _) = desktop("again.ai"); - let dir = vectorcraft_testkit::temp_dir("ai-options-enter"); + let dir = Folder::adopt(vectorcraft_testkit::temp_dir("ai-options-enter")); let orig = dir.join("orig.ai"); std::fs::write(&orig, vectorcraft_testkit::ai::ai(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_pdf())).unwrap(); app.run("document.open", json!({"path": orig.to_string_lossy()})).unwrap(); @@ -215,7 +216,7 @@ fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { #[test] fn save_a_copy_and_template_suggest_their_names() { let (mut app, written, picks) = desktop("copy.vectorcraft"); - let folder = vectorcraft_testkit::temp_dir("save-template-dialog"); + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-dialog")); app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); app.run("file.saveCopy", json!({})).unwrap(); assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); @@ -237,7 +238,8 @@ fn save_a_copy_and_template_suggest_their_names() { #[test] fn save_as_template_writes_ait_when_it_is_picked() { let (mut app, written, _) = desktop("flyer.ait"); - app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ait").to_string_lossy().into_owned(); + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-ait")); + app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); app.run("file.saveAsTemplate", json!({})).unwrap(); assert!(app.ui.dialog.is_none(), "a template asks no options"); let (path, bytes) = written.borrow()[0].clone(); @@ -285,9 +287,10 @@ fn the_native_options_have_no_precision_row() { /// replaced with the template's, as Save As replaces the extension of a format it doesn't write. #[test] fn save_as_template_replaces_a_save_formats_extension() { + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-ext")); for (picked, saved) in [("card.pdf", "card.vctemplate"), ("card.vectorcraft", "card.vctemplate"), ("card.svg", "card.vctemplate")] { let (mut app, written, _) = desktop(picked); - app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ext").to_string_lossy().into_owned(); + app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); app.run("file.saveAsTemplate", json!({})).unwrap(); let (path, bytes) = written.borrow()[0].clone(); assert_eq!(path, saved, "typed {picked}"); diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index a3a6cc4768..fda9e78cc5 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -251,14 +251,14 @@ fn long_page_notes_are_translated() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 56_692.91, "height": 2_834.65})).unwrap(); let mut notes: Vec = vec![]; - for p in [json!({"compatibility": "1.5"}), json!({"preset": "PDF/X-4:2010"})] { + for p in [json!({"compatibility": "1.5", "longPagesInPoints": false}), json!({"preset": "PDF/X-4:2010"})] { let r = s.execute("document.exportPdf", &p).unwrap(); let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("14,400 pt")).map(str::to_string)); } notes.push(s.execute("document.exportPdf", &json!({"preset": "PDF/X-1a:2001"})).unwrap_err().to_string()); s.execute("file.new", &json!({"width": 85_040, "height": 17})).unwrap(); - let r = s.execute("document.exportPdf", &json!({})).unwrap(); + let r = s.execute("document.exportPdf", &json!({"longPagesInPoints": false})).unwrap(); let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("too long and narrow")).map(str::to_string)); assert_eq!(notes.len(), 5, "{notes:?}"); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 117e83277c..ca37940601 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -205,7 +205,8 @@ fn svg_continues_in_svg_options() { /// Without Use Artboards, a PDF continues in Save PDF too, without its artboard choice, and the /// file is one page of the art's bounds. Its options apply: a drawing 10 m long is written at -/// full size in points with "Write pages longer than 200 in at full size in points". +/// full size in points with "Write pages longer than 200 in at full size in points" (on by +/// default), and with a `UserUnit` without it. #[test] fn pdf_art_continues_in_the_save_pdf_dialog() { let (mut app, written) = app(2); @@ -223,13 +224,22 @@ fn pdf_art_continues_in_the_save_pdf_dialog() { app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap(); app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); confirm(&mut app).unwrap(); - set(&mut app, "longPagesInPoints", json!(true)); frame(&mut app); confirm(&mut app).unwrap(); let bytes = written.borrow()[1].1.clone(); assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width(); assert!((28_346.0..28_350.0).contains(&width), "{width}"); + + app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); + confirm(&mut app).unwrap(); + set(&mut app, "longPagesInPoints", json!(false)); + frame(&mut app); + confirm(&mut app).unwrap(); + let bytes = written.borrow()[2].1.clone(); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit")); + let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width(); + assert!((28_346.0..28_350.0).contains(&width), "{width}"); } #[test] diff --git a/crates/ui-egui/src/dialogs/three_d_materials.rs b/crates/ui-egui/src/dialogs/three_d_materials.rs index 180572aeaa..3091a00cac 100644 --- a/crates/ui-egui/src/dialogs/three_d_materials.rs +++ b/crates/ui-egui/src/dialogs/three_d_materials.rs @@ -1,13 +1,13 @@ //! The 3D and Materials panel's Materials tab: the document's Graphics (its symbols) as thumbnails, -//! Add as Single Graphic, and the graphics mapped onto the selected 3D object's planar faces with -//! their face, position, size and turn. Picking a thumbnail maps that graphic onto the selected -//! objects (`threeD.mapGraphic`); art dropped on the thumbnails becomes a graphic +//! Add as Single Graphic, and the graphics mapped onto the selected 3D object (its planar faces, or +//! Revolve's side) with their face, position, size and turn. Picking a thumbnail maps that graphic +//! onto the selected objects (`threeD.mapGraphic`); art dropped on the thumbnails becomes a graphic //! (`threeD.addGraphic`). Editing a mapped graphic changes the effect's `graphics` parameter, which //! the panel sends as it sends every other setting. use egui::{Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; -use vectorcraft_effects::{GRAPHICS, MappedGraphic, PlanarFace, ROTATE}; +use vectorcraft_effects::{GRAPHICS, MappedGraphic, PlanarFace, REVOLVE, ROTATE}; use super::revolve::{heading, label, number}; use crate::VectorcraftApp; @@ -96,7 +96,7 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) } ui.add_space(10.0); if !takes { - dim_label(ui, tl!("Graphics map onto planar faces: pick the Plane or Extrude object type.")); + dim_label(ui, tl!("Graphics map onto the Plane, Extrude and Revolve object types.")); return; } let raw = d.fields.get(GRAPHICS).cloned().unwrap_or(Value::Null); @@ -121,7 +121,8 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) ui.add_space(6.0); let Some(g) = list.get(index).cloned() else { return }; let mut edited = g.clone(); - if effect != ROTATE { + // A plane has one face, and Revolve's graphics go on its side. + if effect != ROTATE && effect != REVOLVE { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; label(ui, tl!("Face")); diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index e47561094e..cac6338418 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -508,7 +508,10 @@ fn the_add_tool_menu_fills_a_new_toolbar_without_a_drag() { assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched"); assert!(!app.ui.tools_drawer, "no drawer opened"); let list = app.run("window.toolbar.list", json!({})).unwrap(); - assert_eq!(list, json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]]}])); + assert_eq!( + list, + json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]], "floating": true, "side": "left", "order": null}]) + ); // A second tool goes after the first. let shown = open(&mut app, 19.0); click(&mut app, &ctx, 20.0, items(&shown, "Magic Wand Tool")[0].0); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 4fbb7a7ffc..590b93a2fa 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -367,7 +367,11 @@ pub fn font_menu( let state_id = ui.scope_id().with(&id).with("font-menu"); let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); ui.data(|d| d.get_temp::(filters_id())).unwrap_or_default().apply(&mut st); - let target = ui.data(|d| d.get_temp(wheel_fields_id(ui.ctx().viewport_id()).with("target"))); + // The viewport is read outside `ui.data`. The closure of `ui.data` runs with the context + // locked, and a second lock taken inside it blocks once a font-sample thread is waiting to + // request a repaint. + let wheel_fields = wheel_fields_id(ui.ctx().viewport_id()); + let target = ui.data(|d| d.get_temp(wheel_fields.with("target"))); let target_changed = st.wheel_target != target; if target_changed { st.wheel_order = None; @@ -383,7 +387,7 @@ pub fn font_menu( |ui| { // Wider than a narrow field, so the filters keep their rows and long names show. ui.set_min_width(menu_width(ui, state, look)); - header(ui, state_id, state, current, look) + header(ui, vectorcraft_text::FontDb::global(), state_id, state, current, look) }, look.row, |h| h.rows.len(), @@ -435,10 +439,8 @@ pub fn font_menu( out = Some(FontPick::Wheel(steps)); } } - let queued = ui.data(|d| { - d.get_temp::(wheel_fields_id(ui.ctx().viewport_id())) - .is_some_and(|fields| fields.queued.iter().any(|event| event.field == field.id)) - }); + let queued = + ui.data(|d| d.get_temp::(wheel_fields).is_some_and(|fields| fields.queued.iter().any(|event| event.field == field.id))); if drawn || (!field.contains_pointer() && !queued) { st.wheel_order = None; ui.data_mut(|d| d.remove::(field.id.with("wheel"))); @@ -970,6 +972,8 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { /// What the menu's header hands its rows. struct Listed<'s> { + /// The fonts listed: [`vectorcraft_text::FontDb::global`] outside the unit tests. + db: &'s vectorcraft_text::FontDb, st: &'s mut MenuState, /// The rows listed. rows: Arc<[Row]>, @@ -985,14 +989,28 @@ struct Listed<'s> { /// [`craft_ui::combo::Menu::show_rows_with_header`] lays them out. #[cfg(test)] fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, sample: Option<&str>, look: MenuLook) -> Option { - let h = header(ui, state_id, st, current, look); + list_of(ui, vectorcraft_text::FontDb::global(), state_id, st, current, sample, look).inner +} + +/// [`list`] of the families in `db`, with the rows' scroll area as laid out. +#[cfg(test)] +fn list_of( + ui: &mut Ui, + db: &vectorcraft_text::FontDb, + state_id: egui::Id, + st: &mut MenuState, + current: &str, + sample: Option<&str>, + look: MenuLook, +) -> egui::scroll_area::ScrollAreaOutput> { + let h = header(ui, db, state_id, st, current, look); let total = h.rows.len(); - egui::ScrollArea::vertical().max_height(ui.available_height()).show_rows(ui, look.row, total, |ui, range| rows(ui, range, h, sample, look)).inner + egui::ScrollArea::vertical().max_height(ui.available_height()).show_rows(ui, look.row, total, |ui, range| rows(ui, range, h, sample, look)) } /// The top of the open menu, which stays in place: the search field, the filters, and the rows -/// they list. -fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &str, look: MenuLook) -> Listed<'s> { +/// of `db` they list. +fn header<'s>(ui: &mut Ui, db: &'s vectorcraft_text::FontDb, state_id: egui::Id, st: &'s mut MenuState, current: &str, look: MenuLook) -> Listed<'s> { let t = Tokens::get(ui.ctx()); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); // Each time the menu opens: no query, the current family highlighted and in view (the @@ -1025,7 +1043,6 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase(); let refilter = filters(ui, st, &t); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - let db = vectorcraft_text::FontDb::global(); // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック), // as it was before any preview. let current = db.canonical(&st.current, "").0; @@ -1092,7 +1109,7 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & // The scroll bar beside the rows, not over them: a floating one covered the stars at their // right ends, so a favourite was hard to click (#2277). ui.spacing_mut().scroll = widgets::list_scroll_style(); - Listed { st, rows, current: current.to_string(), enter, keyed, opening } + Listed { db, st, rows, current: current.to_string(), enter, keyed, opening } } /// One of the filters' chips. @@ -1166,28 +1183,29 @@ fn filters(ui: &mut Ui, st: &mut MenuState, t: &Tokens) -> bool { /// [`WINDOW_SHARE`] of the window. The menu is never narrower than its field. fn menu_width(ui: &Ui, st: &MenuState, look: MenuLook) -> f32 { let sample = if look.samples { SAMPLE_W } else { 0.0 }; - let names = name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + let names = name_width(ui, vectorcraft_text::FontDb::global(), look.english_names, &st.rows).min(NAME_MAX); let row = DISCLOSURE + names + NAME_GAP + sample + STAR + 2.0 + widgets::list_scroll_style().allocated_width(); // Rounding in the chips' widths must not wrap the widest row. let filters = if st.filters > 0.0 { st.filters.ceil() + 1.0 } else { 0.0 }; filters.max(row).min(ui.ctx().content_rect().width() * WINDOW_SHARE) } -/// The width of the widest name the menu lists after the disclosure triangle: the family names as -/// Show Font Names in English shows them, and the style names among `rows` after their indent. -fn name_width(ui: &Ui, english: bool, rows: &[Row]) -> f32 { +/// The width of the widest name the menu lists after the disclosure triangle: the family names of +/// `db` as Show Font Names in English shows them, and the style names among `rows` after their +/// indent. +fn name_width(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool, rows: &[Row]) -> f32 { let font = egui::FontId::proportional(NAME_SIZE); let measure = |name: &str| ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x; let styles = rows.iter().filter_map(Row::style).map(|s| STYLE_INDENT + measure(s)).fold(0.0, f32::max); - family_name_width(ui, english).max(styles) + family_name_width(ui, db, english).max(styles) } -/// What the widest family name was measured for: the font list's generation, Show Font Names in -/// English, and the number of fonts the interface draws with, which grows as faces for other -/// scripts are added to it. -fn family_name_key(ui: &Ui, english: bool) -> (u64, bool, usize) { +/// What the widest family name was measured for: the font database and its generation, Show Font +/// Names in English, and the number of fonts the interface draws with, which grows as faces for +/// other scripts are added to it. +fn family_name_key(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool) -> (u64, u64, bool, usize) { let faces = ui.ctx().fonts(|f| f.definitions().font_data.len()); - (vectorcraft_text::FontDb::global().generation(), english, faces) + (db.id(), db.generation(), english, faces) } /// The id under which the widest family name's width is kept with its [`family_name_key`]. @@ -1195,16 +1213,15 @@ fn family_name_id() -> egui::Id { egui::Id::unique("font-menu-name-width") } -/// The width of the widest family name the menu lists, labeled as Show Font Names in English -/// (`english`) labels it; worked out again only when its [`family_name_key`] changes. -fn family_name_width(ui: &Ui, english: bool) -> f32 { - let key = family_name_key(ui, english); - if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, bool, usize), f32)>(family_name_id())) +/// The width of the widest family name the menu lists from `db`, labeled as Show Font Names in +/// English (`english`) labels it; worked out again only when its [`family_name_key`] changes. +fn family_name_width(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool) -> f32 { + let key = family_name_key(ui, db, english); + if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, u64, bool, usize), f32)>(family_name_id())) && k == key { return w; } - let db = vectorcraft_text::FontDb::global(); let w = widest_label(ui, db.menu_family_list().iter().map(|f| db.family_display_name(f, english)).collect()); ui.data_mut(|d| d.insert_temp(family_name_id(), (key, w))); w @@ -1269,14 +1286,13 @@ fn heading_row(ui: &mut Ui, row_h: f32, label: &str, t: &Tokens) { /// The rows of the open menu in `range`, the ones in view: the menu's one scrolling part, under /// the search field and filters (#555). fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Option<&str>, look: MenuLook) -> Option { - let Listed { st, rows, current, enter, keyed, opening } = h; + let Listed { db, st, rows, current, enter, keyed, opening } = h; let current = current.as_str(); let row_h = look.row; let t = Tokens::get(ui.ctx()); let ink = sample_ink(look.on_white, t.text); - let db = vectorcraft_text::FontDb::global(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); - let widest = name_width(ui, look.english_names, &rows); + let widest = name_width(ui, db, look.english_names, &rows); if rows.is_empty() { // A filter or a font rescan can remove the row whose font was previewed. // Do not leave that font applied while the list has no candidate to highlight. diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 88a524e15b..11a66cab00 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -21,40 +21,44 @@ fn the_thai_filter_lists_fonts_with_thai() { /// stay clear of it. #[test] fn the_scroll_bar_leaves_the_stars_clear() { - // Every family starred, so each row draws its star. Tests running alongside add fonts and - // composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps - // the rows to the families starred here. - let all = vectorcraft_text::FontDb::global().families(); + // A database of the bundled fonts alone, each family starred, so each row draws its star: + // fonts that tests running alongside add to the process-wide database stay out of the rows. + let db = vectorcraft_text::FontDb::with_font_dirs(vec![]); + let favorites: Vec = ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"].map(str::to_string).into(); let ctx = egui::Context::default(); + // Under the search field and the filters, room for fewer rows than the four families. let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); let mut st = super::MenuState { favorites_only: true, ..Default::default() }; - let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; - let mut stars = vec![]; - for _ in 0..3 { + let look = super::MenuLook { samples: false, favorites: &favorites, ..Default::default() }; + let (mut list, mut stars) = (None, vec![]); + // Frames enough for the scroll bar to finish appearing. + for _ in 0..30 { let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); - super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look); + let shown = super::list_of(&mut child, &db, egui::Id::unique("fonts"), &mut st, "Inter", None, look); + list = Some((shown.inner_rect, shown.content_size)); }); out.textures_delta.clear(); stars = out .shapes .iter() .filter_map(|c| match &c.shape { - // The rows' stars, not the ★ filter button at the top left. - egui::Shape::Text(t) if t.galley.text() == "★" && t.pos.x > area.center().x => Some(t.visual_bounding_rect().right()), + egui::Shape::Text(t) if t.galley.text() == "★" => Some(t.visual_bounding_rect()), _ => None, }) .collect(); } - let families = st.rows.len(); - assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); - // The rows don't fill the list without enough families to scroll, and then there is no bar. - if families as f32 * look.row > area.height() { - let bar = super::widgets::list_scroll_style(); - let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin; - for right in stars { - assert!(right <= area.right() - column + 0.5, "a star at {right} runs under the scroll bar"); - } + let Some((inner, content)) = list else { panic!("the list was not drawn") }; + let families: Vec<&str> = st.rows.iter().filter_map(super::Row::family).collect(); + assert_eq!(families.len(), favorites.len(), "the starred families are listed: {families:?}"); + assert!(content.y > inner.height() + 0.5, "the rows, {} high, fill a list {} high", content.y, inner.height()); + // The rows' stars in the list, not the ★ filter above it. + let stars: Vec<_> = stars.into_iter().filter(|s| inner.contains(s.center())).collect(); + assert!(!stars.is_empty(), "no row star drawn in the list at {inner:?}"); + let bar = super::widgets::list_scroll_style(); + let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin; + for star in stars { + assert!(star.right() <= area.right() - column + 0.5, "a star at {} runs under the scroll bar", star.right()); } } @@ -104,14 +108,33 @@ fn the_widest_name_is_measured_again_when_the_interface_fonts_change() { }; // A width no name has, kept as if measured for the fonts as they are. frame(&mut |ui| { - let key = super::family_name_key(ui, true); + let key = super::family_name_key(ui, vectorcraft_text::FontDb::global(), true); ui.data_mut(|d| d.insert_temp(super::family_name_id(), (key, -1.0_f32))); }); frame(&mut |ui| { let data = ui.ctx().fonts(|f| f.definitions().font_data.values().next().cloned()).expect("an interface font"); ui.ctx().add_font(egui::epaint::text::FontInsert { name: "font-menu-test-face".into(), data: (*data).clone(), families: vec![] }); }); - frame(&mut |ui| assert!(super::family_name_width(ui, true) >= 0.0, "measured again")); + frame(&mut |ui| assert!(super::family_name_width(ui, vectorcraft_text::FontDb::global(), true) >= 0.0, "measured again")); +} + +/// The widest family name measured for one font database is not taken for another one at the same +/// generation. +#[test] +fn each_font_database_has_its_own_widest_name() { + use vectorcraft_doc::CompositeFont; + let short = vectorcraft_text::FontDb::with_font_dirs(vec![]); + let long = vectorcraft_text::FontDb::with_font_dirs(vec![]); + assert!(short.set_composite_font(&CompositeFont::new("C", "Inter", "Regular"))); + assert!(long.set_composite_font(&CompositeFont::new("A composite font with a name longer than any family", "Inter", "Regular"))); + assert_eq!(short.generation(), long.generation()); + let ctx = egui::Context::default(); + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + widths = (super::family_name_width(ui, &short, true), super::family_name_width(ui, &long, true)); + }); + out.textures_delta.clear(); + assert!(widths.1 > widths.0 + 1.0, "the long name's database measured {} and the other {}", widths.1, widths.0); } /// Without a row of filters drawn yet, the menu still makes room for the widest name, its sample, @@ -125,7 +148,7 @@ fn the_menu_makes_room_for_the_widest_name_and_its_sample() { let mut out = ctx.run_ui(input, |ui| { let st = super::MenuState::default(); let look = super::MenuLook::default(); - let names = super::name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + let names = super::name_width(ui, vectorcraft_text::FontDb::global(), look.english_names, &st.rows).min(NAME_MAX); assert!(names > 0.0, "a family is listed"); let width = super::menu_width(ui, &st, look); assert!(width >= DISCLOSURE + names + NAME_GAP + SAMPLE_W + STAR, "the menu is {width} wide for a name {names} wide"); @@ -143,14 +166,15 @@ fn only_listed_style_names_widen_the_names() { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let long = format!("SemiCondensed ExtraLight Italic {}", "W".repeat(80)); + let db = vectorcraft_text::FontDb::global(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let families = super::name_width(ui, true, &[]); + let families = super::name_width(ui, db, true, &[]); let font = egui::FontId::proportional(super::NAME_SIZE); let style = STYLE_INDENT + ui.painter().layout_no_wrap(long.clone(), font, egui::Color32::WHITE).size().x; assert!(style > families, "the style name is wider than every family name"); let listed = [Row::Family("A".into()), Row::Style("A".into(), long.clone())]; - assert_eq!(super::name_width(ui, true, &listed), style); - assert!(super::name_width(ui, true, &listed[..1]) < style, "a style not listed is not measured"); + assert_eq!(super::name_width(ui, db, true, &listed), style); + assert!(super::name_width(ui, db, true, &listed[..1]) < style, "a style not listed is not measured"); }); out.textures_delta.clear(); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1fb96d9c7d..ff72685662 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1537,6 +1537,8 @@ font-technology Variable Променливи Objects on Path Tool Инструмент „Обекти по пътя“ Attach Objects on Path Прикрепяне на обекти към пътя Expand Objects on Path Разширяване на обекти по пътя + Detach Откачване + Detach Object from Path Откачване на обект от пътя Release Live Paint Освобождаване на динамично оцветяване Release Repeat Освобождаване на повторение Release Slice Освобождаване на отрязък @@ -2833,6 +2835,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията Color Theme Picker Избор на цветова тема Click or drag across the art to pick its colors; Shift adds more Щракнете или плъзнете през илюстрацията, за да вземете цветовете ѝ; Shift добавя още + Prominent Colors Преобладаващи цветове + Save All Colors Запази всички цветове + Save Prominent Colors Запази преобладаващите цветове Reset to Default Връщане към стандартните Reset to White, Black Нулиране към бяло, черно Reset with Mesh Нулиране с мрежа @@ -3369,6 +3374,20 @@ digits Malayalam Малаялам Spot Color Objects Обекти със спот цветове Pattern Objects Обекти с мостри Gradient Objects Обекти с градиенти + Font Details Подробности за шрифтовете + Linked Images Свързани изображения + Embedded Images Вградени изображения + Compound Paths Съставни пътища + Text Objects Текстови обекти + Placed Documents Поставени документи + Symbol Instances Екземпляри на символи + Gradient Meshes Мрежи с градиент + Blends Преливания + Envelopes Пликове + Intertwine Groups Групи с преплитане + Opacity Masks Маски за непрозрачност + Objects with Effects Обекти с ефекти + Path Length Дължина на пътя Spot Colors Спот цветове Spot Colors… Спот цветове… Spread: Разпространение: @@ -4054,6 +4073,10 @@ axis Both И двете Dock Panel Закотвен панел Dock to Top Закотвяне горе Dock to Bottom Закотвяне долу + Dock to Left Закотвяне вляво + Dock to Right Закотвяне вдясно + Dock Toolbar Закотвяне на лентата с инструменти + Float Toolbar Плаваща лента с инструменти Control Bar Options Опции на контролната лента Tool Options Опции за инструмент Object Options Опции за обект @@ -4493,6 +4516,7 @@ axis Both И двете @msg paths contain invalid coordinates пътищата съдържат невалидни координати @msg paths: give at least one file пътища: посочете поне един файл @msg replace: the object to replace was changed or deleted замяна: обектът за замяна е променен или изтрит +@msg the document it was prepared for is no longer active документът, за който е подготвено, вече не е активен @msg pattern missing липсва мостра @msg patterns nested too deeply are filled with mid-grey мострите, вложени прекалено дълбоко, се запълват със средно сиво @msg percent must be above 0 and at most 6400, not {p} percent трябва да е над 0 и най-много 6400, а не {p} @@ -5998,6 +6022,7 @@ axis Y Axis Y ос @msg message must be text message трябва да бъде текст @msg allowContinue must be true or false allowContinue трябва да бъде true или false @msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране +@msg Batch cannot play an action with a View menu step Пакет не може да изпълни действие със стъпка от менюто Изглед Insert Menu Item… Вмъкване на елемент от меню… Insert Menu Item Вмъкване на елемент от меню Insert Select Path Вмъкване на избор на път @@ -6162,6 +6187,13 @@ axis Y Axis Y ос Area Text Текст в зона # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Измерване на текста по границите на глифа +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Опции за прилепване към пиксел + Pixel Snapping Options… Опции за прилепване към пиксел… + Snap to Pixel While Drawing Прилепване към пиксел при рисуване + Snap to Pixel While Moving Прилепване към пиксел при преместване + Snap to Pixel While Scaling Прилепване към пиксел при мащабиране + These options take effect when View › Snap to Pixel is on. Тези опции действат, когато Изглед › Прилепване към пиксел е включено. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показване на опциите за прилепване към глиф Hide Snap to Glyph Options Скриване на опциите за прилепване към глиф @@ -6747,6 +6779,19 @@ breakText Glyphs Глифи Show where this tool is Показване къде е този инструмент @msg missing `tutorial` (see learn.list) липсва `tutorial` (вижте learn.list) @msg unknown tutorial `{id}` (see learn.list) непознат урок `{id}` (вижте learn.list) + View more Вижте повече + Finish tutorial Завършване на урока + Next Up Следващ + You finished the last tutorial in this skill. Завършихте последния урок в това умение. + Back to skills Обратно към уменията + Open Tutorial Sample Отваряне на примерния файл на урока + Finish Tutorial Завършване на урок + Next Tutorial Следващ урок + Back to Skills Обратно към уменията +@msg tutorial `{id}` has no practice sample урокът `{id}` няма примерен файл за упражнение +@msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample) +@msg finish `{id}` first (see learn.finish) първо завършете `{id}` (вижте learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` е последният урок в своето умение # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f04e9535e5..0c77f90ad8 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -908,6 +908,10 @@ Dock Panel Usidri panel Dock to Top Usidri na vrh Dock to Bottom Usidri na dno + Dock to Left Usidri lijevo + Dock to Right Usidri desno + Dock Toolbar Usidri traku s alatima + Float Toolbar Odvoji traku s alatima Control Bar Options Opcije kontrolne trake Tool Options Opcije alata Object Options Opcije objekta @@ -1809,6 +1813,8 @@ digits Malayalam Malajalam Objects on Path Tool Alat objekti na putanji Attach Objects on Path Priloži objekte na putanju Expand Objects on Path Proširi objekte na putanji + Detach Odvoji + Detach Object from Path Odvoji objekat s putanje Release Live Paint Oslobodi živo bojenje Release Repeat Oslobodi ponavljanje Release Slice Oslobodi isječak @@ -2855,6 +2861,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Vrati redove na boje crteža Color Theme Picker Birač teme boja Click or drag across the art to pick its colors; Shift adds more Kliknite ili prevucite preko crteža da uzmete njegove boje; Shift dodaje još + Prominent Colors Istaknute boje + Save All Colors Sačuvaj sve boje + Save Prominent Colors Sačuvaj istaknute boje Separate colors into rows Razdvoji boje u redove Unlink harmony colors Prekini vezu boja harmonije 1 Color Job Posao s 1 bojom @@ -3956,6 +3965,19 @@ theme Appearance Izgled Spot Color Objects Objekti sa spot bojama Pattern Objects Objekti s uzorcima Gradient Objects Objekti s gradijentima + Font Details Detalji fontova + Linked Images Povezane slike + Embedded Images Ugrađene slike + Compound Paths Složene putanje + Text Objects Tekstualni objekti + Placed Documents Postavljeni dokumenti + Symbol Instances Instance simbola + Gradient Meshes Gradijentne mreže + Blends Stapanja + Envelopes Omotnice + Intertwine Groups Grupe isprepletanja + Opacity Masks Maske neprozirnosti + Objects with Effects Objekti s efektima Spot Colors Spot boje Spot Colors (0) Spot boje (0) Swatch Options Opcije uzorka boje @@ -4889,6 +4911,7 @@ imageTrace Outline Obris @msg paths contain invalid coordinates putanje sadrže nevažeće koordinate @msg paths: give at least one file paths: navedite barem jednu datoteku @msg replace: the object to replace was changed or deleted replace: objekat za zamjenu je promijenjen ili izbrisan +@msg the document it was prepared for is no longer active dokument za koji je pripremljeno više nije aktivan @msg pattern missing uzorak nedostaje @msg patterns nested too deeply are filled with mid-grey previše duboko ugniježđeni uzorci ispunjavaju se srednje sivom @msg percent must be above 0 and at most 6400, not {p} percent mora biti iznad 0 i najviše 6400, a ne {p} @@ -4924,6 +4947,7 @@ imageTrace Outline Obris @msg select a repeat odaberite ponavljanje @msg select an Intertwine group odaberite Intertwine grupu @msg select an Objects on Path group odaberite grupu objekata na putanji +@msg choose the object to detach with its Select/Move widget odaberite objekat koji želite odvojiti njegovom ručicom Odaberi/Pomjeri @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects putanja unutar složene putanje, stapanja, omotnice, ponavljanja ili Intertwine grupe ne može primiti objekte @msg a clipping path can't take objects putanja isijecanja ne može primiti objekte @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekti unutar stapanja, omotnice, ponavljanja ili Intertwine grupe ne mogu se priložiti putanji @@ -6040,6 +6064,7 @@ axis Y Axis Y osa @msg message must be text message mora biti tekst @msg allowContinue must be true or false allowContinue mora biti true ili false @msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem +@msg Batch cannot play an action with a View menu step Paketna obrada ne može reprodukovati akciju s korakom iz menija Prikaz Insert Menu Item… Umetni stavku menija… Insert Menu Item Umetni stavku menija Insert Select Path Umetni odabir putanje @@ -6221,6 +6246,12 @@ axis Y Axis Y osa Area Text Tekst u oblasti # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mjeri tekst po granicama glifa + Pixel Snapping Options Opcije prianjanja uz piksel + Pixel Snapping Options… Opcije prianjanja uz piksel… + Snap to Pixel While Drawing Prilijepi uz piksel pri crtanju + Snap to Pixel While Moving Prilijepi uz piksel pri pomjeranju + Snap to Pixel While Scaling Prilijepi uz piksel pri skaliranju + These options take effect when View › Snap to Pixel is on. Ove opcije djeluju kada je uključeno Prikaz › Prilijepi uz piksel. Show Snap to Glyph Options Prikaži opcije prianjanja uz glif Hide Snap to Glyph Options Sakrij opcije prianjanja uz glif Snap to Glyph Options Opcije prianjanja uz glif @@ -6801,6 +6832,19 @@ shape Open Otvoren Show where this tool is Prikaži gdje je ovaj alat @msg missing `tutorial` (see learn.list) nedostaje `tutorial` (vidi learn.list) @msg unknown tutorial `{id}` (see learn.list) nepoznat tutorijal `{id}` (vidi learn.list) + View more Prikaži više + Finish tutorial Završi tutorijal + Next Up Sljedeće + You finished the last tutorial in this skill. Završili ste posljednji tutorijal u ovoj vještini. + Back to skills Nazad na vještine + Open Tutorial Sample Otvori uzorak tutorijala + Finish Tutorial Završi tutorijal + Next Tutorial Sljedeći tutorijal + Back to Skills Nazad na vještine +@msg tutorial `{id}` has no practice sample tutorijal `{id}` nema uzorak za vježbu +@msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample) +@msg finish `{id}` first (see learn.finish) prvo završite `{id}` (vidi learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` je posljednji tutorijal u svojoj vještini # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index e6b6f398f2..6601948ba6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1247,6 +1247,10 @@ antiAlias Sharp Nítid Dock Panel Acobla el panell Dock to Top Acobla a dalt Dock to Bottom Acobla a baix + Dock to Left Acobla a l’esquerra + Dock to Right Acobla a la dreta + Dock Toolbar Acobla la barra d’eines + Float Toolbar Flota la barra d’eines Control Bar Options Opcions de la barra de control Tool Options Opcions de l’eina Object Options Opcions de l’objecte @@ -1542,6 +1546,8 @@ antiAlias Sharp Nítid Expand Live Paint Expandeix la pintura interactiva Expand Object Expandeix l’objecte Expand Objects on Path Expandeix els objectes del traçat + Detach Separa + Detach Object from Path Separa l’objecte del traçat Expand Options Opcions d’expansió Expand Panels Expandeix els panells Expand Repeat Expandeix la repetició @@ -3617,6 +3623,9 @@ digits Malayalam Malaiàlam Reset the rows to the artwork's colors Restableix les files als colors de l’obra Color Theme Picker Selector de tema de colors Click or drag across the art to pick its colors; Shift adds more Feu clic o arrossegueu sobre l’obra per agafar-ne els colors; Maj n’afegeix més + Prominent Colors Colors predominants + Save All Colors Desa tots els colors + Save Prominent Colors Desa els colors predominants Reset to Default Restableix el valor predeterminat Reset to default Restableix el valor predeterminat Reset to Defaults Restableix els valors predeterminats @@ -4051,6 +4060,7 @@ digits Malayalam Malaiàlam @msg message must be text message ha de ser text @msg allowContinue must be true or false allowContinue ha de ser true o false @msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada +@msg Batch cannot play an action with a View menu step Lot no pot reproduir una acció amb un pas del menú Visualitza Insert Menu Item… Insereix un element de menú… Insert Menu Item Insereix un element de menú Insert Select Path Insereix una selecció de traçat @@ -4381,6 +4391,19 @@ digits Malayalam Malaiàlam Spot Color Objects Objectes amb colors directes Pattern Objects Objectes amb motius Gradient Objects Objectes amb degradats + Font Details Detalls dels tipus de lletra + Linked Images Imatges enllaçades + Embedded Images Imatges incrustades + Compound Paths Traçats compostos + Text Objects Objectes de text + Placed Documents Documents col·locats + Symbol Instances Instàncies de símbol + Gradient Meshes Malles de degradat + Blends Fusions + Envelopes Embolcalls + Intertwine Groups Grups entrellaçats + Opacity Masks Màscares d’opacitat + Objects with Effects Objectes amb efectes Spot Colors Colors directes Spot Colors (0) Colors directes (0) Spot Colors… Colors directes… @@ -5735,6 +5758,7 @@ digits Malayalam Malaiàlam @msg the library has another item of that kind named `{name}` la biblioteca ja té un altre element d’aquest tipus anomenat `{name}` @msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list}) @msg No linked files to look for No hi ha fitxers enllaçats per buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç @msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt @msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt @msg no native format no hi ha format natiu @@ -5945,6 +5969,7 @@ digits Malayalam Malaiàlam @msg Reload the page to go on (changes not saved yet may be lost). Recarrega la pàgina per continuar (els canvis encara no desats es poden perdre). @msg Replace Spine: select a blend and a path Substitueix l’espina: selecciona una fusió i un traçat @msg replace: the object to replace was changed or deleted replace: l’objecte que s’havia de substituir s’ha canviat o suprimit +@msg the document it was prepared for is no longer active el document per al qual es va preparar ja no està actiu @msg Reset {_1} Restableix {_1} @msg resolutions must be between 1 and 2400 ppi les resolucions han de ser entre 1 i 2400 ppi @msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them S’ha restaurat {n} document recuperat: desa’l per conservar-lo|S’han restaurat {n} documents recuperats: desa’ls per conservar-los @@ -6000,6 +6025,7 @@ digits Malayalam Malaiàlam @msg select an Intertwine group selecciona un grup Intertwine @msg select an object in perspective selecciona un objecte en perspectiva @msg select an Objects on Path group selecciona un grup d’objectes sobre un traçat +@msg choose the object to detach with its Select/Move widget tria l’objecte que vols separar amb el seu control Selecciona/Mou @msg select artwork (or a colour group) to recolor selecciona la il·lustració (o un grup de colors) per recolorejar @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloració completada; sense canvis: {locked} i {hidden} @msg Recolor completed; {objects} were left unchanged Recoloració completada; sense canvis: {objects} @@ -6095,7 +6121,9 @@ digits Malayalam Malaiàlam @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` ha de ser front o back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` ha de ser un nombre de {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` ha de ser una matriu -@msg Graphics map onto planar faces: use the Extrude or Plane object type Els gràfics s’apliquen a cares planes: fes servir el tipus d’objecte Extrusió o Pla +@msg Graphics map onto the Plane, Extrude and Revolve object types Els gràfics s’apliquen als tipus d’objecte Pla, Extrusió i Revolució +@msg Revolve maps graphics onto its side: map them onto the front Revoluciona aplica els gràfics al seu lateral: aplica’ls a la cara davantera +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Els gràfics són massa complexos per a aquesta superfície: simplifica la il·lustració dels símbols o redueix Segments @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admet com a màxim {MAX_GRAPHICS} gràfics @msg A plane has one face: map its graphics onto the front Un pla té una sola cara: aplica’n els gràfics a la cara davantera @msg The graphics are too complex: simplify the symbols' art Els gràfics són massa complexos: simplifica la il·lustració dels símbols @@ -6679,6 +6707,13 @@ theme Appearance Aparença Area Text Text d’àrea # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mesura el text pels límits del glif +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opcions d’ajust al píxel + Pixel Snapping Options… Opcions d’ajust al píxel… + Snap to Pixel While Drawing Ajusta al píxel en dibuixar + Snap to Pixel While Moving Ajusta al píxel en moure + Snap to Pixel While Scaling Ajusta al píxel en escalar + These options take effect when View › Snap to Pixel is on. Aquestes opcions tenen efecte quan Visualitza › Ajusta al píxel està activat. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra les opcions d’ajust al glif Hide Snap to Glyph Options Amaga les opcions d’ajust al glif @@ -7324,6 +7359,19 @@ breakText Glyphs Glifs Show where this tool is Mostra on és aquesta eina @msg missing `tutorial` (see learn.list) falta `tutorial` (vegeu learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconegut `{id}` (vegeu learn.list) + View more Mostra’n més + Finish tutorial Acaba el tutorial + Next Up A continuació + You finished the last tutorial in this skill. Heu acabat l’últim tutorial d’aquesta habilitat. + Back to skills Torna a les habilitats + Open Tutorial Sample Obre la mostra del tutorial + Finish Tutorial Acaba el tutorial + Next Tutorial Tutorial següent + Back to Skills Torna a les habilitats +@msg tutorial `{id}` has no practice sample el tutorial `{id}` no té cap mostra de pràctica +@msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample) +@msg finish `{id}` first (see learn.finish) acabeu primer `{id}` (vegeu learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` és l’últim tutorial de la seva habilitat # Deferred plug-in effects Refresh Deferred Effects Actualitza els efectes ajornats diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a44e3b9979..baef0a88e6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -923,6 +923,10 @@ Dock Panel Ukotvit panel Dock to Top Ukotvit nahoru Dock to Bottom Ukotvit dolů + Dock to Left Ukotvit vlevo + Dock to Right Ukotvit vpravo + Dock Toolbar Ukotvit panel nástrojů + Float Toolbar Plovoucí panel nástrojů Control Bar Options Možnosti ovládacího panelu Tool Options Možnosti nástroje Object Options Možnosti objektu @@ -1840,6 +1844,8 @@ digits Malayalam Malajálamské Objects on Path Tool Nástroj Objekty na cestě Attach Objects on Path Připojit objekty na cestu Expand Objects on Path Rozložit objekty na cestě + Detach Odpojit + Detach Object from Path Odpojit objekt od cesty Release Live Paint Uvolnit živou malbu Release Repeat Uvolnit opakování Release Slice Uvolnit řez @@ -2891,6 +2897,9 @@ digits Malayalam Malajálamské Reset the rows to the artwork's colors Obnovit řádky na barvy kresby Color Theme Picker Výběr barevného motivu Click or drag across the art to pick its colors; Shift adds more Klepnutím nebo tažením přes kresbu převezmete její barvy; Shift přidá další + Prominent Colors Převládající barvy + Save All Colors Uložit všechny barvy + Save Prominent Colors Uložit převládající barvy Separate colors into rows Rozdělit barvy do řádků Unlink harmony colors Zrušit propojení barev harmonie 1 Color Job Zakázka s 1 barvou @@ -4012,6 +4021,19 @@ theme Appearance Motiv Spot Color Objects Objekty s přímými barvami Pattern Objects Objekty se vzorky Gradient Objects Objekty s přechody + Font Details Podrobnosti písem + Linked Images Propojené obrazy + Embedded Images Vložené obrazy + Compound Paths Složené cesty + Text Objects Textové objekty + Placed Documents Umístěné dokumenty + Symbol Instances Instance symbolů + Gradient Meshes Přechodové sítě + Blends Prolnutí + Envelopes Obálky + Intertwine Groups Skupiny proplétání + Opacity Masks Masky krytí + Objects with Effects Objekty s efekty Spot Colors Přímé barvy Spot Colors (0) Přímé barvy (0) Swatch Options Možnosti položky vzorníku @@ -5056,6 +5078,7 @@ imageTrace Outline Obrys @msg paths contain invalid coordinates cesty obsahují neplatné souřadnice @msg paths: give at least one file paths: zadejte alespoň jeden soubor @msg replace: the object to replace was changed or deleted replace: objekt k nahrazení byl změněn nebo odstraněn +@msg the document it was prepared for is no longer active dokument, pro který bylo připraveno, už není aktivní @msg pattern missing chybí vzorek @msg patterns nested too deeply are filled with mid-grey příliš hluboko vnořené vzorky se vyplní středně šedou @msg percent must be above 0 and at most 6400, not {p} percent musí být větší než 0 a nejvýše 6400, ne {p} @@ -5091,6 +5114,7 @@ imageTrace Outline Obrys @msg select a repeat vyberte opakování @msg select an Intertwine group vyberte skupinu propletení @msg select an Objects on Path group vyberte skupinu objektů na cestě +@msg choose the object to detach with its Select/Move widget vyberte objekt k odpojení jeho ovládacím prvkem Vybrat/Přesunout @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects cesta uvnitř složené cesty, prolnutí, obálky, opakování nebo skupiny propletení nemůže přijmout objekty @msg a clipping path can't take objects ořezová cesta nemůže přijmout objekty @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekty uvnitř prolnutí, obálky, opakování nebo skupiny propletení nelze připojit na cestu @@ -5415,6 +5439,7 @@ imageTrace Outline Obrys @msg every numbered name for it is taken in the Fonts folder ve složce Fonts jsou obsazena všechna číslovaná jména pro tento soubor @msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} @msg No linked files to look for Žádné propojené soubory k vyhledání +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} @@ -5938,7 +5963,9 @@ mark-style Japanese Japonské @msg `{GRAPHICS}` face must be front or back face v `{GRAPHICS}` musí být front nebo back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} v `{GRAPHICS}` musí být číslo od {lo} do {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` musí být pole -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafika se mapuje na rovinné stěny: použijte typ objektu Vysunutí nebo Rovina +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafika se mapuje na typy objektu Rovina, Vysunutí a Rotační těleso +@msg Revolve maps graphics onto its side: map them onto the front Rotační těleso mapuje grafiku na svůj bok: umístěte ji na přední stranu +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafika je pro tento povrch příliš složitá: zjednodušte kresbu symbolů nebo snižte Segmenty @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` přijímá nejvýše {MAX_GRAPHICS} grafik @msg A plane has one face: map its graphics onto the front Rovina má jednu stranu: umístěte její grafiku na přední stranu @msg The graphics are too complex: simplify the symbols' art Grafika je příliš složitá: zjednodušte kresbu symbolů @@ -6533,6 +6560,7 @@ axis Y Axis osa Y @msg message must be text message musí být text @msg allowContinue must be true or false allowContinue musí být true nebo false @msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením +@msg Batch cannot play an action with a View menu step Dávka nemůže přehrát akci s krokem z nabídky Zobrazení Insert Menu Item… Vložit položku nabídky… Insert Menu Item Vložit položku nabídky Insert Select Path Vložit výběr cesty @@ -6716,6 +6744,13 @@ axis Y Axis osa Y Area Text Text v oblasti # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Měřit text podle hranic glyfů +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Možnosti přichytávání k obrazovým bodům + Pixel Snapping Options… Možnosti přichytávání k obrazovým bodům… + Snap to Pixel While Drawing Přichytávat k obrazovým bodům při kreslení + Snap to Pixel While Moving Přichytávat k obrazovým bodům při přesouvání + Snap to Pixel While Scaling Přichytávat k obrazovým bodům při změně velikosti + These options take effect when View › Snap to Pixel is on. Tyto volby se uplatní, když je zapnuto Zobrazení › Přichytávat k obrazovým bodům. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Zobrazit možnosti přichytávání ke glyfům Hide Snap to Glyph Options Skrýt možnosti přichytávání ke glyfům @@ -7343,6 +7378,19 @@ breakText Glyphs Glyfy Show where this tool is Zobrazit, kde je tento nástroj @msg missing `tutorial` (see learn.list) chybí `tutorial` (viz learn.list) @msg unknown tutorial `{id}` (see learn.list) neznámý výukový kurz `{id}` (viz learn.list) + View more Zobrazit více + Finish tutorial Dokončit výukový kurz + Next Up Další + You finished the last tutorial in this skill. Dokončili jste poslední výukový kurz této dovednosti. + Back to skills Zpět na dovednosti + Open Tutorial Sample Otevřít ukázku výukového kurzu + Finish Tutorial Dokončit výukový kurz + Next Tutorial Další výukový kurz + Back to Skills Zpět na dovednosti +@msg tutorial `{id}` has no practice sample výukový kurz `{id}` nemá cvičnou ukázku +@msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample) +@msg finish `{id}` first (see learn.finish) nejprve dokončete `{id}` (viz learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` je poslední výukový kurz své dovednosti # Deferred plug-in effects Refresh Deferred Effects Obnovit odložené efekty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bce3bd12da..9192974eec 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -853,6 +853,10 @@ Dock Panel Dok panel Dock to Top Dok øverst Dock to Bottom Dok nederst + Dock to Left Dok til venstre + Dock to Right Dok til højre + Dock Toolbar Dok værktøjslinjen + Float Toolbar Flydende værktøjslinje Control Bar Options Indstillinger for kontrollinjen Tool Options Værktøjsindstillinger Object Options Objektindstillinger @@ -2675,6 +2679,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Nulstil rækkerne til grafikkens farver Color Theme Picker Farvetemavælger Click or drag across the art to pick its colors; Shift adds more Klik eller træk hen over grafikken for at hente dens farver; Skift tilføjer flere + Prominent Colors Fremtrædende farver + Save All Colors Gem alle farver + Save Prominent Colors Gem fremtrædende farver Separate colors into rows Opdel farver i rækker Unlink harmony colors Fjern sammenkædning af harmonifarver 1 Color Job 1-farvejob @@ -3731,6 +3738,19 @@ theme Appearance Udseende Spot Color Objects Objekter med spotfarver Pattern Objects Objekter med mønstre Gradient Objects Objekter med gradienter + Font Details Skrifttypedetaljer + Linked Images Linkede billeder + Embedded Images Indlejrede billeder + Compound Paths Sammensatte kurver + Text Objects Tekstobjekter + Placed Documents Placerede dokumenter + Symbol Instances Symbolforekomster + Gradient Meshes Gradientnet + Blends Blandinger + Envelopes Konvolutter + Intertwine Groups Sammenfletningsgrupper + Opacity Masks Opacitetsmasker + Objects with Effects Objekter med effekter Spot Colors Spotfarver Spot Colors (0) Spotfarver (0) Swatch Options Indstillinger for farveprøve @@ -4594,6 +4614,7 @@ imageTrace Outline Kontur @msg paths contain invalid coordinates stierne indeholder ugyldige koordinater @msg paths: give at least one file stier: angiv mindst én fil @msg replace: the object to replace was changed or deleted erstat: objektet, der skulle erstattes, er ændret eller slettet +@msg the document it was prepared for is no longer active dokumentet, det blev forberedt til, er ikke længere aktivt @msg pattern missing mønster mangler @msg patterns nested too deeply are filled with mid-grey mønstre, der er indlejret for dybt, udfyldes med mellemgrå @msg percent must be above 0 and at most 6400, not {p} percent skal være over 0 og højst 6400, ikke {p} @@ -5821,6 +5842,8 @@ swatch Bright Violet Kraftig violet Objects on Path Tool Objekter på kurve-værktøj Attach Objects on Path Tilknyt objekter til kurve Expand Objects on Path Udvid objekter på kurve + Detach Frigør + Detach Object from Path Frigør objekt fra kurve Release Live Paint Frigør live-maling Release Repeat Frigør gentagelse Release Slice Frigør udsnit @@ -6015,6 +6038,7 @@ axis Y Axis Y-akse @msg message must be text message skal være tekst @msg allowContinue must be true or false allowContinue skal være true eller false @msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop +@msg Batch cannot play an action with a View menu step Batch kan ikke afspille en handling med et trin fra menuen Vis Insert Menu Item… Indsæt menupunkt… Insert Menu Item Indsæt menupunkt Insert Select Path Indsæt kurvemarkering @@ -6179,6 +6203,13 @@ axis Y Axis Y-akse Area Text Områdetekst # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mål tekst efter glyfafgrænsning +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Indstillinger for fastgørelse til pixel + Pixel Snapping Options… Indstillinger for fastgørelse til pixel… + Snap to Pixel While Drawing Fastgør til pixel ved tegning + Snap to Pixel While Moving Fastgør til pixel ved flytning + Snap to Pixel While Scaling Fastgør til pixel ved skalering + These options take effect when View › Snap to Pixel is on. Disse indstillinger virker, når Vis › Fastgør til pixel er slået til. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Vis indstillinger for fastgørelse til tegn Hide Snap to Glyph Options Skjul indstillinger for fastgørelse til tegn @@ -6763,6 +6794,19 @@ breakText Glyphs Glyffer Show where this tool is Vis, hvor dette værktøj er @msg missing `tutorial` (see learn.list) `tutorial` mangler (se learn.list) @msg unknown tutorial `{id}` (see learn.list) ukendt vejledning `{id}` (se learn.list) + View more Vis mere + Finish tutorial Afslut vejledning + Next Up Næste + You finished the last tutorial in this skill. Du har afsluttet den sidste vejledning i denne færdighed. + Back to skills Tilbage til færdigheder + Open Tutorial Sample Åbn eksempel til vejledning + Finish Tutorial Afslut vejledning + Next Tutorial Næste vejledning + Back to Skills Tilbage til færdigheder +@msg tutorial `{id}` has no practice sample vejledningen `{id}` har intet øvelseseksempel +@msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample) +@msg finish `{id}` first (see learn.finish) afslut først `{id}` (se learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` er den sidste vejledning i sin færdighed # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0d995ddc2e..78e7fe186b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1592,6 +1592,8 @@ file-filter Templates Vorlagen Objects on Path Tool Objekte-auf-Pfad-Werkzeug Attach Objects on Path Objekte an Pfad anhängen Expand Objects on Path Objekte auf Pfad umwandeln + Detach Lösen + Detach Object from Path Objekt vom Pfad lösen Release Live Paint „Interaktiv malen“ zurückwandeln Release Repeat Wiederholung zurückwandeln Release Slice Slice zurückwandeln @@ -2879,6 +2881,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Zeilen auf die Farben der Grafik zurücksetzen Color Theme Picker Farbthema-Auswahl Click or drag across the art to pick its colors; Shift adds more Klicken oder ziehen Sie über die Grafik, um ihre Farben aufzunehmen; Umschalt fügt weitere hinzu + Prominent Colors Hervorstechende Farben + Save All Colors Alle Farben speichern + Save Prominent Colors Hervorstechende Farben speichern Reset to Default Auf Standard zurücksetzen Reset to White, Black Auf Weiß, Schwarz zurücksetzen Reset with Mesh Mit Gitter zurücksetzen @@ -3432,6 +3437,20 @@ effect Points Punkte Spot Color Objects Objekte mit Volltonfarben Pattern Objects Objekte mit Mustern Gradient Objects Objekte mit Verläufen + Font Details Schriftdetails + Linked Images Verknüpfte Bilder + Embedded Images Eingebettete Bilder + Compound Paths Zusammengesetzte Pfade + Text Objects Textobjekte + Placed Documents Platzierte Dokumente + Symbol Instances Symbolinstanzen + Gradient Meshes Verlaufsgitter + Blends Angleichungen + Envelopes Hüllen + Intertwine Groups Verflechtungsgruppen + Opacity Masks Deckkraftmasken + Objects with Effects Objekte mit Effekten + Pattern Swatches Musterfarbfelder Spot Colors Volltonfarben Spot Colors… Volltonfarben… Spread: Ausbreitung: @@ -4074,6 +4093,10 @@ theme Appearance Erscheinungsbild Dock Panel Bedienfeld andocken Dock to Top Oben andocken Dock to Bottom Unten andocken + Dock to Left Links andocken + Dock to Right Rechts andocken + Dock Toolbar Werkzeugleiste andocken + Float Toolbar Werkzeugleiste ablösen Control Bar Options Optionen der Steuerungsleiste Tool Options Werkzeugoptionen Object Options Objektoptionen @@ -4671,6 +4694,7 @@ theme Appearance Erscheinungsbild @msg paths contain invalid coordinates die Pfade enthalten ungültige Koordinaten @msg paths: give at least one file paths: Geben Sie mindestens eine Datei an @msg replace: the object to replace was changed or deleted replace: Das zu ersetzende Objekt wurde geändert oder gelöscht +@msg the document it was prepared for is no longer active das Dokument, für das es vorbereitet wurde, ist nicht mehr aktiv @msg pattern missing das Muster fehlt @msg patterns nested too deeply are filled with mid-grey zu tief verschachtelte Muster werden mit mittlerem Grau gefüllt @msg percent must be above 0 and at most 6400, not {p} percent muss über 0 und höchstens 6400 sein, nicht {p} @@ -4706,6 +4730,7 @@ theme Appearance Erscheinungsbild @msg select a repeat wählen Sie eine Wiederholung aus @msg select an Intertwine group wählen Sie eine Verflechtungsgruppe aus @msg select an Objects on Path group wählen Sie eine Gruppe „Objekte auf Pfad“ aus +@msg choose the object to detach with its Select/Move widget wählen Sie das zu lösende Objekt mit seinem Steuerelement „Auswählen/Verschieben“ aus @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects ein Pfad in einem zusammengesetzten Pfad, einer Angleichung, einer Hülle, einer Wiederholung oder einer Verflechtungsgruppe kann keine Objekte aufnehmen @msg a clipping path can't take objects ein Schnittpfad kann keine Objekte aufnehmen @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path die Objekte in einer Angleichung, einer Hülle, einer Wiederholung oder einer Verflechtungsgruppe können nicht an einen Pfad angehängt werden @@ -5030,6 +5055,7 @@ theme Appearance Erscheinungsbild @msg every numbered name for it is taken in the Fonts folder alle nummerierten Namen dafür sind im Schriftenordner bereits vergeben @msg every numbered name for {name} is taken in the document's folder alle nummerierten Namen für {name} sind im Ordner des Dokuments bereits vergeben @msg No linked files to look for Keine verknüpften Dateien zum Suchen +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Die Verknüpfung befindet sich in einem Symbol, einem Diagrammdesign, einem Muster oder einer Deckkraftmaske und hat keinen Platz auf der Zeichenfläche # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Dienste @@ -5513,7 +5539,9 @@ lighting Light Licht @msg `{GRAPHICS}` face must be front or back face in `{GRAPHICS}` muss front oder back sein @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} in `{GRAPHICS}` muss eine Zahl von {lo} bis {hi} sein @msg `{GRAPHICS}` must be an array `{GRAPHICS}` muss ein Array sein -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiken werden auf ebene Seiten abgebildet: Verwenden Sie den Objekttyp Extrudieren oder Ebene +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafiken werden auf die Objekttypen Ebene, Extrudieren und Kreiseln abgebildet +@msg Revolve maps graphics onto its side: map them onto the front Kreiseln bildet Grafiken auf seiner Mantelfläche ab: Bilden Sie sie auf der Vorderseite ab +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Die Grafiken sind für diese Oberfläche zu komplex: Vereinfachen Sie das Bildmaterial der Symbole oder verringern Sie Segmente @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nimmt höchstens {MAX_GRAPHICS} Grafiken auf @msg A plane has one face: map its graphics onto the front Eine Ebene hat nur eine Seite: Bilden Sie ihre Grafiken auf der Vorderseite ab @msg The graphics are too complex: simplify the symbols' art Die Grafiken sind zu komplex: Vereinfachen Sie das Bildmaterial der Symbole @@ -6124,6 +6152,7 @@ axis Y Axis Y-Achse @msg message must be text message muss Text sein @msg allowContinue must be true or false allowContinue muss true oder false sein @msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen +@msg Batch cannot play an action with a View menu step Die Stapelverarbeitung kann keine Aktion mit einem Schritt aus dem Menü Ansicht abspielen Insert Menu Item… Menübefehl einfügen… Insert Menu Item Menübefehl einfügen Insert Select Path Pfadauswahl einfügen @@ -6307,6 +6336,13 @@ axis Y Axis Y-Achse Area Text Flächentext # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Text an Glyphenbegrenzung messen +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Optionen für „An Pixel ausrichten“ + Pixel Snapping Options… Optionen für „An Pixel ausrichten“… + Snap to Pixel While Drawing Beim Zeichnen an Pixel ausrichten + Snap to Pixel While Moving Beim Verschieben an Pixel ausrichten + Snap to Pixel While Scaling Beim Skalieren an Pixel ausrichten + These options take effect when View › Snap to Pixel is on. Diese Optionen wirken, wenn Ansicht › An Pixel ausrichten aktiviert ist. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Optionen für „An Glyphe ausrichten“ einblenden Hide Snap to Glyph Options Optionen für „An Glyphe ausrichten“ ausblenden @@ -6938,6 +6974,19 @@ breakText Glyphs Glyphen Show where this tool is Zeigen, wo sich dieses Werkzeug befindet @msg missing `tutorial` (see learn.list) `tutorial` fehlt (siehe learn.list) @msg unknown tutorial `{id}` (see learn.list) unbekanntes Lernprogramm `{id}` (siehe learn.list) + View more Mehr anzeigen + Finish tutorial Lernprogramm abschließen + Next Up Als Nächstes + You finished the last tutorial in this skill. Sie haben das letzte Lernprogramm dieser Fertigkeit abgeschlossen. + Back to skills Zurück zu den Fertigkeiten + Open Tutorial Sample Übungsdatei des Lernprogramms öffnen + Finish Tutorial Lernprogramm abschließen + Next Tutorial Nächstes Lernprogramm + Back to Skills Zurück zu den Fertigkeiten +@msg tutorial `{id}` has no practice sample das Lernprogramm `{id}` hat keine Übungsdatei +@msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample) +@msg finish `{id}` first (see learn.finish) schließen Sie zuerst `{id}` ab (siehe learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` ist das letzte Lernprogramm seiner Fertigkeit # Deferred plug-in effects Refresh Deferred Effects Zurückgestellte Effekte aktualisieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cb409e9274..82f265b702 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1512,6 +1512,8 @@ font-technology Variable Μεταβλητές Objects on Path Tool Αντικείμενα σε διαδρομή (Objects on Path Tool) Attach Objects on Path Προσάρτηση αντικειμένων σε διαδρομή Expand Objects on Path Επέκταση αντικειμένων σε διαδρομή + Detach Αποσύνδεση + Detach Object from Path Αποσύνδεση αντικειμένου από διαδρομή Release Live Paint Αποδέσμευση δυναμικής βαφής Release Repeat Αποδέσμευση επανάληψης Release Slice Αποδέσμευση τομής @@ -2257,6 +2259,10 @@ axis Both Και τα δύο Dock Panel Αγκύρωση πάνελ Dock to Top Αγκύρωση επάνω Dock to Bottom Αγκύρωση κάτω + Dock to Left Αγκύρωση αριστερά + Dock to Right Αγκύρωση δεξιά + Dock Toolbar Αγκύρωση γραμμής εργαλείων + Float Toolbar Αιωρούμενη γραμμή εργαλείων Control Bar Options Επιλογές γραμμής ελέγχου Tool Options Επιλογές εργαλείου Object Options Επιλογές αντικειμένου @@ -3598,6 +3604,9 @@ digits Malayalam Μαλαγιάλαμ Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου Color Theme Picker Επιλογέας χρωματικού θέματος Click or drag across the art to pick its colors; Shift adds more Κάντε κλικ ή σύρετε πάνω στο σχέδιο για να πάρετε τα χρώματά του· το Shift προσθέτει κι άλλα + Prominent Colors Κυρίαρχα χρώματα + Save All Colors Αποθήκευση όλων των χρωμάτων + Save Prominent Colors Αποθήκευση κυρίαρχων χρωμάτων Reset to White, Black Επαναφορά σε λευκό και μαύρο Reset with Mesh Επαναφορά με πλέγμα Reset with Mesh… Επαναφορά με πλέγμα… @@ -3966,6 +3975,20 @@ digits Malayalam Μαλαγιάλαμ Spot Color Objects Αντικείμενα με χρώματα spot Pattern Objects Αντικείμενα με μοτίβα Gradient Objects Αντικείμενα με ντεγκραντέ + Font Details Λεπτομέρειες γραμματοσειρών + Linked Images Συνδεδεμένες εικόνες + Embedded Images Ενσωματωμένες εικόνες + Compound Paths Σύνθετες διαδρομές + Text Objects Αντικείμενα κειμένου + Placed Documents Τοποθετημένα έγγραφα + Symbol Instances Στιγμιότυπα συμβόλων + Gradient Meshes Πλέγματα ντεγκραντέ + Blends Αναμείξεις + Envelopes Περιβλήματα + Intertwine Groups Ομάδες διαπλοκής + Opacity Masks Μάσκες αδιαφάνειας + Objects with Effects Αντικείμενα με εφέ + Path Length Μήκος διαδρομής Spot Colors Πλακάτα χρώματα Spot Colors… Πλακάτα χρώματα… Stacked Bar Graph Tool Στοιβαγμένο ραβδόγραμμα (Stacked Bar Graph Tool) @@ -4701,6 +4724,7 @@ digits Malayalam Μαλαγιάλαμ @msg paths contain invalid coordinates οι διαδρομές περιέχουν μη έγκυρες συντεταγμένες @msg paths: give at least one file διαδρομές: δώστε τουλάχιστον ένα αρχείο @msg replace: the object to replace was changed or deleted αντικατάσταση: το αντικείμενο προς αντικατάσταση άλλαξε ή διαγράφηκε +@msg the document it was prepared for is no longer active το έγγραφο για το οποίο προετοιμάστηκε δεν είναι πλέον ενεργό @msg pattern missing λείπει το μοτίβο @msg patterns nested too deeply are filled with mid-grey τα πολύ βαθιά ένθετα μοτίβα γεμίζονται με μέτριο γκρι @msg percent must be above 0 and at most 6400, not {p} το percent πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p} @@ -5054,6 +5078,7 @@ digits Malayalam Μαλαγιάλαμ @msg every numbered name for it is taken in the Fonts folder όλα τα αριθμημένα ονόματά του χρησιμοποιούνται ήδη στον φάκελο γραμματοσειρών @msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου @msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά Hide VectorCraft Απόκρυψη VectorCraft Bring All to Front Μεταφορά όλων στο προσκήνιο Break Link Κατάργηση σύνδεσης @@ -5297,7 +5322,9 @@ digits Malayalam Μαλαγιάλαμ @msg `{GRAPHICS}` face must be front or back το face του `{GRAPHICS}` πρέπει να είναι front ή back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} το {key} του `{GRAPHICS}` πρέπει να είναι αριθμός από {lo} έως {hi} @msg `{GRAPHICS}` must be an array το `{GRAPHICS}` πρέπει να είναι πίνακας -@msg Graphics map onto planar faces: use the Extrude or Plane object type Τα γραφικά αντιστοιχίζονται σε επίπεδες όψεις: χρησιμοποιήστε τον τύπο αντικειμένου Εξώθηση ή Επίπεδο +@msg Graphics map onto the Plane, Extrude and Revolve object types Τα γραφικά αντιστοιχίζονται στους τύπους αντικειμένου Επίπεδο, Εξώθηση και Περιστροφή γύρω από άξονα +@msg Revolve maps graphics onto its side: map them onto the front Η Περιστροφή γύρω από άξονα αντιστοιχίζει τα γραφικά στην πλευρική της επιφάνεια: αντιστοιχίστε τα στην μπροστινή όψη +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Τα γραφικά είναι πολύ σύνθετα για αυτή την επιφάνεια: απλοποιήστε το σχέδιο των συμβόλων ή μειώστε τα Τμήματα @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics το `{GRAPHICS}` δέχεται το πολύ {MAX_GRAPHICS} γραφικά @msg A plane has one face: map its graphics onto the front Ένα επίπεδο έχει μία όψη: αντιστοιχίστε τα γραφικά του στην μπροστινή όψη @msg The graphics are too complex: simplify the symbols' art Τα γραφικά είναι πολύ σύνθετα: απλοποιήστε το σχέδιο των συμβόλων @@ -6016,6 +6043,7 @@ effect Points Σημεία @msg no bound object reports a value (locked objects and embedded images report none) κανένα συνδεδεμένο αντικείμενο δεν αναφέρει τιμή (τα κλειδωμένα αντικείμενα και οι ενσωματωμένες εικόνες δεν αναφέρουν καμία) @msg select an Intertwine group επιλέξτε μια ομάδα διαπλοκής @msg select an Objects on Path group επιλέξτε μια ομάδα αντικειμένων σε διαδρομή +@msg choose the object to detach with its Select/Move widget επιλέξτε το αντικείμενο προς αποσύνδεση με το στοιχείο ελέγχου Επιλογή/Μετακίνηση @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects μια διαδρομή μέσα σε σύνθετη διαδρομή, ανάμειξη, περίβλημα, επανάληψη ή ομάδα διαπλοκής δεν μπορεί να δεχτεί αντικείμενα @msg a clipping path can't take objects μια διαδρομή αποκοπής δεν μπορεί να δεχτεί αντικείμενα @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path τα αντικείμενα μέσα σε ανάμειξη, περίβλημα, επανάληψη ή ομάδα διαπλοκής δεν μπορούν να προσαρτηθούν σε διαδρομή @@ -6484,6 +6512,7 @@ axis Y Axis Άξονας Y @msg message must be text Το message πρέπει να είναι κείμενο @msg allowContinue must be true or false Το allowContinue πρέπει να είναι true ή false @msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή +@msg Batch cannot play an action with a View menu step Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με βήμα από το μενού Προβολή Insert Menu Item… Εισαγωγή στοιχείου μενού… Insert Menu Item Εισαγωγή στοιχείου μενού Insert Select Path Εισαγωγή επιλογής διαδρομής @@ -6667,6 +6696,13 @@ axis Y Axis Άξονας Y Area Text Κείμενο περιοχής # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Μέτρηση κειμένου με βάση τα όρια γλυφής +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Επιλογές προσκόλλησης σε εικονοστοιχείο + Pixel Snapping Options… Επιλογές προσκόλλησης σε εικονοστοιχείο… + Snap to Pixel While Drawing Προσκόλληση σε εικονοστοιχείο κατά τη σχεδίαση + Snap to Pixel While Moving Προσκόλληση σε εικονοστοιχείο κατά τη μετακίνηση + Snap to Pixel While Scaling Προσκόλληση σε εικονοστοιχείο κατά την αλλαγή κλίμακας + These options take effect when View › Snap to Pixel is on. Αυτές οι επιλογές ισχύουν όταν είναι ενεργή η επιλογή Προβολή › Προσκόλληση σε εικονοστοιχείο. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Εμφάνιση επιλογών προσκόλλησης σε γλυφή Hide Snap to Glyph Options Απόκρυψη επιλογών προσκόλλησης σε γλυφή @@ -7295,6 +7331,19 @@ breakText Glyphs Γλύφοι Show where this tool is Εμφάνιση της θέσης αυτού του εργαλείου @msg missing `tutorial` (see learn.list) λείπει το `tutorial` (δείτε learn.list) @msg unknown tutorial `{id}` (see learn.list) άγνωστος οδηγός εκμάθησης `{id}` (δείτε learn.list) + View more Προβολή περισσότερων + Finish tutorial Ολοκλήρωση οδηγού + Next Up Επόμενο + You finished the last tutorial in this skill. Ολοκληρώσατε τον τελευταίο οδηγό εκμάθησης αυτής της δεξιότητας. + Back to skills Πίσω στις δεξιότητες + Open Tutorial Sample Άνοιγμα δείγματος οδηγού εκμάθησης + Finish Tutorial Ολοκλήρωση οδηγού εκμάθησης + Next Tutorial Επόμενος οδηγός εκμάθησης + Back to Skills Πίσω στις δεξιότητες +@msg tutorial `{id}` has no practice sample ο οδηγός εκμάθησης `{id}` δεν έχει δείγμα εξάσκησης +@msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample) +@msg finish `{id}` first (see learn.finish) ολοκληρώστε πρώτα το `{id}` (δείτε learn.finish) +@msg `{id}` is the last tutorial in its skill το `{id}` είναι ο τελευταίος οδηγός εκμάθησης της δεξιότητάς του # Deferred plug-in effects Refresh Deferred Effects Ανανέωση εφέ σε αναμονή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index de9512cddb..e0aaffde87 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1592,6 +1592,8 @@ font-technology Variable Variables Objects on Path Tool Herramienta Objetos en trazado Attach Objects on Path Adjuntar objetos al trazado Expand Objects on Path Expandir objetos en trazado + Detach Separar + Detach Object from Path Separar objeto del trazado Release Live Paint Soltar pintura interactiva Release Repeat Soltar repetición Release Slice Soltar sector @@ -2912,6 +2914,9 @@ digits Malayalam Malayálam Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración Color Theme Picker Selector de tema de color Click or drag across the art to pick its colors; Shift adds more Haga clic o arrastre sobre la ilustración para tomar sus colores; Mayús añade más + Prominent Colors Colores destacados + Save All Colors Guardar todos los colores + Save Prominent Colors Guardar colores destacados Reset to Default Restablecer valores predeterminados Reset to White, Black Restablecer a blanco, negro Reset with Mesh Restablecer con malla @@ -3462,6 +3467,19 @@ digits Malayalam Malayálam Spot Color Objects Objetos con tintas planas Pattern Objects Objetos con motivos Gradient Objects Objetos con degradados + Font Details Detalles de fuentes + Linked Images Imágenes vinculadas + Embedded Images Imágenes incrustadas + Compound Paths Trazados compuestos + Text Objects Objetos de texto + Placed Documents Documentos colocados + Symbol Instances Instancias de símbolo + Gradient Meshes Mallas de degradado + Blends Fusiones + Envelopes Envolventes + Intertwine Groups Grupos entrelazados + Opacity Masks Máscaras de opacidad + Objects with Effects Objetos con efectos Spot Colors Tintas planas Spot Colors… Tintas planas… Spread: Extensión: @@ -4152,6 +4170,10 @@ axis Both Ambos Dock Panel Anclar panel Dock to Top Anclar arriba Dock to Bottom Anclar abajo + Dock to Left Anclar a la izquierda + Dock to Right Anclar a la derecha + Dock Toolbar Anclar barra de herramientas + Float Toolbar Hacer flotar la barra de herramientas Control Bar Options Opciones de la barra de control Tool Options Opciones de herramienta Object Options Opciones de objeto @@ -4748,6 +4770,7 @@ axis Both Ambos @msg paths contain invalid coordinates los trazados contienen coordenadas no válidas @msg paths: give at least one file paths: indica al menos un archivo @msg replace: the object to replace was changed or deleted replace: se modificó o eliminó el objeto que se iba a reemplazar +@msg the document it was prepared for is no longer active el documento para el que se preparó ya no está activo @msg pattern missing falta el motivo @msg patterns nested too deeply are filled with mid-grey los motivos anidados a demasiada profundidad se rellenan de gris medio @msg percent must be above 0 and at most 6400, not {p} percent debe ser mayor que 0 y como máximo 6400, no {p} @@ -4783,6 +4806,7 @@ axis Both Ambos @msg select a repeat selecciona una repetición @msg select an Intertwine group selecciona un grupo entrelazado @msg select an Objects on Path group selecciona un grupo de objetos en trazado +@msg choose the object to detach with its Select/Move widget elige el objeto que quieres separar con su control Seleccionar/Mover @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un trazado dentro de un trazado compuesto, una fusión, una envolvente, una repetición o un grupo entrelazado no puede recibir objetos @msg a clipping path can't take objects un trazado de recorte no puede recibir objetos @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path los objetos dentro de una fusión, una envolvente, una repetición o un grupo entrelazado no se pueden adjuntar a un trazado @@ -5107,6 +5131,7 @@ axis Both Ambos @msg every numbered name for it is taken in the Fonts folder todos los nombres numerados para él ya existen en la carpeta Fonts @msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento @msg No linked files to look for No hay archivos vinculados que buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios @@ -5879,7 +5904,10 @@ mark-style Japanese Japonesas @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` debe ser front o back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` debe ser un número de {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` debe ser una matriz -@msg Graphics map onto planar faces: use the Extrude or Plane object type Los gráficos se asignan a caras planas: use el tipo de objeto Extrusión o Plano +@msg Graphics map onto the Plane, Extrude and Revolve object types Los gráficos se asignan a los tipos de objeto Plano, Extrusión y Revolución + Graphics map onto the Plane, Extrude and Revolve object types. Los gráficos se asignan a los tipos de objeto Plano, Extrusión y Revolución. +@msg Revolve maps graphics onto its side: map them onto the front Revolución asigna los gráficos a su lateral: asígnelos a la cara frontal +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Los gráficos son demasiado complejos para esta superficie: simplifique la ilustración de los símbolos o reduzca Segmentos @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admite como máximo {MAX_GRAPHICS} gráficos @msg A plane has one face: map its graphics onto the front Un plano tiene una sola cara: asigne sus gráficos a la cara frontal @msg The graphics are too complex: simplify the symbols' art Los gráficos son demasiado complejos: simplifique la ilustración de los símbolos @@ -6475,6 +6503,7 @@ axis Y Axis Eje Y @msg message must be text message debe ser texto @msg allowContinue must be true or false allowContinue debe ser true o false @msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada +@msg Batch cannot play an action with a View menu step Lote no puede reproducir una acción con un paso del menú Ver Insert Menu Item… Insertar elemento de menú… Insert Menu Item Insertar elemento de menú Insert Select Path Insertar selección de trazado @@ -6658,6 +6687,13 @@ axis Y Axis Eje Y Area Text Texto de área # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Medir el texto por los límites del glifo +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opciones de ajuste a píxel + Pixel Snapping Options… Opciones de ajuste a píxel… + Snap to Pixel While Drawing Ajustar a píxel al dibujar + Snap to Pixel While Moving Ajustar a píxel al mover + Snap to Pixel While Scaling Ajustar a píxel al escalar + These options take effect when View › Snap to Pixel is on. Estas opciones se aplican cuando Ver › Ajustar a píxel está activado. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar opciones de ajuste a glifo Hide Snap to Glyph Options Ocultar opciones de ajuste a glifo @@ -7286,6 +7322,19 @@ breakText Glyphs Glifos Show where this tool is Mostrar dónde está esta herramienta @msg missing `tutorial` (see learn.list) falta `tutorial` (consulta learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconocido `{id}` (consulta learn.list) + View more Ver más + Finish tutorial Terminar tutorial + Next Up A continuación + You finished the last tutorial in this skill. Has terminado el último tutorial de esta habilidad. + Back to skills Volver a las habilidades + Open Tutorial Sample Abrir muestra del tutorial + Finish Tutorial Terminar tutorial + Next Tutorial Tutorial siguiente + Back to Skills Volver a las habilidades +@msg tutorial `{id}` has no practice sample el tutorial `{id}` no tiene muestra de práctica +@msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample) +@msg finish `{id}` first (see learn.finish) termina primero `{id}` (consulta learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` es el último tutorial de su habilidad # Deferred plug-in effects Refresh Deferred Effects Actualizar efectos aplazados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1c3327fb1e..1ea537adbe 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -911,6 +911,10 @@ Dock Panel Telakoi paneeli Dock to Top Telakoi ylös Dock to Bottom Telakoi alas + Dock to Left Telakoi vasemmalle + Dock to Right Telakoi oikealle + Dock Toolbar Telakoi työkalurivi + Float Toolbar Kelluta työkalurivi Control Bar Options Ohjauspalkin asetukset Tool Options Työkalun asetukset Object Options Objektin asetukset @@ -1813,6 +1817,8 @@ digits Malayalam Malajalam Objects on Path Tool Objektit polulla -työkalu Attach Objects on Path Kiinnitä objektit polulle Expand Objects on Path Laajenna objektit polulla + Detach Irrota + Detach Object from Path Irrota objekti polulta Release Live Paint Vapauta elävä maalaus Release Repeat Vapauta toisto Release Slice Vapauta siivu @@ -2835,6 +2841,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Palauta rivit kuvituksen väreihin Color Theme Picker Väriteeman valitsin Click or drag across the art to pick its colors; Shift adds more Napsauta tai vedä kuvituksen yli poimiaksesi sen värit; Vaihto lisää useampia + Prominent Colors Hallitsevat värit + Save All Colors Tallenna kaikki värit + Save Prominent Colors Tallenna hallitsevat värit Separate colors into rows Erottele värit riveille Unlink harmony colors Poista harmoniavärien linkitys 1 Color Job 1 värin työ @@ -3936,6 +3945,19 @@ theme Appearance Ulkoasu Spot Color Objects Objektit, joissa on spottivärejä Pattern Objects Objektit, joissa on kuvioita Gradient Objects Objektit, joissa on liukuvärejä + Font Details Fonttien tiedot + Linked Images Linkitetyt kuvat + Embedded Images Upotetut kuvat + Compound Paths Yhdistelmäpolut + Text Objects Tekstiobjektit + Placed Documents Sijoitetut asiakirjat + Symbol Instances Symbolien ilmentymät + Gradient Meshes Liukuväriverkot + Blends Sulautukset + Envelopes Vääristyskuoret + Intertwine Groups Lomitusryhmät + Opacity Masks Peittävyysmaskit + Objects with Effects Objektit, joissa on tehosteita Spot Colors Spottivärit Spot Colors (0) Spottivärit (0) Swatch Options Värimallin asetukset @@ -4962,6 +4984,7 @@ imageTrace Outline Ääriviiva @msg paths contain invalid coordinates polut sisältävät virheellisiä koordinaatteja @msg paths: give at least one file paths: anna vähintään yksi tiedosto @msg replace: the object to replace was changed or deleted replace: korvattavaa objektia on muutettu tai se on poistettu +@msg the document it was prepared for is no longer active asiakirja, jota varten se valmisteltiin, ei ole enää aktiivinen @msg pattern missing kuvio puuttuu @msg patterns nested too deeply are filled with mid-grey liian syvälle sisäkkäiset kuviot täytetään keskiharmaalla @msg percent must be above 0 and at most 6400, not {p} percent-arvon on oltava yli 0 ja enintään 6400, ei {p} @@ -5315,6 +5338,7 @@ imageTrace Outline Ääriviiva @msg every numbered name for it is taken in the Fonts folder kaikki sen numeroidut nimet ovat jo käytössä Fonts-kansiossa @msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} @msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} @@ -5820,7 +5844,9 @@ mark-style Japanese Japanilainen @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-kohteen face-arvon on oltava front tai back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-kohteen {key}-arvon on oltava luku, joka on vähintään {lo} ja enintään {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` on oltava taulukko -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiikat sijoitetaan tasaisille pinnoille: käytä objektityyppiä Pursota tai Taso +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafiikat sijoitetaan objektityypeille Taso, Pursota ja Pyöräytä +@msg Revolve maps graphics onto its side: map them onto the front Pyöräytä sijoittaa grafiikat kylkeensä: sijoita ne etupuolelle +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafiikat ovat liian monimutkaisia tälle pinnalle: yksinkertaista symbolien kuvitusta tai pienennä Segmentit-arvoa @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` hyväksyy enintään {MAX_GRAPHICS} grafiikkaa @msg A plane has one face: map its graphics onto the front Tasolla on yksi puoli: sijoita sen grafiikat etupuolelle @msg The graphics are too complex: simplify the symbols' art Grafiikat ovat liian monimutkaisia: yksinkertaista symbolien kuvitusta @@ -6028,6 +6054,7 @@ mark-style Japanese Japanilainen @msg no bound object reports a value (locked objects and embedded images report none) mikään sidottu objekti ei ilmoita arvoa (lukitut objektit ja upotetut kuvat eivät ilmoita arvoa) @msg select an Intertwine group valitse lomitusryhmä @msg select an Objects on Path group valitse Objektit polulla -ryhmä +@msg choose the object to detach with its Select/Move widget valitse irrotettava objekti sen Valitse/Siirrä-säätimellä @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects yhdistelmäpolun, sulautuksen, vääristyskuoren, toiston tai lomitusryhmän sisällä olevaan polkuun ei voi kiinnittää objekteja @msg a clipping path can't take objects leikkauspolkuun ei voi kiinnittää objekteja @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path sulautuksen, vääristyskuoren, toiston tai lomitusryhmän sisällä olevia objekteja ei voi kiinnittää polulle @@ -6563,6 +6590,7 @@ axis Y Axis Y-akseli @msg message must be text message on oltava tekstiä @msg allowContinue must be true or false allowContinue on oltava true tai false @msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys +@msg Batch cannot play an action with a View menu step Erä ei voi toistaa toimintoa, jossa on Näytä-valikon vaihe Insert Menu Item… Lisää valikkokohta… Insert Menu Item Lisää valikkokohta Insert Select Path Lisää polun valinta @@ -6746,6 +6774,13 @@ axis Y Axis Y-akseli Area Text Alueteksti # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mittaa teksti merkin rajoista +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Pikseliin kohdistuksen asetukset + Pixel Snapping Options… Pikseliin kohdistuksen asetukset… + Snap to Pixel While Drawing Kohdista pikseliin piirrettäessä + Snap to Pixel While Moving Kohdista pikseliin siirrettäessä + Snap to Pixel While Scaling Kohdista pikseliin skaalattaessa + These options take effect when View › Snap to Pixel is on. Nämä asetukset ovat voimassa, kun Näytä › Kohdista pikseliin on käytössä. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Näytä merkkiin kohdistuksen asetukset Hide Snap to Glyph Options Piilota merkkiin kohdistuksen asetukset @@ -7378,6 +7413,19 @@ breakText Glyphs Merkit Show where this tool is Näytä, missä tämä työkalu on @msg missing `tutorial` (see learn.list) `tutorial` puuttuu (ks. learn.list) @msg unknown tutorial `{id}` (see learn.list) tuntematon opetusohjelma `{id}` (ks. learn.list) + View more Näytä lisää + Finish tutorial Viimeistele opetusohjelma + Next Up Seuraavaksi + You finished the last tutorial in this skill. Viimeistelit tämän taidon viimeisen opetusohjelman. + Back to skills Takaisin taitoihin + Open Tutorial Sample Avaa opetusohjelman harjoitustiedosto + Finish Tutorial Viimeistele opetusohjelma + Next Tutorial Seuraava opetusohjelma + Back to Skills Takaisin taitoihin +@msg tutorial `{id}` has no practice sample opetusohjelmalla `{id}` ei ole harjoitustiedostoa +@msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample) +@msg finish `{id}` first (see learn.finish) viimeistele ensin `{id}` (ks. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` on taitonsa viimeinen opetusohjelma # Deferred plug-in effects Refresh Deferred Effects Päivitä lykätyt tehosteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3b45c4d386..96d6f7dd13 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1586,6 +1586,8 @@ font-technology Variable Variables Objects on Path Tool Outil Objets sur tracé Attach Objects on Path Attacher des objets au tracé Expand Objects on Path Décomposer les objets sur tracé + Detach Détacher + Detach Object from Path Détacher l’objet du tracé Release Live Paint Annuler la peinture dynamique Release Repeat Annuler la répétition Release Slice Annuler la tranche @@ -2904,6 +2906,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration Color Theme Picker Sélecteur de thème de couleurs Click or drag across the art to pick its colors; Shift adds more Cliquez ou faites glisser sur l’illustration pour prélever ses couleurs ; Maj en ajoute d’autres + Prominent Colors Couleurs dominantes + Save All Colors Enregistrer toutes les couleurs + Save Prominent Colors Enregistrer les couleurs dominantes Reset to Default Rétablir les valeurs par défaut Reset to White, Black Réinitialiser à blanc, noir Reset with Mesh Réinitialiser avec un filet @@ -3454,6 +3459,19 @@ digits Malayalam Malayalam Spot Color Objects Objets avec tons directs Pattern Objects Objets avec motifs Gradient Objects Objets avec dégradés + Font Details Détails des polices + Linked Images Images liées + Embedded Images Images incorporées + Compound Paths Tracés transparents + Text Objects Objets texte + Placed Documents Documents importés + Symbol Instances Instances de symbole + Gradient Meshes Filets de dégradé + Blends Dégradés de formes + Envelopes Enveloppes + Intertwine Groups Groupes entrelacés + Opacity Masks Masques d’opacité + Objects with Effects Objets avec effets Spot Colors Tons directs Spot Colors… Tons directs… Spread: Étendue : @@ -4141,6 +4159,10 @@ axis Both Les deux Dock Panel Ancrer le panneau Dock to Top Ancrer en haut Dock to Bottom Ancrer en bas + Dock to Left Ancrer à gauche + Dock to Right Ancrer à droite + Dock Toolbar Ancrer la barre d’outils + Float Toolbar Rendre la barre d’outils flottante Control Bar Options Options de la barre de contrôle Tool Options Options de l’outil Object Options Options de l’objet @@ -4737,6 +4759,7 @@ axis Both Les deux @msg paths contain invalid coordinates les tracés contiennent des coordonnées non valides @msg paths: give at least one file paths : indiquez au moins un fichier @msg replace: the object to replace was changed or deleted replace : l’objet à remplacer a été modifié ou supprimé +@msg the document it was prepared for is no longer active le document pour lequel il a été préparé n’est plus actif @msg pattern missing motif manquant @msg patterns nested too deeply are filled with mid-grey les motifs imbriqués trop profondément sont remplis de gris moyen @msg percent must be above 0 and at most 6400, not {p} percent doit être supérieur à 0 et au plus égal à 6400, pas {p} @@ -4772,6 +4795,7 @@ axis Both Les deux @msg select a repeat sélectionnez une répétition @msg select an Intertwine group sélectionnez un groupe entrelacé @msg select an Objects on Path group sélectionnez un groupe d’objets sur tracé +@msg choose the object to detach with its Select/Move widget choisissez l’objet à détacher avec sa commande Sélectionner/Déplacer @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un tracé à l’intérieur d’un tracé transparent, d’un dégradé de formes, d’une enveloppe, d’une répétition ou d’un groupe entrelacé ne peut pas recevoir d’objets @msg a clipping path can't take objects un tracé d’écrêtage ne peut pas recevoir d’objets @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path les objets à l’intérieur d’un dégradé de formes, d’une enveloppe, d’une répétition ou d’un groupe entrelacé ne peuvent pas être attachés à un tracé @@ -5094,6 +5118,7 @@ axis Both Les deux @msg every numbered name for it is taken in the Fonts folder tous les noms numérotés pour ce fichier sont déjà pris dans le dossier Fonts @msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document @msg No linked files to look for Aucun fichier lié à rechercher +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services @@ -5508,7 +5533,9 @@ lighting Light Lumière @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` doit valoir front ou back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` doit être un nombre de {lo} à {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` doit être un tableau -@msg Graphics map onto planar faces: use the Extrude or Plane object type Les graphiques s'appliquent sur des faces planes : utilisez le type d'objet Extrusion ou Plan +@msg Graphics map onto the Plane, Extrude and Revolve object types Les graphiques s'appliquent aux types d'objet Plan, Extrusion et Révolution +@msg Revolve maps graphics onto its side: map them onto the front Révolution applique les graphiques sur sa face latérale : appliquez-les sur la face avant +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Les graphiques sont trop complexes pour cette surface : simplifiez l'illustration des symboles ou réduisez Segments @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accepte au plus {MAX_GRAPHICS} graphiques @msg A plane has one face: map its graphics onto the front Un plan n'a qu'une face : appliquez ses graphiques sur la face avant @msg The graphics are too complex: simplify the symbols' art Les graphiques sont trop complexes : simplifiez l'illustration des symboles @@ -6528,6 +6555,7 @@ axis Y Axis Axe Y @msg message must be text message doit être du texte @msg allowContinue must be true or false allowContinue doit valoir true ou false @msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt +@msg Batch cannot play an action with a View menu step Le traitement par lots ne peut pas exécuter un script comportant une étape du menu Affichage Insert Menu Item… Insérer une commande de menu… Insert Menu Item Insérer une commande de menu Insert Select Path Insérer une sélection de tracé @@ -6711,6 +6739,13 @@ axis Y Axis Axe Y Area Text Texte captif # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mesurer le texte d’après les limites du glyphe +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Options de magnétisme du pixel + Pixel Snapping Options… Options de magnétisme du pixel… + Snap to Pixel While Drawing Magnétisme du pixel lors du dessin + Snap to Pixel While Moving Magnétisme du pixel lors du déplacement + Snap to Pixel While Scaling Magnétisme du pixel lors de la mise à l’échelle + These options take effect when View › Snap to Pixel is on. Ces options s’appliquent lorsque Affichage › Magnétisme du pixel est activé. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Afficher les options de magnétisme du glyphe Hide Snap to Glyph Options Masquer les options de magnétisme du glyphe @@ -7338,6 +7373,19 @@ breakText Glyphs Glyphes Show where this tool is Afficher l'emplacement de cet outil @msg missing `tutorial` (see learn.list) `tutorial` manquant (voir learn.list) @msg unknown tutorial `{id}` (see learn.list) tutoriel inconnu `{id}` (voir learn.list) + View more Voir plus + Finish tutorial Terminer le tutoriel + Next Up À suivre + You finished the last tutorial in this skill. Vous avez terminé le dernier tutoriel de cette compétence. + Back to skills Retour aux compétences + Open Tutorial Sample Ouvrir le fichier d’exercice du tutoriel + Finish Tutorial Terminer le tutoriel + Next Tutorial Tutoriel suivant + Back to Skills Retour aux compétences +@msg tutorial `{id}` has no practice sample le tutoriel `{id}` n’a pas de fichier d’exercice +@msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample) +@msg finish `{id}` first (see learn.finish) terminez d’abord `{id}` (voir learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` est le dernier tutoriel de sa compétence # Deferred plug-in effects Refresh Deferred Effects Actualiser les effets différés diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0c6089e40a..b9401b2b77 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -754,7 +754,7 @@ antiAlias Sharp शार्प Changed since the last check. आखिरी चेक के बाद से बदल गया। Changed since the last Refresh. आखिरी रिफ्रेश के बाद से बदल गया। Changes allowed: परिवर्तन की अनुमति: - Changes made since it was last saved will be lost. क्योंकि यह पिछले बचाया गया था परिवर्तन खो दिया जाएगा। + Changes made since it was last saved will be lost. पिछली बार सेव करने के बाद किए गए बदलाव खो जाएँगे। Changes the reference app's preferences संदर्भ ऐप की वरीयताओं को बदलता है Changing these settings changes how raster effects look when exported, flattened or expanded. ये सेटिंग्स बदलने से एक्सपोर्ट, फ़्लैटन या एक्सपैंड किए गए रास्टर इफ़ेक्ट्स का रूप बदलता है। Channel चैनल @@ -776,7 +776,7 @@ antiAlias Sharp शार्प Character: कैरेक्टर: Charcoal चारकोल Charcoal… चारकोल… - Check an artboard to save. बचाने के लिए एक आर्टबोर्ड की जाँच करें। + Check an artboard to save. सेव करने के लिए किसी आर्टबोर्ड पर चेक लगाएँ। Check Document डॉक्यूमेंट की जाँच करें Check for Updates… अपडेट जाँचें… Check Links चेक लिंक @@ -818,9 +818,9 @@ antiAlias Sharp शार्प Click or drag क्लिक करें या खींचें Click the art with the Gradient tool to add or select points. पॉइंट जोड़ने या सिलेक्ट करने के लिए ग्रेडिएंट टूल से आर्टवर्क पर क्लिक करें। Click the first point पहले बिंदु पर क्लिक करें - Click the ramp or a type button to apply a gradient. एक ग्रेडिएंट लगाने के लिए रैंप या एक प्रकार का बटन पर क्लिक करें। + Click the ramp or a type button to apply a gradient. ग्रेडिएंट लगाने के लिए रैंप या किसी प्रकार बटन पर क्लिक करें। Click to add a tab stop, drag to move it, drag it off the ruler to delete it टैब स्टॉप जोड़ने के लिए क्लिक करें, खिसकाने के लिए ड्रैग करें, हटाने के लिए रूलर से बाहर ड्रैग करें - Click to apply to the selected type सिलेक्टेड प्रकार पर लागू करने के लिए क्लिक करें + Click to apply to the selected type सिलेक्टेड टाइप पर लागू करने के लिए क्लिक करें Click to choose a swatch, Shift-click to mix a colour स्वॉच चुनने के लिए क्लिक करें, रंग मिलाने के लिए Shift-क्लिक करें Click to edit संपादित करने के लिए क्लिक करें Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it मास्क एडिट करने के लिए क्लिक करें; केवल मास्क देखने के लिए Alt-क्लिक करें; उसे अक्षम या सक्षम करने के लिए Shift-क्लिक करें @@ -858,7 +858,7 @@ antiAlias Sharp शार्प Close paths when ends are within: जब समाप्त होता है तो बंद पाथ भीतर होते हैं: Close the circular ends of open profiles. Partial sweeps keep their cut sides open. खुली प्रोफ़ाइल के गोल सिरे बंद करें। आंशिक स्वीप की कटी हुई साइड खुली रहती हैं। Closed बंद - Closing bracket with no opening one कोई ओपन करने के साथ समापन ब्रैकेट + Closing bracket with no opening one बंद करने वाला ब्रैकेट, जिसका कोई खोलने वाला ब्रैकेट नहीं है Clumped गुच्छेदार Cmd Cmd CMYK CMYK @@ -1069,7 +1069,7 @@ antiAlias Sharp शार्प Couldn't {} {name}: {e} {name} को {} नहीं किया जा सका: {e} Couldn't copy to the system clipboard: {e} सिस्टम क्लिपबोर्ड को कॉपी नहीं कर सका: {e} Couldn't paste from the clipboard: {e} क्लिपबोर्ड से पेस्ट नहीं कर सका: {e} - Couldn't save recovery data: {e} वसूली डेटा नहीं बचा सकता: {e} + Couldn't save recovery data: {e} रिकवरी डेटा सेव नहीं किया जा सका: {e} Count गिनती Count: गिनती: Cove कोव @@ -1119,7 +1119,7 @@ antiAlias Sharp शार्प CRLF (Windows) CRLF (Windows) Croatian क्रोएशियाई Crop क्रॉप करें - Crop Image फसल की इमेज + Crop Image इमेज क्रॉप करें Image Tools इमेज टूल Remove Background… पृष्ठभूमि हटाएँ… Remove Background पृष्ठभूमि हटाएँ @@ -1134,7 +1134,7 @@ antiAlias Sharp शार्प Remove Color Mask रंग मास्क हटाएँ Apply Color Mask रंग मास्क लागू करें Crop Marks क्रॉप मार्क - Crop To करने के लिए फसल + Crop To इसके अनुसार क्रॉप करें Cross क्रॉस Crosshatch क्रॉसहैच Crosshatch… क्रॉसहैच… @@ -1326,7 +1326,7 @@ antiAlias Sharp शार्प Diffusion डिफ़्यूज़न Digits अंक Dim Copies to: प्रतियाँ इतनी मंद करें: - Dim Images to: करने के लिए इमेज: + Dim Images to: इमेज इतनी मंद करें: Dimension आयाम Dimension Lines आयाम लाइन Dimension Text आयाम टेक्स्ट @@ -1343,7 +1343,7 @@ antiAlias Sharp शार्प Disable Auto Add/Delete ऑटो जोड़ना/हटाना अक्षम करें Disable Opacity Mask ओपेसिटी मास्क अक्षम करें Discard डिस्कर्ड - Discard Empty Type डिस्कर्ड खाली प्रकार + Discard Empty Type खाली टाइप हटाएँ Discard Recovered Documents रिकवर किए डॉक्यूमेंट डिस्कार्ड करें Discard White Overprint in Output आउटपुट में अलार्म व्हाइट ओवरप्रिंट Discord Discord @@ -1386,6 +1386,10 @@ antiAlias Sharp शार्प Dock Panel पैनल डॉक करें Dock to Top ऊपर डॉक करें Dock to Bottom नीचे डॉक करें + Dock to Left बाईं ओर डॉक करें + Dock to Right दाईं ओर डॉक करें + Dock Toolbar टूलबार डॉक करें + Float Toolbar टूलबार फ़्लोट करें Control Bar Options नियंत्रण बार ऑप्शन्स Tool Options टूल ऑप्शन्स Object Options ऑब्जेक्ट ऑप्शन्स @@ -1410,7 +1414,7 @@ antiAlias Sharp शार्प Document Raster Effects Settings… डॉक्यूमेंट रास्टर इफ़ेक्ट्स सेटिंग्स… Document Setup डॉक्यूमेंट सेटअप Document Setup… डॉक्यूमेंट सेटअप… - Document Swatches डॉक्यूमेंट Swatch + Document Swatches डॉक्यूमेंट स्वॉचेस Document Title: डॉक्यूमेंट शीर्षक: Document Tools डॉक्यूमेंट टूल Documents you create appear here. डॉक्यूमेंट जो आप यहाँ दिखाई देते हैं। @@ -1536,7 +1540,7 @@ antiAlias Sharp शार्प Ease In Out बाहर में आसानी Ease Out ईज़ आउट Ease Out In ईज़ आउट इन - East Asian Type पूर्वी एशियाई प्रकार + East Asian Type पूर्वी एशियाई टाइप Edge किनारा Edge Brightness किनारे की ब्राइटनेस Edge Fidelity किनारे की सटीकता @@ -1706,6 +1710,8 @@ antiAlias Sharp शार्प Expand Live Paint लाइव पेंट एक्सपैंड करें Expand Object ऑब्जेक्ट का विस्तार Expand Objects on Path पाथ पर ऑब्जेक्ट्स एक्सपैंड करें + Detach अलग करें + Detach Object from Path पाथ से ऑब्जेक्ट अलग करें Expand Options ऑप्शन्स एक्सपैंड करें Expand Panels पैनलों का विस्तार Expand Repeat रिपीट एक्सपैंड करें @@ -1841,7 +1847,7 @@ antiAlias Sharp शार्प Fill Grid ग्रिड भरें Fill Line लाइन भरें Fill new strokes नए स्ट्रोक भरें - Fill New Type Objects With Placeholder Text प्लेसहोल्डर टेक्स्ट के साथ नए प्रकार के ऑब्जेक्ट भरें + Fill New Type Objects With Placeholder Text नए टाइप ऑब्जेक्ट्स में प्लेसहोल्डर टेक्स्ट भरें Fill Pattern पैटर्न भरें Fill Rule नियम भरें Fill with Placeholder Text प्लेसहोल्डर टेक्स्ट से भरें @@ -2217,8 +2223,8 @@ antiAlias Sharp शार्प High Fidelity Photo उच्च निष्ठा फोटो High Performance (discrete) उच्च निष्पादन (डिस्क्रीट) High Quality Print उच्च गुणवत्ता वाले प्रिंट - High resolution उच्च संकल्प - High Resolution उच्च संकल्प + High resolution उच्च रिज़ॉल्यूशन + High Resolution उच्च रिज़ॉल्यूशन Highlight हाइलाइट Highlight Alternate Glyphs हाइलाइट अल्टरनेटर ग्लिफ़ Highlight anchors on mouse over माउस ओवर पर हाइलाइट एंकर @@ -2427,11 +2433,11 @@ antiAlias Sharp शार्प Justification जस्टिफ़िकेशन Justification… जस्टिफ़िकेशन… Justify जस्टिफ़ाई करें - Justify all lines सभी लाइनों को जस्ट करें + Justify all lines सभी लाइनें जस्टिफ़ाई करें Justify Vertically वर्टिकल दिशा में जस्टिफ़ाई करें - Justify with last line aligned center अंतिम पंक्ति संरेखित केंद्र के साथ Justify + Justify with last line aligned center आखिरी लाइन बीच में अलाइन करके जस्टिफ़ाई करें Justify with last line aligned left आखिरी लाइन बाएँ अलाइन करके जस्टिफ़ाई करें - Justify with last line aligned right अंतिम पंक्ति के साथ Justify सही गठबंधन + Justify with last line aligned right आखिरी लाइन दाएँ अलाइन करके जस्टिफ़ाई करें Kaku #2 काकू #2 Kaku #3 काकू #3 Kannada कन्नड़ @@ -2445,7 +2451,7 @@ antiAlias Sharp शार्प Keep Plain Text प्लेन टेक्स्ट रखें Keep selected सिलेक्ट करें Keep Shared Points Together साझा अंक एक साथ रखें - Keep the pasted color as a new swatch with a numbered name. पेस्ट किए गए रंग को एक नए swatch के रूप में रखें। + Keep the pasted color as a new swatch with a numbered name. पेस्ट किए गए रंग को क्रमांकित नाम वाले नए स्वॉच के रूप में रखें। Keep this row's colors (exclude) इस पंक्ति के रंग (बाहर) रखें Keep visible surfaces only when expanding विस्तार करते समय केवल दृश्यमान सतहों को रखें Keep: ध्यान रखें: @@ -2597,7 +2603,7 @@ antiAlias Sharp शार्प Lines +/− पंक्तियाँ +/− Lines added जोड़ी गई पंक्तियाँ Lines deleted हटाई गई पंक्तियाँ - Lines: clicks add points joined by a line लाइन्स: क्लिक्स ऐड पॉइंट्स एक लाइन से जुड़ गए + Lines: clicks add points joined by a line लाइन्स: क्लिक से पॉइंट जुड़ते हैं, जिन्हें एक लाइन जोड़ती है Link लिंक करें Link Block Series लिंक ब्लॉक श्रृंखला Link harmony colors लिंक सद्भाव रंग @@ -2679,8 +2685,8 @@ antiAlias Sharp शार्प Lossy: हानि: Low निम्न Low Fidelity Photo लो फिडेलिटी फोटो - Low Resolution कम संकल्प - Low resolution (150 ppi) कम संकल्प (150 ppi) + Low Resolution कम रिज़ॉल्यूशन + Low resolution (150 ppi) कम रिज़ॉल्यूशन (150 ppi) lowercase लोअरकेस Luminosity ल्यूमिनोसिटी LZW Compression LZW संपीड़न @@ -2692,7 +2698,7 @@ antiAlias Sharp शार्प Magic Wand Tool मैजिक वैंड टूल Maintain Envelope Shape लिफाफे आकार को बनाए रखें Make बनाएँ - Make a theme on Create and save it. Create पर थीम बनाकर सेव करें। + Make a theme on Create and save it. बनाएँ टैब पर थीम बनाकर उसे सेव करें। Make all bleed settings the same सभी ब्लीड सेटिंग्स एक जैसी करें Make and Expand बनाएँ और एक्सपैंड करें Make Blend ब्लेंड बनाएँ @@ -2765,7 +2771,7 @@ antiAlias Sharp शार्प Medium Gray मध्यम ग्रे Medium Light मध्यम प्रकाश Medium Lines मध्यम रेखाएँ - Medium Resolution मध्यम संकल्प + Medium Resolution मध्यम रिज़ॉल्यूशन Medium Strokes मध्यम स्ट्रोक Medium Thumbnail View मध्यम थंबनेल Medium Thumbnails मध्यम थंबनेल @@ -3042,8 +3048,8 @@ antiAlias Sharp शार्प No options कोई ऑप्शन्स No printer settings to open here यहाँ ओपन करने के लिए कोई प्रिंटर सेटिंग नहीं No results कोई परिणाम नहीं - No saved themes कोई बचाया विषयों - No saved views (View → New View…) कोई बचाया दृष्टिकोण (देखें → नया दृश्य) + No saved themes कोई सेव की गई थीम नहीं + No saved views (View → New View…) कोई सेव किया गया व्यू नहीं (व्यू → नया दृश्य…) No selection कोई सिलेक्शन नहीं No Selection कोई सिलेक्शन नहीं No Slices कोई स्लाइस नहीं @@ -3339,7 +3345,7 @@ antiAlias Sharp शार्प Paragraph: पैराग्राफ़: Parametric Shape पैरामीट्रिक आकार Parametric Shape Tool पैरामीट्रिक आकार टूल - Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). प्रतीक: Lucide (ISC) + VectorCraft. + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft का हिस्सा। MIT OR Apache-2.0. फ़ॉन्ट: Source Sans 3, Inter, JetBrains Mono (OFL). आइकन: Lucide (ISC) + VectorCraft. Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft का हिस्सा। MIT OR Apache-2.0। फ़ॉन्ट: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL)। आइकन: Lucide (ISC) + VectorCraft। Password पासवर्ड Paste पेस्ट करें @@ -3377,7 +3383,7 @@ antiAlias Sharp शार्प Pattern Editing पैटर्न संपादन Pattern Options पैटर्न ऑप्शन्स Pattern options are available in pattern editing mode. पैटर्न ऑप्शन्स पैटर्न संपादन मोड में उपलब्ध हैं। - Pattern Swatches पैटर्न Swatches + Pattern Swatches पैटर्न स्वॉचेस Pattern Type पैटर्न प्रकार Patterns पैटर्न्स Pause For इतने समय रुकें @@ -3448,7 +3454,7 @@ antiAlias Sharp शार्प Place प्लेस करें Place DXF DXF रखें Place from Library लाइब्रेरी - Place onto a new locked, dimmed template layer below the current layer वर्तमान लेयर के नीचे एक नए बंद, dimmed टेम्पलेट लेयर पर रखें + Place onto a new locked, dimmed template layer below the current layer वर्तमान लेयर के नीचे एक नए लॉक्ड, डिम किए गए टेम्पलेट लेयर पर रखें Place PDF PDF रखें Place Symbol Instance सिंबल इंस्टेंस प्लेस करें Place the pages with the placement again पृष्ठों को फिर से प्लेसमेंट के साथ रखें @@ -3480,6 +3486,7 @@ antiAlias Sharp शार्प @msg message must be text message टेक्स्ट होना चाहिए @msg allowContinue must be true or false allowContinue true या false होना चाहिए @msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता +@msg Batch cannot play an action with a View menu step बैच व्यू मेनू के चरण वाला एक्शन नहीं चला सकता Insert Menu Item… मेनू आइटम जोड़ें… Insert Menu Item मेनू आइटम जोड़ें Insert Select Path सिलेक्ट पाथ जोड़ें @@ -3523,7 +3530,7 @@ antiAlias Sharp शार्प Points पॉइंट्स Points per segment प्रति खंड अंक Points: पॉइंट्स: - Points: clicks add free points अंक: क्लिक मुफ्त अंक जोड़ते हैं + Points: clicks add free points पॉइंट्स: क्लिक से अलग-अलग पॉइंट जुड़ते हैं Polar Grid ध्रुवीय ग्रिड Polar Grid Tool ध्रुवीय ग्रिड टूल Polar Grid Tool Options ध्रुवीय ग्रिड टूल ऑप्शन्स @@ -3945,8 +3952,8 @@ antiAlias Sharp शार्प Reset Preferences प्रेफ़रेंसेज़ रीसेट करें Export Preferences… प्रेफ़रेंसेज़ निर्यात करें… Import Preferences… प्रेफ़रेंसेज़ आयात करें… - Save the applied preferences to a JSON file लागू प्रेफ़रेंसेज़ को JSON फ़ाइल में सहेजें - Apply the preferences in a file Export Preferences saved प्रेफ़रेंसेज़ निर्यात करें से सहेजी गई फ़ाइल की प्रेफ़रेंसेज़ लागू करें + Save the applied preferences to a JSON file लागू प्रेफ़रेंसेज़ को JSON फ़ाइल में सेव करें + Apply the preferences in a file Export Preferences saved प्रेफ़रेंसेज़ निर्यात करें से सेव की गई फ़ाइल की प्रेफ़रेंसेज़ लागू करें Export Preferences प्रेफ़रेंसेज़ निर्यात करें Import Preferences प्रेफ़रेंसेज़ आयात करें Search Preferences प्रेफ़रेंसेज़ में खोजें @@ -3965,6 +3972,9 @@ antiAlias Sharp शार्प Reset the rows to the artwork's colors आर्टवर्क के रंग के लिए पंक्तियों को रीसेट करें Color Theme Picker रंग थीम पिकर Click or drag across the art to pick its colors; Shift adds more आर्टवर्क के रंग लेने के लिए उस पर क्लिक करें या खींचें; Shift और जोड़ता है + Prominent Colors प्रमुख रंग + Save All Colors सभी रंग सेव करें + Save Prominent Colors प्रमुख रंग सेव करें Reset to default डिफ़ॉल्ट पर रीसेट करें Reset to Default डिफ़ॉल्ट पर रीसेट करें Reset to Defaults डिफ़ॉल्ट पर रीसेट करें @@ -3993,7 +4003,7 @@ antiAlias Sharp शार्प Resize panel window पैनल विंडो का साइज़ बदलें Resize panels पैनलों का साइज़ बदलें Resize Slice स्लाइस का आकार - Resize Type Area प्रकार क्षेत्र का आकार + Resize Type Area टाइप एरिया का आकार बदलें Resolution: रिज़ॉल्यूशन: Responsive उत्तरदायी Restore पुनर्स्थापित करें @@ -4236,7 +4246,7 @@ antiAlias Sharp शार्प Saved View 8 सेव्ड व्यू 8 Saved View 9 सेव्ड व्यू 9 Saved Views सेव किया गया दृश्य - Saves the current bars, toolbar and panel layout. वर्तमान बार, टूलबार और पैनल लेआउट को बचाता है। + Saves the current bars, toolbar and panel layout. वर्तमान बार, टूलबार और पैनल लेआउट को सेव करता है। Saving सेव किया जा रहा है Saving {} बचत {} Saving Poster.vectorcraft बचत Poster.vectorcraft @@ -4351,7 +4361,7 @@ antiAlias Sharp शार्प Select styled art and click New Graphic Style. स्टाइलबद्ध कला सिलेक्ट करें और नई ग्राफिक स्टाइल क्लिक करें। Select text टेक्स्ट चुनें Select Text by Font फ़ॉन्ट द्वारा टेक्स्ट सिलेक्ट करें - Select text or click into text with the Type tool to insert glyphs टेक्स्ट सिलेक्ट करें या glyphs डालने के लिए टाइप टूल के साथ टेक्स्ट में क्लिक करें + Select text or click into text with the Type tool to insert glyphs टेक्स्ट सिलेक्ट करें या ग्लिफ़ डालने के लिए टाइप टूल से टेक्स्ट में क्लिक करें Select text to set its OpenType features. अपनी OpenType सुविधाओं को सेट करने के लिए टेक्स्ट सिलेक्ट करें। Select the art (and, on top of it, the mask object) कला सिलेक्ट करें (और इसके शीर्ष पर, मास्क ऑब्जेक्ट) Select Tool टूल सिलेक्ट करें @@ -4365,7 +4375,7 @@ antiAlias Sharp शार्प Selected Styles Only (0) केवल सिलेक्टेड स्टाइल्स (0) Selected Swatches सिलेक्टेड स्वॉचेस Selected Swatches Only केवल सिलेक्टेड स्वॉचेस - Selected Swatches Only (0) केवल सिलेक्टेड Swatches (0) + Selected Swatches Only (0) केवल सिलेक्टेड स्वॉचेस (0) Selected Symbols Only केवल सिलेक्टेड सिंबल Selected: {n}, Total number of files to export: {m} सिलेक्टेड: {n}, निर्यात करने के लिए फ़ाइलों की कुल संख्या: {m} Selection सिलेक्शन @@ -4499,7 +4509,7 @@ antiAlias Sharp शार्प Show all open documents सभी खुला डॉक्यूमेंट दिखाएं Show all plates सभी प्लेटें दिखाएं Show All Plates सभी प्लेटें दिखाएं - Show All Swatches सभी Swatches दिखाएँ + Show All Swatches सभी स्वॉचेस दिखाएँ Show and print from the Lab definition; PDF files carry it as the ink's alternate color. लैब परिभाषा से शो और प्रिंट करें; PDF फ़ाइलें इसे स्याही के वैकल्पिक रंग के रूप में ले जाती हैं। Show and separate through the working CMYK space, as in older documents. पुराने डॉक्यूमेंट्स के रूप में कार्य CMYK अंतरिक्ष के माध्यम से दिखाएँ और अलग करें। Show Artboards आर्टबोर्ड्स दिखाएँ @@ -4546,7 +4556,7 @@ antiAlias Sharp शार्प Show Original Path मूल पाथ दिखाएं Show Package पैकेज करें दिखाएँ Show Panel शो पैनल - Show Pattern Swatches पैटर्न Swatches दिखाएँ + Show Pattern Swatches पैटर्न स्वॉचेस दिखाएँ Show Perspective Grid पर्सपेक्टिव ग्रिड दिखाएँ Show Pixel Grid (Above 600% Zoom) प्रदर्शन पिक्सेल ग्रिड (600% ज़ूम ऊपर) Show Presets प्रीसेट्स दिखाएँ @@ -4558,6 +4568,12 @@ antiAlias Sharp शार्प Show Slice Numbers स्लाइस नंबर दिखाएँ Show Slices स्लाइस दिखाएँ Show smooth color wheel चिकनी रंग पहिया दिखाएँ + Pixel Snapping Options पिक्सेल पर स्नैप ऑप्शन्स + Pixel Snapping Options… पिक्सेल पर स्नैप ऑप्शन्स… + Snap to Pixel While Drawing ड्रॉ करते समय पिक्सेल के लिए स्नैप + Snap to Pixel While Moving मूव करते समय पिक्सेल के लिए स्नैप + Snap to Pixel While Scaling स्केल करते समय पिक्सेल के लिए स्नैप + These options take effect when View › Snap to Pixel is on. ये ऑप्शन्स तब लागू होते हैं जब व्यू › पिक्सेल के लिए स्नैप चालू हो। Show Snap to Glyph Options ग्लिफ़ पर स्नैप ऑप्शन्स दिखाएँ Show Swatch Bounds स्वॉच की सीमाएँ दिखाएँ Show Swatch Kinds स्वॉच प्रकार दिखाएँ @@ -4743,6 +4759,19 @@ antiAlias Sharp शार्प Spot Color Objects स्पॉट कलर्स के साथ ऑब्जेक्ट्स Pattern Objects पैटर्न्स के साथ ऑब्जेक्ट्स Gradient Objects ग्रेडिएंट्स के साथ ऑब्जेक्ट्स + Font Details फ़ॉन्ट विवरण + Linked Images लिंक्ड चित्र + Embedded Images एम्बेडेड चित्र + Compound Paths कंपाउंड पाथ्स + Text Objects टेक्स्ट ऑब्जेक्ट्स + Placed Documents प्लेस्ड डॉक्यूमेंट्स + Symbol Instances सिंबल इंस्टेंस + Gradient Meshes ग्रेडिएंट मेश + Blends ब्लेंड्स + Envelopes एन्वेलप्स + Intertwine Groups इंटरट्वाइन समूह + Opacity Masks ओपेसिटी मास्क + Objects with Effects इफ़ेक्ट्स के साथ ऑब्जेक्ट्स Spot Colors स्पॉट रंग Spot Colors (0) स्पॉट कलर्स (0) Spot Colors… स्पॉट कलर्स… @@ -4870,7 +4899,7 @@ antiAlias Sharp शार्प Swatch Conflicts स्वॉच विरोध Swatch exchange files keep solid colors only. स्वॉच एक्सचेंज फ़ाइलों में केवल सॉलिड रंग रहते हैं। Swatch Libraries स्वॉच लाइब्रेरीज़ - Swatch Libraries Menu घड़ी लाइब्रेरीों मेनू + Swatch Libraries Menu स्वॉच लाइब्रेरीज़ मेनू Swatch Library स्वॉच लाइब्रेरी Swatch Name: स्वॉच का नाम: Swatch Options स्वॉच ऑप्शन्स @@ -4956,7 +4985,7 @@ antiAlias Sharp शार्प Temperature/Tint तापमान / तापमान Temperature/Tint… तापमान / टिंट… Template टेम्पलेट - Template literal not closed टेम्पलेट साक्षर नहीं बंद + Template literal not closed टेम्पलेट लिटरल बंद नहीं हुआ Templates टेम्पलेट्स Templates Folder टेम्पलेट्स फ़ोल्डर Tetrad टेट्राड @@ -5188,10 +5217,10 @@ antiAlias Sharp शार्प Center on Bottom नीचे केंद्रित करें Type on a Path Tool पाथ पर टाइप टूल Type Optimized टाइप ऑप्टिमाइज़ - Type Orientation प्रकार अभिविन्यास + Type Orientation टाइप ओरिएंटेशन Type stays text, and every stroke is a line with its lineweight and dashes. टाइप टेक्स्ट बना रहता है। हर स्ट्रोक अपने लाइनवेट और डैश वाली लाइन बनता है। Type Tool टाइप टूल - Type Tools प्रकार टूल + Type Tools टाइप टूल्स Type: प्रकार: Type… टाइप… Typing टाइपिंग @@ -5206,7 +5235,7 @@ antiAlias Sharp शार्प Undo {name} {name} पूर्ववत करें Unembed एम्बेड हटाएँ Unembed… एम्बेड हटाएँ… - Unembedded to {} Unembed to {} + Unembedded to {} एम्बेड हटाकर {} में रखा गया Ungroup समूह तोड़ें Ungroup Color Group रंग समूह का ग्रूपिंग हटाएँ Unicode यूनिकोड @@ -5262,8 +5291,8 @@ antiAlias Sharp शार्प Use Default डिफ़ॉल्ट का उपयोग करें Use document bleed settings डॉक्यूमेंट की ब्लीड सेटिंग्स उपयोग करें Use Document Raster Effects Resolution डॉक्यूमेंट के रास्टर इफ़ेक्ट्स का रिज़ॉल्यूशन उपयोग करें - Use Even-Odd Fill Rule यहां तक कि-Odd भरण नियम का उपयोग करें - Use Japanese Crop Marks जापानी फसल मार्क्स का प्रयोग करें + Use Even-Odd Fill Rule ईवन-ऑड फ़िल नियम का उपयोग करें + Use Japanese Crop Marks जापानी क्रॉप मार्क्स का उपयोग करें Use Library Item लाइब्रेरी आइटम का उपयोग करें Use Low Resolution Proxy for Linked EPS लिंक्ड ईपीएस के लिए कम रिज़ॉल्यूशन प्रॉक्सी का उपयोग करें Use Non-Zero Winding Fill Rule गैर-ज़ीरो विंडिंग फिल नियम का उपयोग करें @@ -5275,7 +5304,7 @@ antiAlias Sharp शार्प Use Square for Previews प्रीव्यू के लिए स्क्वायर का उपयोग करें Use System Defaults for Edit Original सिस्टम डिफ़ॉल्ट का उपयोग करने के लिए संपादित करें मूल Use Text for Previews प्रीव्यू के लिए टेक्स्ट का उपयोग करें - Use the document's swatch: pasted colors take its color. डॉक्यूमेंट की घड़ी का उपयोग करें: पेस्ट किए गए रंग अपना रंग लेते हैं। + Use the document's swatch: pasted colors take its color. डॉक्यूमेंट का स्वॉच इस्तेमाल करें: पेस्ट किए गए रंग उसका रंग ले लेते हैं। Use the Selected Text's Font सिलेक्टेड टेक्स्ट के फ़ॉन्ट का उपयोग करें Use Typographer's Quotes टाइपोग्राफ़िक उद्धरण चिह्न उपयोग करें User Brush Library 1 उपयोगकर्ता ब्रश लाइब्रेरी 1 @@ -5352,7 +5381,7 @@ antiAlias Sharp शार्प VectorCraft on getartcraft.com getartcraft.com पर VectorCraft VectorCraft on GitHub GitHub पर VectorCraft VectorCraft read this script without running it. VectorCraft ने इस स्क्रिप्ट को रन किए बिना पढ़ा। - VectorCraft Swatches (.vcswatches) वेक्टर क्रेफ्ट Swatches (.vcswatches) + VectorCraft Swatches (.vcswatches) VectorCraft स्वॉचेस (.vcswatches) VectorCraft Updates वेक्टर क्रेफ्ट अपडेट Vectors वेक्टर Version संस्करण @@ -5476,7 +5505,7 @@ antiAlias Sharp शार्प Yes हाँ Yonroku-ban योनरोकु-बान Your browser downloads the files (several as one .zip). आपका ब्राउज़र फ़ाइलों को डाउनलोड करता है (एक ज़िप के रूप में कई)। - Your changes will be lost if you don't save them. यदि आप उन्हें नहीं बचाते हैं तो आपका परिवर्तन खो जाएगा। + Your changes will be lost if you don't save them. अगर आप अपने बदलाव सेव नहीं करते हैं, तो वे खो जाएँगे। Zero-Opacity Objects शून्य-ऑपेसिटी ऑब्जेक्ट Zig Zag… ज़िग ज़ैग… Zoom ज़ूम @@ -5688,8 +5717,8 @@ antiAlias Sharp शार्प @msg {_1} is not a file {_1} एक फ़ाइल नहीं है @msg {_1} is reserved by the system {_1} सिस्टम द्वारा आरक्षित है @msg {_1} isn't a path {_1} एक पाथ नहीं है -@msg {_1} MB of document, at most {_2} MB {_1} MB of document, at most {_2} MB -@msg {_1} MB of images, at most {_2} MB {_1} MB of images, at most {_2} MB +@msg {_1} MB of document, at most {_2} MB डॉक्यूमेंट के {_1} MB, अधिकतम {_2} MB +@msg {_1} MB of images, at most {_2} MB इमेज के {_1} MB, अधिकतम {_2} MB @msg {_1} objects (limit {MAX_OBJECTS}) {_1} ऑब्जेक्ट्स (सीमा {MAX_OBJECTS}) @msg {_1} options: {e} {_1} ऑप्शन्स: {e} @msg {_1} paints nothing to rasterize {_1} कुछ भी नहीं पेंट करता है @@ -5699,7 +5728,7 @@ antiAlias Sharp शार्प @msg {_1}: belongs to another account {_1}: किसी अन्य खाते से संबंधित है @msg {_1}: not a folder of its own {_1}: अपने खुद के फ़ोल्डर नहीं @msg {_1}: not a plain file {_1}: एक सादे फ़ाइल नहीं -@msg {_1}: not saved, so not closed {_1}: बचाया नहीं गया, इसलिए बंद नहीं हुआ +@msg {_1}: not saved, so not closed {_1}: सेव नहीं हुआ, इसलिए बंद नहीं हुआ @msg {_1}: they came in as the page's art {_1}: वे पृष्ठ की कला के रूप में आए @msg {at}: {msg} {at}: {msg} @msg {bpp}-bit bitmaps can't be read {bpp}-bit bitmaps पढ़ा नहीं जा सकता @@ -5750,7 +5779,7 @@ antiAlias Sharp शार्प @msg {kind} images can't be opened (see document.formats) {kind} इमेज ओपन नहीं हो सकतीं (document.formats देखें) @msg {label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप, समूह, ब्लेंड या सिंबल इंस्टेंस आवश्यक है। पहले अन्य ऑब्जेक्ट को एक्सपैंड करें। @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} इमेज से ठोस नहीं बना सकता: पहले ऑब्जेक्ट › इमेज ट्रेस › बनाएँ से उसे ट्रेस करें। -@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। +@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक आर्टबोर्ड के साथ फिर से खुलती है। सभी आर्टबोर्ड रखने के लिए संपादन क्षमताओं को संरक्षित करना विकल्प चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए @msg {label} must be at most {max} characters {label} में अधिकतम {max} वर्ण होने चाहिए @@ -5777,7 +5806,7 @@ antiAlias Sharp शार्प @msg {name}: CRC or size mismatch {name}: CRC or size mismatch @msg {name}: no local header {name}: कोई स्थानीय हैडर नहीं @msg {name}: no such recovery entry {name}: ऐसी कोई वसूली प्रविष्टि नहीं -@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: केवल इस Affinity फ़ाइल के एम्बेडेड प्रीव्यू को पढ़ा जा सकता है; फ़ाइल का उपयोग करें > इसे अपनी चेतावनी के साथ देखने के लिए ओपन करें, या SVG या PDF को Affinity से पहले निर्यात करें। +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: इस Affinity फ़ाइल का केवल एम्बेडेड प्रीव्यू पढ़ा जा सका; इसे इसकी चेतावनी के साथ देखने के लिए फ़ाइल › ओपन का उपयोग करें, या प्लेस करने से पहले Affinity से SVG या PDF निर्यात करें @msg {native}; its embedded preview can't be read either ({e}) {native}; इसका एम्बेड किया प्रीव्यू भी पढ़ा नहीं जा सका ({e}) @msg {note}) ({more} {note} ({more}) @msg {path} can't be found: relink it first {path} पाया नहीं जा सकता: इसे पहली बार फिर से लिंक करें @@ -5787,7 +5816,7 @@ antiAlias Sharp शार्प @msg {path}: not a file {path}: एक फ़ाइल नहीं @msg {status} ({e}) {status} ({e}) @msg {std} files are CMYK, and the file would have colours in {_1} {std} फ़ाइलें CMYK हैं, और फ़ाइल में {_1} में रंग होंगे। -@msg {std} files embed their fonts, and the file would have one that isn't {std} फ़ाइलें अपने फ़ॉन्ट एम्बेड करती हैं, और फ़ाइल में एक ऐसा होता है जो नहीं है। +@msg {std} files embed their fonts, and the file would have one that isn't {std} फ़ाइलें अपने फ़ॉन्ट एम्बेड करती हैं, और इस फ़ाइल में एक ऐसा फ़ॉन्ट होता जो एम्बेड नहीं है @msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway यह डॉक्यूमेंट {target} से पढ़ा गया था और पढ़ते समय कुछ चीज़ें छूट गईं ({what}): उसी फ़ाइल पर लिखने से वे स्थायी रूप से खो जाएँगी। दूसरी फ़ाइल लिखें या फिर भी बदलने के लिए acknowledgeLoss: true दें @msg {tool} isn't in a toolbar group of several tools {tool} कई टूल्स के टूलबार समूह में नहीं है @msg {ty} brushes need selected art (or params.{k}) {ty} ब्रश के लिए सिलेक्टेड आर्टवर्क (या params.{k}) चाहिए @@ -5803,7 +5832,7 @@ antiAlias Sharp शार्प @msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` एक लाइव इफ़ेक्ट है: इसे effect.apply {{"effect": "{_2}"}} के साथ लागू करें @msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` एक एक्सपोर्टर है: document.export {{"format": "{_2}"}} से एक्सपोर्ट करें @msg `{_1}` is built in and always listed `{_1}` बनाया गया है और हमेशा सूचीबद्ध है -@msg `{_1}` is no save command `{_1}` कोई बचत आदेश नहीं है +@msg `{_1}` is no save command `{_1}` कोई सेव कमांड नहीं है @msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइल नहीं है @msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` पैकेज द्वारा ओवरराइट किया जाएगा: एक अलग फ़ोल्डर या नाम चुनें @msg `{_1}`: {msg} `{_1}`: {msg} @@ -5812,7 +5841,7 @@ antiAlias Sharp शार्प @msg `{f}` belongs to a VectorCraft that is running `{f}` एक VectorCraft से संबंधित है जो चल रहा है @msg `{f}` is the copy of a document open now `{f}` एक डॉक्यूमेंट की प्रतिलिपि है जो अब खुला है @msg `{file}` has no art to place `{file}` के पास कोई कला नहीं है -@msg `{file}` is over {_1} MB `{file}` is over {_1} MB +@msg `{file}` is over {_1} MB `{file}` {_1} MB से बड़ी है @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` एक VectorCraft डॉक्यूमेंट नहीं है, एक PDF या एक PDF-compatible.ai फ़ाइल @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` एक लाइब्रेरी पैनल नहीं है (देखें window.swatchLibrary) @msg `{id}` has no envelope dialog `{id}` में कोई लिफाफा संवाद नहीं है @@ -5828,7 +5857,7 @@ antiAlias Sharp शार्प @msg `{k}` must be {what}, not {v} `{k}` होना चाहिए {what}, नहीं {v} @msg `{k}` must be a byte offset `{k}` को एक बाइट ऑफ़सेट होना चाहिए @msg `{k}` must be a number `{k}` एक संख्या होना चाहिए -@msg `{k}` must be a number of points `{k}` को कई अंक होना चाहिए +@msg `{k}` must be a number of points `{k}` पॉइंट में एक संख्या होनी चाहिए @msg `{k}` must be a string `{k}` एक स्ट्रिंग होना चाहिए @msg `{k}` must be a whole number `{k}` एक पूर्ण संख्या होना चाहिए @msg `{k}` must be from {lo} to {hi} for {key} `{k}` होना चाहिए {lo} से {hi} {key} के लिए @@ -5873,7 +5902,7 @@ antiAlias Sharp शार्प @msg `{n}` can't be deleted `{n}` को हटाया नहीं जा सकता @msg `{n}` can't be merged `{n}` को विलय नहीं किया जा सकता है @msg `{n}` can't be moved `{n}` को स्थानांतरित नहीं किया जा सकता है -@msg `{n}` is not a colour swatch `{n}` एक रंग की घड़ी नहीं है +@msg `{n}` is not a colour swatch `{n}` रंग स्वॉच नहीं है @msg `{n}` isn't a solid colour `{n}` एक ठोस रंग नहीं है @msg `{n}`: colour groups hold solid colours only `{n}`: रंग समूहों में केवल ठोस रंग होते हैं @msg `{name}` can't be deleted `{name}` को हटाया नहीं जा सकता है @@ -5896,21 +5925,21 @@ antiAlias Sharp शार्प @msg `{name}` is a built-in workspace; choose another name `{name}` एक अंतर्निहित वर्कस्पेस है; एक और नाम चुनें @msg `{name}` is a process colour: tints apply to global and spot colours `{name}` एक प्रक्रिया रंग है: टिंट वैश्विक और स्पॉट रंगों पर लागू होते हैं @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` एक स्थिर सिंबल है: अपने सिंबल प्रकार को पहले गतिशील करने के लिए सेट करें -@msg Only the fill, stroke, opacity and blend of a path, compound path or type selected in a symbol instance can change प्रतीक इंस्टेंस में चयनित पथ, संयोजित पथ या पाठ का केवल भराव, स्ट्रोक, अपारदर्शिता और ब्लेंड बदला जा सकता है -@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` `{base}` का एक टिंट है: `{base}` संपादित करें (केवल एक टिंट swatch केवल एक नया नाम लेता है) +@msg Only the fill, stroke, opacity and blend of a path, compound path or type selected in a symbol instance can change सिंबल इंस्टेंस में सिलेक्ट किए गए पाथ, कंपाउंड पाथ या टाइप का केवल फ़िल, स्ट्रोक, ओपेसिटी और ब्लेंड बदला जा सकता है +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` `{base}` का एक टिंट है: `{base}` संपादित करें (टिंट स्वॉच केवल नया नाम लेता है) @msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` वर्किंग स्पेस है: पहले edit.colorSettings से दूसरा चुनें @msg `{name}` is built in and stays `{name}` बनाया गया है और रहता है @msg `{name}` is no recovery entry name `{name}` कोई वसूली प्रविष्टि नाम नहीं है -@msg `{name}` isn't a colour swatch `{name}` is not a color swatch -@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` अपने खुद के प्रकार (रंग, ग्रेडिएंट या पैटर्न) का एक रंग लेता है। +@msg `{name}` isn't a colour swatch `{name}` रंग स्वॉच नहीं है +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` अपने ही प्रकार का पेंट लेता है (रंग, ग्रेडिएंट या पैटर्न) @msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: न्यूनतम इच्छा से अधिक नहीं होना चाहिए, न ही इच्छा अधिकतम @msg `{other}` doesn't trace an image `{other}` एक इमेज का पता नहीं लगा @msg `{path}` must be a bool or an object of bools `{path}` एक bool होना चाहिए या एक ऑब्जेक्ट होना चाहिए। @msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` एक रंग नहीं है (#rrggbb या एक पूर्व निर्धारित नाम) @msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: चरण के संदर्भ मूल्य के {_1} MB से अधिक में प्रतिलिपि बनाते हैं @msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` एक अक्ष टैग नहीं है: एक टैग चार वर्ण हैं, जैसे कि "wght" -@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: चरण {i} के परिणाम में ऐसा कोई मूल्य नहीं है (एक साक्षर $ के लिए $$ लिखना) -@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: इस से पहले कोई स्टेप {i} नहीं है (स्टेप 0 से गिनते हैं; $$ को साक्षर $ के लिए लिखें) +@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: चरण {i} के परिणाम में ऐसा कोई मान नहीं है (शाब्दिक $ के लिए $$ लिखें) +@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: इससे पहले कोई चरण {i} नहीं है (चरण 0 से गिने जाते हैं; शाब्दिक $ के लिए $$ लिखें) @msg `{to}` is empty or already used `{to}` खाली है या पहले से ही इस्तेमाल किया जाता है @msg `{to}` is not an available name `{to}` उपलब्ध नहीं है @msg `{tool}` isn't a freehand tool `{tool}` एक फ्रीहैंड टूल नहीं है @@ -6004,7 +6033,7 @@ antiAlias Sharp शार्प @msg `gap` must be from 0 to 6 pt `gap` 0 से 6 तक होना चाहिए। @msg `gid` must be a glyph id, not {v} `gid` एक glyph id होना चाहिए, नहीं {v} @msg `globalAdjust` must be an object `globalAdjust` एक ऑब्जेक्ट होना चाहिए -@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` एक चरित्र से संबंधित है, एक चरित्र स्टाइल के लिए नहीं (text.setGlyphAlternate इसे सेट) +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` एक कैरेक्टर का होता है, कैरेक्टर स्टाइल का नहीं (इसे text.setGlyphAlternate सेट करता है) @msg `gradient` must be an object `gradient` एक ऑब्जेक्ट होना चाहिए @msg `groupColors` must be an array `groupColors` को ऐरे होना चाहिए @msg `guide` must be a guide index (guide.list) `guide` एक गाइड सूचकांक (guide.list) होना चाहिए। @@ -6078,7 +6107,7 @@ antiAlias Sharp शार्प @msg `path` must be a path object `path` एक पाथ ऑब्जेक्ट होना चाहिए @msg `pathfinder` must be an object `pathfinder` एक ऑब्जेक्ट होना चाहिए @msg `pivot` must be a whole number from 0 to 8 `pivot` 0 से 8 तक पूरी संख्या होना चाहिए -@msg `points` must be a list of [x, y] points `points` को [x, y] अंक की एक सूची होना चाहिए +@msg `points` must be a list of [x, y] points `points` को [x, y] पॉइंट की सूची होना चाहिए @msg `points` must be a list of point indexes `points` को पॉइंट इंडेक्स की सूची होना चाहिए @msg `points` must be an array of point indices `points` को पॉइंट इंडेक्स की ऐरे होना चाहिए @msg `points` must be point indexes `points` में पॉइंट इंडेक्स होने चाहिए @@ -6101,14 +6130,14 @@ antiAlias Sharp शार्प @msg `select` must be true or false `select` को true या false होना चाहिए @msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` श्रृंखला सूचकांकों की एक गैर-खाली सूची है @msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` को `type` या `valueAxis` की आवश्यकता होती है -@msg `shift` must be a number of points `shift` को कई अंक होना चाहिए +@msg `shift` must be a number of points `shift` पॉइंट में एक संख्या होनी चाहिए @msg `showCenter` must be true or false `showCenter` को true या false होना चाहिए @msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` को "fullJustify", "alignLeft", "alignCenter" या "alignRight" होना चाहिए। @msg `size` is a percentage of the base text's size, 1 to 100 `size` आधार टेक्स्ट के आकार का एक प्रतिशत है, 1 से 100 @msg `source` and `target` are the same object `source` और `target` एक ही ऑब्जेक्ट है @msg `source` must be `folder` or `datasets` `source` होना चाहिए `folder` या `datasets` होना चाहिए @msg `spacing` must be a number `spacing` होना चाहिए -@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` को कई अंक, 0 या उससे अधिक, या "auto" होना चाहिए। +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` पॉइंट में 0 या उससे बड़ी संख्या, या "auto" होनी चाहिए @msg `start` must be a finite number `start` को परिमित संख्या होना चाहिए @msg `starts` must be an array of anchor indices (or nulls) `starts` को एंकर इंडेक्स (या null) की ऐरे होना चाहिए @msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची @@ -6126,7 +6155,7 @@ antiAlias Sharp शार्प @msg `t` must be from 0 to 1 `t` 0 से 1 तक होना चाहिए @msg `target` must be "object" or "contents", not {v} `target` को "object" या "contents" होना चाहिए, नहीं {v} @msg `tint` applies to global and spot colours `tint` वैश्विक और स्पॉट रंगों पर लागू होता है -@msg `tint` goes with a global or spot `swatch` `tint` एक वैश्विक या स्थान `swatch` के साथ चला जाता है +@msg `tint` goes with a global or spot `swatch` `tint` किसी ग्लोबल या स्पॉट `swatch` के साथ दिया जाता है @msg `tint` must be a number (%) `tint` एक नंबर होना चाहिए (%) @msg `tint` needs a global or spot colour `tint` को वैश्विक या स्पॉट रंग की आवश्यकता होती है @msg `to` is already threaded `to` पहले से ही पिरोया है @@ -6176,7 +6205,7 @@ antiAlias Sharp शार्प @msg a document needs at least one layer डॉक्यूमेंट की कम से कम एक लेयर की आवश्यकता होती है @msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard एक DXF ड्राइंग का एक मूल होता है: हर आर्टबोर्ड के लिए एक ड्राइंग के लिए useArtboards: true दें @msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes एक feConvolveMatrix फिल्टर का आदेश {x} ×{y} है, जो कर्नेल-आकार की बहुप्लाई usvg को ओवरफ्लो करता है। -@msg a freeform gradient keeps at least one point एक freeform ग्रेडिएंट कम से कम एक बिंदु रखता है +@msg a freeform gradient keeps at least one point फ़्रीफ़ॉर्म ग्रेडिएंट में कम से कम एक पॉइंट रहता है @msg a gradient needs at least two stops कम से कम दो स्टॉप की आवश्यकता होती है @msg a gradient preset is already called {n} gradient preset पहले से ही कहा जाता है {n} @msg a graph has at most {_1} series, so it transposes with at most that many categories एक ग्राफ में सबसे {_1} श्रृंखला है, इसलिए यह उस कई श्रेणियों के साथ ट्रांसपोजित होता है। @@ -6190,7 +6219,7 @@ antiAlias Sharp शार्प @msg a layer's form can't be read एक लेयर का रूप नहीं पढ़ा जा सकता है @msg a layer's form has no box एक लेयर के रूप में कोई बॉक्स नहीं है @msg a layer's form has no dictionary एक लेयर के रूप में कोई शब्दकोश नहीं है -@msg a legend draws at most {MAX_CHIPS} swatches एक किंवदंती सबसे {MAX_CHIPS} swatches पर आकर्षित होती है +@msg a legend draws at most {MAX_CHIPS} swatches लेजेंड अधिकतम {MAX_CHIPS} स्वॉच बनाता है @msg a linked image can't become a gradient mesh: embed it first एक संबद्ध इमेज एक ग्रेडिएंट जाल नहीं बन सकती है: इसे पहले एम्बेड करें @msg a live effect runs while the document is drawn, so it can't be `heavy` जब डॉक्यूमेंट तैयार हो जाता है, तो यह `heavy` नहीं हो सकता है। @msg a mask has more than one art block एक मास्क एक से अधिक कला ब्लॉक है @@ -6208,7 +6237,7 @@ antiAlias Sharp शार्प @msg a page tree kid isn't a reference एक पृष्ठ पेड़ बच्चा एक संदर्भ नहीं है @msg a page tree node has no kids पृष्ठ पेड़ नोड में कोई बच्चे नहीं है @msg a page's placement is not finite एक पृष्ठ का प्लेसमेंट परिमित नहीं है -@msg a path has too many points एक पाथ में बहुत अधिक अंक होते हैं +@msg a path has too many points पाथ में बहुत अधिक पॉइंट हैं @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects एक जटिल पाथ के अंदर एक पाथ, मिश्रण, लिफ़ाफ़ाफ़ा, दोहराने या इंटरट्विन समूह ऑब्जेक्ट नहीं ले सकता है @msg a pattern layer doesn't end what it starts एक पैटर्न लेयर अंत नहीं है कि यह क्या शुरू होता है @msg a pattern layer's colors hold something else एक पैटर्न लेयर के रंग कुछ और पकड़ते हैं @@ -6233,16 +6262,16 @@ antiAlias Sharp शार्प @msg A profile supports one Revolve effect. Edit the existing effect's options instead. एक प्रोफ़ाइल एक रिवॉल्व इफ़ेक्ट का समर्थन करता है। इसके बजाय मौजूदा इफ़ेक्ट के ऑप्शन्स को संपादित करें। @msg a row can't go into itself एक पंक्ति अपने आप में नहीं जा सकती @msg a series goes on the left or the right value axis एक श्रृंखला बाएं या दाएं मूल्य अक्ष पर जाती है -@msg a slice size must be a positive number of points एक स्लाइस आकार अंक की एक सकारात्मक संख्या होना चाहिए -@msg a spine keeps at least two points रीढ़ की हड्डी कम से कम दो अंक रखती है +@msg a slice size must be a positive number of points स्लाइस का आकार पॉइंट में एक धनात्मक संख्या होनी चाहिए +@msg a spine keeps at least two points स्पाइन में कम से कम दो पॉइंट रहते हैं @msg a start must be an anchor index ≥ 0 or null प्रारंभ में एक एंकर इंडेक्स होना चाहिए ≥ 0 या null @msg a stream runs past the end एक स्ट्रीम अंत तक चलता है @msg a stream's length is wrong धारा की लंबाई गलत है @msg a stream's length isn't a number एक धारा की लंबाई नहीं है @msg a style named `{name}` already exists `{name}` नामक एक स्टाइल पहले से मौजूद है @msg a style named `{to}` already exists `{to}` नामक एक स्टाइल पहले से मौजूद है -@msg a swatch needs a colour, gradient or pattern एक swatch एक रंग, ग्रेडिएंट या पैटर्न की जरूरत है -@msg a swatch or color group named `{n}` exists `{n}` नामक एक swatch या रंग समूह मौजूद है +@msg a swatch needs a colour, gradient or pattern स्वॉच को रंग, ग्रेडिएंट या पैटर्न चाहिए +@msg a swatch or color group named `{n}` exists `{n}` नाम का स्वॉच या रंग समूह पहले से मौजूद है @msg a symbol can't hold an instance of itself एक सिंबल खुद का एक उदाहरण नहीं पकड़ सकता है @msg a symbol named `{n}` already exists पहले से ही `{n}` नाम का सिंबल मौजूद है @msg a theme has 1 to {THEME_SIZE} colours थीम में 1 से {THEME_SIZE} रंग होते हैं @@ -6304,10 +6333,10 @@ antiAlias Sharp शार्प @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art या type @msg appearance must be flat|center|edge appearance को flat|center|edge होना चाहिए @msg apply is "fill" or "stroke", not `{other}` apply "fill" या "stroke" है, नहीं `{other}` -@msg area must be a closed polygon of 3 to 100000 [x, y] points area: क्षेत्र 3 से 100000 [x, y] अंक के एक बंद बहुभुज होना चाहिए -@msg area type {_1} is named twice क्षेत्र के प्रकार {_1} को दो बार नाम दिया गया है -@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise फ्रेम में क्षेत्र प्रकार फ़ॉन्ट की एक पंक्ति के लिए बहुत कम है, यह लंबा फ्रेम ({_1}) में दिखाता है: उनका प्रकार अन्यथा नहीं दिखाई देगा -@msg area type needs a path that encloses an area क्षेत्र के प्रकार को एक पाथ की आवश्यकता होती है जो एक क्षेत्र को घेरता है +@msg area must be a closed polygon of 3 to 100000 [x, y] points area: क्षेत्र 3 से 100000 [x, y] पॉइंट का एक बंद बहुभुज होना चाहिए +@msg area type {_1} is named twice एरिया टाइप {_1} का नाम दो बार दिया गया है +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise अपने फ़ॉन्ट की एक लाइन के लिए बहुत छोटे फ़्रेम वाले एरिया टाइप को लंबे फ़्रेम दिए गए ({_1}): वरना उनका टाइप नहीं दिखता +@msg area type needs a path that encloses an area एरिया टाइप को ऐसा पाथ चाहिए जो किसी क्षेत्र को घेरता हो @msg arrowAlign must be extend|tip, got {o} arrowAlign होना चाहिए thetip, get {o} @msg arrowScale must be [start, end] arrowScale होना चाहिए [शुरू, अंत] @msg artboard {0} does not exist आर्टबोर्ड {0} मौजूद नहीं है @@ -6462,7 +6491,7 @@ antiAlias Sharp शार्प @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}`, "#ff8800" जैसा रंग नहीं है @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: अधिकतम {MAX_TABLE} रंग @msg colour groups can't go into colour groups रंग समूह रंग समूहों में नहीं जा सकते हैं -@msg colour, mode and spot apply to solid-colour swatches only रंग, मोड और स्पॉट केवल ठोस रंग की घड़ी पर लागू होते हैं +@msg colour, mode and spot apply to solid-colour swatches only रंग, मोड और स्पॉट केवल ठोस रंग वाले स्वॉच पर लागू होते हैं @msg column {column}: `{cell}` is a `%` column of graph data files, which are not read कॉलम {column}: `{cell}` ग्राफ़ डेटा फ़ाइल का `%` कॉलम है, जिसे पढ़ा नहीं जाता @msg column {column}: `{cell}` names `{name}`, which is a {_1} variable कॉलम {column}: `{cell}` नाम `{name}`, जो एक {_1} चर है @msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` नाम कोई चर नहीं @@ -6479,7 +6508,7 @@ antiAlias Sharp शार्प @msg composer must be "singleLine" or "everyLine" composer: संगीतकार "singleLine" या "everyLine" होना चाहिए। @msg compound {_1} must contain exactly one open path to join यौगिक {_1} में शामिल होने के लिए बिल्कुल एक खुला रास्ता होना चाहिए @msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं -@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes मिश्रित आकार पाथ, यौगिक पाथ, समूह, मिश्रित आकार, प्रकार, मिश्रण और लिफाफे लेते हैं +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes कंपाउंड शेप पाथ, कंपाउंड पाथ, समूह, कंपाउंड शेप, टाइप, ब्लेंड और एन्वेलप लेते हैं @msg compressed (RLE) bitmaps can't be read yet संकुचित (RLE) bitmaps अभी तक पढ़ा नहीं जा सकता है @msg connect end must be "start" or "end" कनेक्ट अंत "start" या "end" होना चाहिए @msg connect needs a path other than the one extended connect: कनेक्ट एक विस्तारित के अलावा एक पाथ की जरूरत है @@ -6504,8 +6533,8 @@ antiAlias Sharp शार्प @msg Ran {name} {name} चलाई गई @msg The script was stopped and its changes were undone स्क्रिप्ट रोकी गई और उसके बदलाव पूर्ववत किए गए @msg a script is running: stop it or wait for it to finish एक स्क्रिप्ट चल रही है: उसे रोकें या पूरा होने की प्रतीक्षा करें -@msg this is a compiled script, which cannot be run; use its source text यह कंपाइल की हुई स्क्रिप्ट है, जिसे चलाया नहीं जा सकता; इसका स्रोत पाठ इस्तेमाल करें -@msg the script is not UTF-8 text स्क्रिप्ट UTF-8 पाठ नहीं है +@msg this is a compiled script, which cannot be run; use its source text यह कंपाइल की हुई स्क्रिप्ट है, जिसे चलाया नहीं जा सकता; इसका सोर्स टेक्स्ट इस्तेमाल करें +@msg the script is not UTF-8 text स्क्रिप्ट UTF-8 टेक्स्ट नहीं है @msg the script is too large to run स्क्रिप्ट चलाने के लिए बहुत बड़ी है @msg a drag or an undo group is open; finish it before running a script कोई ड्रैग या पूर्ववत समूह खुला है; स्क्रिप्ट चलाने से पहले उसे पूरा करें @msg the script left a document that cannot be kept स्क्रिप्ट ने ऐसा दस्तावेज़ छोड़ा है जिसे रखा नहीं जा सकता @@ -6529,7 +6558,7 @@ antiAlias Sharp शार्प @msg Script files cannot be opened in another application स्क्रिप्ट फ़ाइलें किसी अन्य ऐप्लिकेशन में नहीं खोली जा सकतीं @msg Script file access is unavailable on the web वेब पर स्क्रिप्ट फ़ाइल पहुँच उपलब्ध नहीं है @msg Script has 256 files open स्क्रिप्ट ने 256 फ़ाइलें खोल रखी हैं -@msg Script file path exceeds 32,767 characters स्क्रिप्ट फ़ाइल का पथ 32,767 वर्णों से अधिक है +@msg Script file path exceeds 32,767 characters स्क्रिप्ट फ़ाइल का पाथ 32,767 वर्णों से अधिक है @msg Script folder mask takes too many steps to match स्क्रिप्ट फ़ोल्डर मास्क के मिलान में बहुत अधिक चरण लगते हैं @msg the script opened more file data than one run may open (32 MiB) स्क्रिप्ट ने एक रन में खोले जा सकने वाले फ़ाइल डेटा से अधिक डेटा खोला (32 MiB) @msg File.seek: takes a position and a mode File.seek: एक स्थिति और एक मोड लेता है @@ -6537,9 +6566,9 @@ antiAlias Sharp शार्प @msg vc.run: takes a command id and an object of parameters vc.run: एक कमांड id और पैरामीटर का एक ऑब्जेक्ट लेता है @msg vc.run: the parameters must be an object vc.run: पैरामीटर एक ऑब्जेक्ट होने चाहिए @msg vc.run: the result{place} is too large for a script vc.run: परिणाम{place} स्क्रिप्ट के लिए बहुत बड़ा है -@msg $.evalFile requires a file path $.evalFile के लिए फ़ाइल पथ आवश्यक है +@msg $.evalFile requires a file path $.evalFile के लिए फ़ाइल पाथ आवश्यक है @msg $.evalFile: the timeout must be a finite number of milliseconds, 0 or more $.evalFile: टाइमआउट मिलीसेकंड की एक सीमित संख्या होनी चाहिए, 0 या अधिक -@msg $.evalFile: takes a file path and a timeout $.evalFile: एक फ़ाइल पथ और एक टाइमआउट लेता है +@msg $.evalFile: takes a file path and a timeout $.evalFile: एक फ़ाइल पाथ और एक टाइमआउट लेता है @msg {name}: line {line}, column {column} of {file}: {e} (thrown) {name}: {file} की लाइन {line}, कॉलम {column}: {e} (थ्रो किया गया) @msg {name}: line {line}, column {column}: {e} (thrown) {name}: लाइन {line}, कॉलम {column}: {e} (थ्रो किया गया) @msg {name}: {e} (thrown) {name}: {e} (थ्रो किया गया) @@ -6602,7 +6631,7 @@ antiAlias Sharp शार्प @msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus को unknown, copyrighted या publicDomain होना चाहिए @msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl को URL होना चाहिए (स्पेस नहीं, अधिकतम 2048 अक्षर) @msg Corners edits the corners of a path कॉर्नर एक पाथ के कोनों को संपादित करता है -@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners: कोनों 4 [x, y] अंक (TL, TR, BR, BL) होना चाहिए +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners में 4 [x, y] पॉइंट (TL, TR, BR, BL) होने चाहिए @msg could not decode image: {0} इमेज को डीकोड नहीं कर सकता: {0} @msg Couldn't copy to the system clipboard: {e} सिस्टम क्लिपबोर्ड को कॉपी नहीं कर सका: {e} @msg Couldn't open {name}: {e} नहीं खोल सकता {name}: {e} @@ -6612,19 +6641,19 @@ antiAlias Sharp शार्प @msg Couldn't place {name}: the document it was dropped on was closed नहीं था {name}: यह डॉक्यूमेंट बंद हो गया था @msg Couldn't place {name}: the document it was placed in was closed नहीं था {name}: यह डॉक्यूमेंट बंद किया गया था @msg Couldn't read {name}: {e} नहीं पढ़ सकता {name}: {e} -@msg Couldn't save recovery data: {e} वसूली डेटा नहीं बचा सकता: {e} +@msg Couldn't save recovery data: {e} रिकवरी डेटा सेव नहीं किया जा सका: {e} @msg counterPer `{c}`: item or file counterPer `{c}`: item या file @msg Create from Guides: the document has no artboard गाइड से बनाएँ: डॉक्यूमेंट में कोई आर्टबोर्ड नहीं है @msg Create from Guides: the guides make more than {MAX_PIECES} slices गाइड से बनाएँ: गाइड {MAX_PIECES} स्लाइस से अधिक बनाते हैं @msg Create from Selection: the selection has no area सिलेक्शन से बनाएँ: सिलेक्शन में कोई क्षेत्र नहीं है @msg Create needs fills, strokes or both जरूरतों को पूरा करने, स्ट्रोक या दोनों की जरूरत है @msg Create Outlines: select text objects आउटलाइन बनाएं: टेक्स्ट ऑब्जेक्ट चुनें -@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" फसल `{_1}`: केवल PDF पृष्ठों में कला, ट्रिम, ब्लीड और मीडिया बॉक्स हैं; "crop" या "bounding" का उपयोग करें -@msg crop rectangle doesn't overlap the image फसल आयत इमेज को ओवरलैप नहीं करता है +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" क्रॉप `{_1}`: केवल PDF पेजों में आर्ट, ट्रिम, ब्लीड और मीडिया बॉक्स होते हैं; "crop" या "bounding" का उपयोग करें +@msg crop rectangle doesn't overlap the image क्रॉप आयत इमेज को ओवरलैप नहीं करता @msg cross-reference and object streams aren't supported क्रॉस-रिफरेंस और ऑब्जेक्ट स्ट्रीम्स का समर्थन नहीं किया जाता है @msg CSS options: {e} सीएसएस ऑप्शन्स: {e} @msg CSV has a header but no data rows CSV एक हेडर है लेकिन कोई डेटा पंक्ति नहीं है -@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV ने NUL बाइट्स को रखा: इसे UTF-8 या UTF-16 के रूप में बाइट-ऑर्डर मार्क के साथ बचाओ +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV में NUL बाइट्स हैं: इसे UTF-8 के रूप में, या बाइट-ऑर्डर मार्क के साथ UTF-16 के रूप में सेव करें @msg CSV is empty CSV खाली है @msg CSV is larger than {MAX_CSV_BYTES} bytes CSV {MAX_CSV_BYTES} bytes से बड़ा है @msg CSV is not UTF-16 CSV UTF-16 नहीं है @@ -6793,12 +6822,12 @@ antiAlias Sharp शार्प @msg give a fraction `t` from 0 to 1 0 से 1 तक एक अंश `t` देना @msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments एक मोड या आर / जी / बी, सी / एम / वाई / के, ग्रे या टिंट समायोजन देते हैं @msg give a name, or paint the active proxy with a saved preset एक सेव किए गए प्रीसेट के साथ सक्रिय प्रॉक्सी को नाम देना, या पेंट करना -@msg give a name, or select a stroke with a saved profile एक नाम देना, या एक बचाया प्रोफ़ाइल के साथ एक स्ट्रोक का सिलेक्शन +@msg give a name, or select a stroke with a saved profile कोई नाम दें, या सेव की गई प्रोफ़ाइल वाला स्ट्रोक सिलेक्ट करें @msg give a pattern name or select an object painted with a pattern एक पैटर्न का नाम देना या एक पैटर्न के साथ चित्रित एक ऑब्जेक्ट का सिलेक्शन करना @msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` इनमें से कम से कम एक दें: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` @msg give at least one of clip, invert, disabled, linked कम से कम एक क्लिप, उलटा, विकलांग, लिंक्ड देना @msg give both `start` and `end` [x, y] (or neither) `start` और `end` दोनों [x, y] दें (या दोनों न दें) -@msg give color, none, swatch or gradient रंग, कोई नहीं, घड़ी या ग्रेडिएंट देना +@msg give color, none, swatch or gradient color, none, swatch या gradient दें @msg give corners of the path: anchors between two straight sides पाथ के कोने: दो सीधे पक्षों के बीच एंकर @msg give corners of the path: anchors where its sides meet at an angle पाथ के कोने: एंकर जहां इसके पक्ष एक कोण पर मिलते हैं @msg give dataBase64 dataBase64 दें @@ -6839,7 +6868,7 @@ antiAlias Sharp शार्प @msg give the settings to change, e.g. {"formats"}]} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {"formats"}]} @msg give the style a name स्टाइल को एक नाम दें @msg give two or more style `names` दो या अधिक स्टाइल `names` -@msg give two or more swatches दो या अधिक swatch +@msg give two or more swatches दो या अधिक स्वॉच दें @msg give x, y, or a handle's length or angle x, y, या एक हैंडल की लंबाई या कोण देना @msg Global Edit is off ग्लोबल एडिट बंद है @msg Global Edit works on paths, compound paths, symbol instances and groups of them ग्लोबल एडिट पाथ, मिश्रित पाथ, सिंबल उदाहरणों और उनके समूहों पर काम करता है @@ -6882,7 +6911,7 @@ antiAlias Sharp शार्प @msg Image Trace finished, but its document was closed इमेज ट्रेस समाप्त हो गया, लेकिन इसका डॉक्यूमेंट बंद हो गया। @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again इमेज ट्रेस समाप्त हो गया, लेकिन इस बीच इमेज या इसकी सेटिंग्स बदल गई: इसे फिर से ट्रेस करें @msg Image Trace: the traced image is missing इमेज ट्रेस: ज्ञात इमेज गायब है -@msg image would be too large; lower the resolution इमेज बहुत बड़ी होगी; संकल्प को कम करें +@msg image would be too large; lower the resolution इमेज बहुत बड़ी होगी; रिज़ॉल्यूशन कम करें @msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client या server @msg Imported {what}: {_1} आयात {what}: {_1} @msg Imported library {name} आयातित लाइब्रेरी {name} @@ -6966,7 +6995,7 @@ antiAlias Sharp शार्प @msg its page has text, and its layers have no text objects to put it in इसके पृष्ठ में टेक्स्ट है, और इसकी लेयर्स में कोई टेक्स्ट ऑब्जेक्ट नहीं है जिसे इसमें डाल दिया जाता है @msg its preview was placed instead, at screen resolution इसके बजाय इसके प्रीव्यू को स्क्रीन रिज़ॉल्यूशन पर रखा गया था। @msg its section ending with `%{end}` doesn't end इसका खंड `%{end}` के साथ समाप्त नहीं होता है -@msg its type is to come in as outlines, which only its page has इसके प्रकार को रूपरेखा के रूप में जाना जाता है, जो केवल इसके पेज में है +@msg its type is to come in as outlines, which only its page has इसका टाइप आउटलाइन के रूप में आना है, जो केवल इसके पेज में हैं @msg its Windows metafile preview was opened instead, which may simplify the art इसके बजाय विंडोज मेटाफ़ाइल प्रीव्यू ओपन किया गया था, जो कला को सरल बना सकता है @msg its zlib compression is damaged इसके zlib संपीड़न क्षतिग्रस्त है @msg its Zstandard compression is damaged इसके Zstandard संपीड़न क्षतिग्रस्त है @@ -7003,9 +7032,9 @@ antiAlias Sharp शार्प @msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf या crlf @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel लिंक किए गए चित्रों से पता चलता है कि फ़ाइल में सेव किए जाने वाले निम्न-रिज़ॉल्यूशन प्रीव्यू को उनकी फ़ाइलों को मिलने तक: लिंक पैनल में उन्हें फिर से लिंक करें @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel फ़ाइल में कोई प्रीव्यू के साथ जुड़े चित्र एक प्लेसहोल्डर दिखाते हैं जब तक कि उनकी फ़ाइलें नहीं मिलती: उन्हें लिंक पैनल में फिर से लिंक करें -@msg linked type areas past VectorCraft's limits were left out संबंधित प्रकार के क्षेत्र अतीत VectorCraft की सीमाओं को छोड़ दिया गया था +@msg linked type areas past VectorCraft's limits were left out VectorCraft की सीमाओं से आगे के लिंक्ड टाइप एरिया छोड़ दिए गए @msg Live Paint Merge: select a Live Paint group and the paths to add लाइव पेंट मर्ज: जोड़ने के लिए एक लाइव पेंट समूह और पाथ सिलेक्ट करें -@msg Live Paint takes paths and compound paths: convert type, images and other objects to paths first लाइव पेंट पाथ और यौगिक पाथ लेता है: प्रकार, इमेज और अन्य ऑब्जेक्ट्स को पहले पाथ में परिवर्तित करें +@msg Live Paint takes paths and compound paths: convert type, images and other objects to paths first लाइव पेंट पाथ और कंपाउंड पाथ लेता है: पहले टाइप, इमेज और अन्य ऑब्जेक्ट्स को पाथ में बदलें @msg Live Paint: select paths or compound paths लाइव पेंट: पाथ या यौगिक पाथ सिलेक्ट करें @msg Live Paint: these paths are too degenerate to paint लाइव पेंट: इन पाथ को पेंट करने के लिए बहुत अलग किया जाता है @msg loading profiles from disk isn't available in the browser डिस्क से लोडिंग प्रोफ़ाइल ब्राउज़र में उपलब्ध नहीं है @@ -7044,7 +7073,7 @@ antiAlias Sharp शार्प @msg missing `group` लापता `group` @msg missing `id` लापता `id` @msg missing `id` (see plugin.list) अनुपलब्ध `id` (plugin.list देखें) -@msg missing `id`: give one, or select one type object लापता `id`: एक देना, या एक प्रकार का ऑब्जेक्ट चुनें +@msg missing `id`: give one, or select one type object लापता `id`: एक दें, या एक टाइप ऑब्जेक्ट सिलेक्ट करें @msg missing `index` (a point index, or null to clear) लापता `index` (a बिंदु सूचकांक, या स्पष्ट करने के लिए null) @msg missing `index` (a stop index, or null to clear) लापता `index` (a स्टॉप इंडेक्स, या स्पष्ट करने के लिए null) @msg missing `index` (an item index or null) लापता `index` (एक आइटम सूचकांक या null) @@ -7143,7 +7172,7 @@ antiAlias Sharp शार्प @msg nineSlice must be true or false nineSlice को true या false होना चाहिए @msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides को [left, top, right, bottom] होना चाहिए, हर मान -16..16, और left + right < 1 तथा top + bottom < 1 @msg no action set is current कोई कार्रवाई सेट चालू नहीं है -@msg no action set to save बचाने के लिए कोई कार्रवाई सेट नहीं +@msg no action set to save सेव करने के लिए कोई एक्शन सेट नहीं @msg no active data set कोई सक्रिय डेटा सेट नहीं @msg no active document कोई सक्रिय डॉक्यूमेंट @msg no active perspective plane कोई सक्रिय पर्सपेक्टिव विमान नहीं @@ -7174,7 +7203,7 @@ antiAlias Sharp शार्प @msg no changes since the last save आखिरी सेव के बाद से कोई बदलाव नहीं @msg no character at byte {start} byte {start} पर कोई चरित्र नहीं @msg no character style `{name}` कोई कैरेक्टर स्टाइल `{name}` -@msg no clock to keep a heartbeat by दिल की धड़कन को दूर रखने के लिए कोई घड़ी नहीं +@msg no clock to keep a heartbeat by हार्टबीट का समय रखने के लिए कोई घड़ी नहीं @msg no color group `{name}` in the document (see swatch.list) डॉक्यूमेंट में कोई रंग समूह `{name}` (देखें swatch.list) @msg no colour कोई रंग नहीं @msg no colour group `{g}` कोई रंग समूह `{g}` @@ -7216,7 +7245,7 @@ antiAlias Sharp शार्प @msg no free recovery area कोई मुफ्त वसूली क्षेत्र @msg no gradient preset `{name}` (see gradient.presets.list) कोई gradient preset `{name}` (view gradient.presets.list) @msg no gradient stop or freeform point is selected कोई क्रमिक स्टॉप या फ्रीफॉर्म पॉइंट नहीं चुना जाता है -@msg no gradient swatch `{name}` कोई gradient swatch `{name}` +@msg no gradient swatch `{name}` कोई ग्रेडिएंट स्वॉच `{name}` नहीं @msg no graphic style `{n}` कोई ग्राफिक स्टाइल `{n}` @msg no graphic style `{n}` in `{_1}` `{_1}` में कोई ग्राफिक स्टाइल `{n}` नहीं @msg no graphic style `{name}` कोई ग्राफिक स्टाइल `{name}` @@ -7241,6 +7270,7 @@ antiAlias Sharp शार्प @msg no library `{key}` (see {list}) कोई लाइब्रेरी `{key}` ({list} देखें) @msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं @msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है @msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं @msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं @msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट @@ -7252,9 +7282,9 @@ antiAlias Sharp शार्प @msg No paragraph breaks to remove हटाने के लिए कोई पैराग्राफ ब्रेक नहीं @msg no parent layer कोई अभिभावक लेयर नहीं @msg no Pathfinder operation applied yet अभी तक कोई पाथफाइंडर ऑपरेशन लागू नहीं है -@msg no pattern `{name}` कोई पैटर्न नहीं `{name}` +@msg no pattern `{name}` कोई पैटर्न `{name}` नहीं @msg no pattern named `{choice}` `{choice}` नाम का कोई पैटर्न नहीं -@msg no pattern swatch `{name}` कोई पैटर्न नहीं `{name}` +@msg no pattern swatch `{name}` कोई पैटर्न स्वॉच `{name}` नहीं @msg no PDF preset named `{n}` कोई PDF प्रीसेट नहीं है जिसका नाम `{n}` है @msg no perspective grid preset named `{b}` (see perspective.presets.list) `{b}` नाम का कोई पर्सपेक्टिव ग्रिड प्रीसेट नहीं है (perspective.presets.list देखें) @msg no perspective grid preset named `{n}` कोई पर्सपेक्टिव ग्रिड प्रीसेट जिसका नाम `{n}` है @@ -7280,32 +7310,32 @@ antiAlias Sharp शार्प @msg no recovery copy `{f}` (see file.recovery.list) कोई पुनर्प्राप्ति प्रतिलिपि `{f}` (file.recovery.list देखें) @msg no revert command कोई रिवर्स कमांड नहीं @msg no rows कोई पंक्ति नहीं -@msg no saved export preset `{name}` कोई बचाया निर्यात पूर्व निर्धारित `{name}` +@msg no saved export preset `{name}` कोई सेव किया गया निर्यात प्रीसेट `{name}` नहीं @msg no saved export presets to export (name built-in ones in `names`) एक्सपोर्ट करने के लिए सेव किए हुए एक्सपोर्ट प्रीसेट नहीं हैं (अंतर्निहित प्रीसेट के नाम `names` में दें) -@msg no saved gradient preset called {name} कोई बचाया ग्रेडिएंट पूर्व निर्धारित {name} कहा जाता है -@msg no saved gradient presets कोई बचाया ग्रेडिएंट presets -@msg no saved PDF preset named `{name}` कोई बचाया PDF preset नामित `{name}` +@msg no saved gradient preset called {name} {name} नाम का कोई सेव किया गया ग्रेडिएंट प्रीसेट नहीं +@msg no saved gradient presets कोई सेव किया गया ग्रेडिएंट प्रीसेट नहीं +@msg no saved PDF preset named `{name}` `{name}` नाम का कोई सेव किया गया PDF प्रीसेट नहीं @msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) `{name}` नामक कोई सेव्ड PDF प्रीसेट नहीं (बिल्ट-इन प्रीसेट केवल पढ़ा जाता है: उनमें से एक नया प्रारंभ करें) -@msg no saved preset called `{name}` कोई बचाया प्रीसेट `{name}` कहा जाता है +@msg no saved preset called `{name}` `{name}` नाम का कोई सेव किया गया प्रीसेट नहीं @msg no saved preset named `{name}` `{name}` नामक सेव्ड प्रीसेट -@msg no saved preset named `{name}` to rename नाम बदलने के लिए `{name}` नाम की कोई बचत पूर्व निर्धारित नहीं है -@msg no saved presets कोई बचाया प्रीसेट नहीं +@msg no saved preset named `{name}` to rename नाम बदलने के लिए `{name}` नाम का कोई सेव किया गया प्रीसेट नहीं है +@msg no saved presets कोई सेव किया गया प्रीसेट नहीं @msg no saved presets to export (name [Default] in `names` to export it) एक्सपोर्ट करने के लिए सेव किए हुए प्रीसेट नहीं हैं ([Default] एक्सपोर्ट करने के लिए उसका नाम `names` में दें) @msg no saved presets to export (name built-in ones in `names`) एक्सपोर्ट करने के लिए सेव किए हुए प्रीसेट नहीं हैं (अंतर्निहित प्रीसेट के नाम `names` में दें) @msg no saved print preset named `{name}` `{name}` नामक कोई सेव्ड प्रिंट प्रीसेट नहीं @msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) `{name}` ({DEFAULT_PRESET} नामक कोई सेव्ड प्रिंट प्रीसेट संरक्षित नहीं है: इससे एक नया प्रारंभ करें) -@msg no saved profile called {name} कोई बचाया प्रोफ़ाइल {name} कहा जाता है -@msg no saved profiles कोई बचाया प्रोफ़ाइल नहीं -@msg no saved selection `{name}` कोई बचाया सिलेक्शन `{name}` -@msg no saved selection recalled कोई बचाया सिलेक्शन याद नहीं -@msg no saved selections कोई बचाया सिलेक्शन नहीं -@msg No saved selections (Select → Save Selection…) कोई बचाया सिलेक्शन नहीं (चुनें → सिलेक्शन सेव करें) -@msg no saved theme called {name} (see colorTheme.list) कोई बचाया थीम जिसे {name} कहा जाता है (colorTheme.list देखें) -@msg no saved themes कोई बचाया विषयों -@msg no saved views कोई बचाया दृष्टिकोण -@msg No saved views (View → New View…) कोई बचाया दृष्टिकोण (देखें → नया दृश्य) +@msg no saved profile called {name} {name} नाम की कोई सेव की गई प्रोफ़ाइल नहीं +@msg no saved profiles कोई सेव की गई प्रोफ़ाइल नहीं +@msg no saved selection `{name}` कोई सेव किया गया सिलेक्शन `{name}` नहीं +@msg no saved selection recalled कोई सेव किया गया सिलेक्शन वापस नहीं लाया गया +@msg no saved selections कोई सेव किया गया सिलेक्शन नहीं +@msg No saved selections (Select → Save Selection…) कोई सेव किया गया सिलेक्शन नहीं (सिलेक्ट → सिलेक्शन सेव करें…) +@msg no saved theme called {name} (see colorTheme.list) {name} नाम की कोई सेव की गई थीम नहीं (colorTheme.list देखें) +@msg no saved themes कोई सेव की गई थीम नहीं +@msg no saved views कोई सेव किया गया व्यू नहीं +@msg No saved views (View → New View…) कोई सेव किया गया व्यू नहीं (व्यू → नया दृश्य…) @msg no segment {segment} on line {line} कोई खंड {segment} लाइन {line} पर -@msg no selected slice in the image: select slices, or save all of them इमेज में कोई सिलेक्टेड स्लाइस नहीं: स्लाइस सिलेक्ट करें, या उनमें से सभी को बचाओ +@msg no selected slice in the image: select slices, or save all of them इमेज में कोई सिलेक्टेड स्लाइस नहीं: स्लाइस सिलेक्ट करें, या सभी स्लाइस सेव करें @msg no selection bounds कोई सिलेक्शन सीमा नहीं @msg no series {i} कोई श्रृंखला {i} @msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again कोई समान ऑब्जेक्ट नहीं पाया (घूर्णित या परिलक्षित प्रतियां मिलान नहीं की जाती हैं); वैश्विक संपादन ऑप्शन्स बदलें और फिर से प्रयास करें @@ -7335,7 +7365,7 @@ antiAlias Sharp शार्प @msg no such recent file ऐसी कोई हाल की फ़ाइल नहीं @msg no such recent font ऐसा नहीं हाल ही में फ़ॉन्ट @msg no such rows ऐसी कोई पंक्ति नहीं -@msg no such saved selection इस तरह के बचाया सिलेक्शन नहीं +@msg no such saved selection ऐसा कोई सेव किया गया सिलेक्शन नहीं @msg no such segment ऐसा कोई खंड नहीं @msg no such spine point ऐसी कोई रीढ़ की हड्डी नहीं @msg no such spine segment ऐसा कोई खंड नहीं @@ -7343,17 +7373,17 @@ antiAlias Sharp शार्प @msg no such user library ऐसी कोई उपयोगकर्ता लाइब्रेरी नहीं @msg no such view कोई ऐसा दृश्य नहीं @msg no such width point ऐसी कोई चौड़ाई बिंदु नहीं -@msg no swatch `{keep}` नहीं `{keep}` -@msg no swatch `{n}` कोई swatch `{n}` -@msg no swatch `{n}` (swatches and colour groups move separately) कोई swatch `{n}` (swatches और रंग समूहों अलग से स्थानांतरित) -@msg no swatch `{name}` कोई swatch `{name}` -@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) कोई swatch लाइब्रेरी `{key}` (देखें swatch.library.list; "{DOCUMENT_SWATCHES}": डॉक्यूमेंट का swatches) -@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) कोई swatch लाइब्रेरी `{key}` (देखें swatch.library.list; "document": डॉक्यूमेंट के swatches) -@msg no swatch library `{key}` (see swatch.library.list) कोई swatch लाइब्रेरी `{key}` (swatch.library.list देखें) -@msg no swatch library `{l}` (see swatch.library.list) कोई swatch लाइब्रेरी `{l}` (देखें swatch.library.list) -@msg no swatch named `{n}` `{n}` नाम का कोई swatch -@msg no swatch or colour group `{n}` कोई swatch या रंग समूह `{n}` -@msg no swatch or colour group `{n}` in `{_1}` कोई swatch या रंग समूह `{n}` में `{_1}` +@msg no swatch `{keep}` कोई स्वॉच `{keep}` नहीं +@msg no swatch `{n}` कोई स्वॉच `{n}` नहीं +@msg no swatch `{n}` (swatches and colour groups move separately) कोई स्वॉच `{n}` नहीं (स्वॉच और रंग समूह अलग-अलग मूव होते हैं) +@msg no swatch `{name}` कोई स्वॉच `{name}` नहीं +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) कोई स्वॉच लाइब्रेरी `{key}` नहीं (swatch.library.list देखें; "{DOCUMENT_SWATCHES}": डॉक्यूमेंट के स्वॉच) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) कोई स्वॉच लाइब्रेरी `{key}` नहीं (swatch.library.list देखें; "document": डॉक्यूमेंट के स्वॉच) +@msg no swatch library `{key}` (see swatch.library.list) कोई स्वॉच लाइब्रेरी `{key}` नहीं (swatch.library.list देखें) +@msg no swatch library `{l}` (see swatch.library.list) कोई स्वॉच लाइब्रेरी `{l}` नहीं (swatch.library.list देखें) +@msg no swatch named `{n}` `{n}` नाम का कोई स्वॉच नहीं +@msg no swatch or colour group `{n}` कोई स्वॉच या रंग समूह `{n}` नहीं +@msg no swatch or colour group `{n}` in `{_1}` `{_1}` में कोई स्वॉच या रंग समूह `{n}` नहीं @msg no symbol `{n}` कोई सिंबल `{n}` @msg no symbol `{n}` in `{_1}` `{_1}` में कोई सिंबल `{n}` @msg no symbol instances selected सिलेक्टेड कोई सिंबल उदाहरण नहीं @@ -7367,7 +7397,7 @@ antiAlias Sharp शार्प @msg no toolbar named `{name}` कोई टूलबार जिसका नाम `{name}` है @msg no tracing preset `{preset}` (see imageTrace.presets) कोई अनुरेखण पूर्व निर्धारित `{preset}` (imageTrace.presets देखें) @msg no user library folder here यहां कोई उपयोगकर्ता लाइब्रेरी फ़ोल्डर नहीं -@msg no user library folder here (save with a path, or without one to get the data) यहां कोई उपयोगकर्ता लाइब्रेरी फ़ोल्डर नहीं (एक पाथ के साथ या बिना किसी डेटा को प्राप्त करने के लिए) +@msg no user library folder here (save with a path, or without one to get the data) यहाँ कोई यूज़र लाइब्रेरी फ़ोल्डर नहीं है (पाथ के साथ सेव करें, या डेटा पाने के लिए बिना पाथ के) @msg no user workspace `{from}` कोई उपयोगकर्ता वर्कस्पेस `{from}` @msg no variable named `{from}` कोई चर नाम `{from}` @msg no variable named `{name}` कोई चर नाम `{name}` @@ -7424,7 +7454,7 @@ antiAlias Sharp शार्प @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) @msg not an RTF file: it doesn't start with {\\rtf RTF फ़ाइल नहीं: यह {\rtf के साथ शुरू नहीं होता है -@msg not area type (text in a frame) outside perspective नहीं क्षेत्र के प्रकार (एक फ्रेम में टेक्स्ट) बाहर पर्सपेक्टिव +@msg not area type (text in a frame) outside perspective पर्सपेक्टिव के बाहर का एरिया टाइप (फ़्रेम में टेक्स्ट) नहीं है @msg not editing an opacity mask एक opacity मास्क संपादित न करें @msg not found नहीं मिला @msg not in pattern editing mode पैटर्न संपादन मोड में नहीं @@ -7439,7 +7469,7 @@ antiAlias Sharp शार्प @msg nothing to bind बाध्य करने के लिए कुछ नहीं @msg nothing to change बदलने के लिए कुछ भी नहीं @msg nothing to change: give dx, dy, hScale, vScale or rotation कुछ भी बदलने के लिए: देना dx, dy, hScale, vScale या रोटेशन -@msg nothing to crop फसल के लिए कुछ नहीं +@msg nothing to crop क्रॉप करने के लिए कुछ नहीं @msg nothing to distort विकृत करने के लिए कुछ भी नहीं @msg nothing to export निर्यात करने के लिए कुछ नहीं @msg nothing to export: the document has no visible art निर्यात करने के लिए कुछ भी नहीं: डॉक्यूमेंट में कोई दृश्य कला नहीं है @@ -7450,7 +7480,7 @@ antiAlias Sharp शार्प @msg nothing to print: every artboard picked is blank मुद्रित करने के लिए कुछ भी नहीं: हर आर्टबोर्ड चुना खाली है @msg nothing to print: the document has no art that prints प्रिंट करने के लिए कुछ भी नहीं: डॉक्यूमेंट में कोई कला नहीं है जो प्रिंट करता है @msg nothing to redo फिर से करने के लिए कुछ नहीं -@msg nothing to save: the document has no visible art बचाने के लिए कुछ नहीं: डॉक्यूमेंट में कोई दृश्य कला नहीं है +@msg nothing to save: the document has no visible art सेव करने के लिए कुछ नहीं: डॉक्यूमेंट में कोई दिखने वाला आर्टवर्क नहीं है @msg Nothing to split at the cursor कर्सर पर विभाजन करने के लिए कुछ भी नहीं @msg nothing to undo पूर्ववत करने के लिए कुछ नहीं @msg nothing to warp कुछ भी नहीं @@ -7492,11 +7522,11 @@ antiAlias Sharp शार्प @msg only a dock group above or below others collapses, not one beside another केवल दूसरों के ऊपर या नीचे एक डॉक समूह गिर जाता है, एक दूसरे के बगल में नहीं @msg only an exporter or an importer declares `extensions` केवल एक्सपोर्टर या इंपोर्टर `extensions` घोषित करता है @msg only an object of the Global Edit other than the one it started from can be left out केवल ग्लोबल एडिट का एक ऑब्जेक्ट, इसके अलावा इसे शुरू किया जा सकता है, छोड़ दिया जा सकता है -@msg only groups, layers, type and images have a Contents (Characters) row केवल समूहों, लेयर्स, प्रकार और इमेज में एक सामग्री (Character) पंक्ति है +@msg only groups, layers, type and images have a Contents (Characters) row केवल समूहों, लेयर्स, टाइप और इमेज में सामग्री (कैरेक्टर्स) पंक्ति होती है @msg only http, https and mailto URLs open in the browser, not “{url}” केवल http, https और Mailto URL ब्राउज़र में खुलते हैं, न कि "{url}" @msg only interior mesh lines can be deleted केवल आंतरिक जाल लाइनों को हटाया जा सकता है @msg only solid colours can be added (not gradients or patterns) केवल ठोस रंगों को जोड़ा जा सकता है (ग्रेडिएंट या पैटर्न नहीं) -@msg only solid-colour swatches can be spot colours केवल ठोस रंग की घड़ी स्पॉट रंग हो सकती है +@msg only solid-colour swatches can be spot colours केवल ठोस रंग वाले स्वॉच स्पॉट रंग हो सकते हैं @msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning केवल इस Affinity फ़ाइल के एम्बेडेड प्रीव्यू को पढ़ा जा सकता है; फ़ाइल का उपयोग करें > इसे अपनी चेतावनी के साथ देखने के लिए ओपन करें @msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first केवल इस Affinity फ़ाइल के एम्बेडेड प्रीव्यू को पढ़ा जा सकता है: फ़ाइल का उपयोग करें > इसे अपनी चेतावनी के साथ देखने के लिए ओपन करें, या पहले Affinity से SVG या PDF निर्यात करें @msg onto must name a floating panel onto: पर एक फ्लोटिंग पैनल का नाम होना चाहिए @@ -7533,10 +7563,10 @@ antiAlias Sharp शार्प @msg page {page}: `{name}` has {n} {pages} पेज {page}: `{name}` में {n} {pages} हैं @msg page art conflicts with image data for {key} {key} के लिए इमेज डेटा के साथ पृष्ठ कला संघर्ष @msg page art conflicts with pattern {name} पैटर्न {name} के साथ पृष्ठ कला संघर्ष -@msg page art conflicts with swatch {name} Swatch {name} के साथ पृष्ठ कला संघर्ष +@msg page art conflicts with swatch {name} पेज आर्टवर्क स्वॉच {name} से मेल नहीं खाता @msg page art is missing image data for {key} पृष्ठ कला {key} के लिए इमेज डेटा लापता है @msg page art is missing pattern {name} पृष्ठ कला लापता पैटर्न {name} है -@msg page art is missing swatch {name} पृष्ठ कला लापता है swatch {name} +@msg page art is missing swatch {name} पेज आर्टवर्क में स्वॉच {name} नहीं है @msg page must be a whole number from 1 page: पृष्ठ 1 से एक पूरी संख्या होना चाहिए @msg page size {w} × {h} {w} × {h} @msg page thumbnail: {e} पेज थंबनेल: {e} @@ -7574,7 +7604,7 @@ antiAlias Sharp शार्प @msg pdfs `{m}`: single or multiple pdfs `{m}`: एकल या एकाधिक @msg percent must be above 0 and at most 6400, not {p} percent: प्रतिशत 0 से अधिक होना चाहिए और अधिक 6400 पर, नहीं {p} @msg Persistent libraries not found: {files} Persistent लाइब्रेरीों नहीं मिला: {files} -@msg pick a colour, gradient or pattern to save as a swatch एक swatch के रूप में बचाने के लिए एक रंग, ग्रेडिएंट या पैटर्न चुनें +@msg pick a colour, gradient or pattern to save as a swatch स्वॉच के रूप में सेव करने के लिए रंग, ग्रेडिएंट या पैटर्न चुनें @msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom को "artwork" या "swatches" होना चाहिए, {v} नहीं @msg pins and moved must be non-empty lists of the same length पिन और ले जाने के लिए समान लंबाई की गैर-खाली सूची होनी चाहिए @msg pins must be [[x,y]…] pins: पिन होना चाहिए [[x,y]...] @@ -7643,15 +7673,16 @@ antiAlias Sharp शार्प @msg release a layer or a group एक लेयर या एक समूह को छोड़ दें @msg Release Guides: no guides रिलीज़ गाइड: कोई गाइड नहीं @msg Release: no layer to put the rectangle in रिलीज: आयत को डालने के लिए कोई लेयर नहीं -@msg Reload the page to go on (changes not saved yet may be lost). पेज को फिर से लोड करने के लिए (बदलने की अभी तक नहीं बचा जा सकता)। +@msg Reload the page to go on (changes not saved yet may be lost). जारी रखने के लिए पेज फिर से लोड करें (जो बदलाव अभी सेव नहीं हुए, वे खो सकते हैं)। @msg replace keeps the replaced object's place and transform: drop template, at and rect प्रतिस्थापित ऑब्जेक्ट के स्थान को बनाए रखता है और बदल देता है: ड्रॉप टेम्पलेट, पर और आयतन @msg Replace Spine: select a blend and a path Spine: एक मिश्रण और एक पाथ चुनें @msg replace: select the one object to replace प्रतिस्थापित करने के लिए एक ऑब्जेक्ट सिलेक्ट करें @msg replace: the object to replace was changed or deleted प्रतिस्थापित करने के लिए ऑब्जेक्ट को बदल दिया गया था या हटा दिया गया +@msg the document it was prepared for is no longer active जिस डॉक्यूमेंट के लिए इसे तैयार किया गया था, वह अब सक्रिय नहीं है @msg Reset {_1} रीसेट करें {_1} @msg resolution must be a number or screen|medium|high resolution: रिज़ॉल्यूशन एक नंबर या स्क्रीन होना चाहिए -@msg resolution must be between 1 and 2400 ppi resolution: संकल्प 1 और 2400 ppi के बीच होना चाहिए -@msg resolutions must be between 1 and 2400 ppi resolutions: संकल्प 1 और 2400 ppi के बीच होना चाहिए +@msg resolution must be between 1 and 2400 ppi resolution: रिज़ॉल्यूशन 1 और 2400 ppi के बीच होना चाहिए +@msg resolutions must be between 1 and 2400 ppi resolutions: रिज़ॉल्यूशन 1 और 2400 ppi के बीच होना चाहिए @msg result would exceed the canvas (coordinates out of range) परिणाम कैनवास से अधिक होगा (श्रेणी से बाहर समन्वय) @msg rotation must be a number of degrees rotation: रोटेशन डिग्री की एक संख्या होना चाहिए @msg rotation must be finite rotation: रोटेशन को परिमित होना चाहिए @@ -7675,14 +7706,14 @@ antiAlias Sharp शार्प @msg Saving सेव किया जा रहा है @msg Saving {_1} बचत {_1} @msg Saving recovery data for {_1} {_1} के लिए रिकवरी डेटा सेव करना -@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) पैमाने `{s}`: एक कारक जैसे 2x, चौड़ाई (100w), ऊंचाई (100h) या एक संकल्प (72ppi) +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) पैमाने `{s}`: एक कारक जैसे 2x, चौड़ाई (100w), ऊंचाई (100h) या एक रिज़ॉल्यूशन (72ppi) @msg scale must be a number above 0, at most 64 scale: पैमाने 0 से अधिक होना चाहिए, अधिकांश 64 में @msg scale must be a number from {_1} to {_2}, not {_3} scale: स्केल {_1} से {_2} के लिए एक नंबर होना चाहिए, नहीं {_3} @msg scale must be a number from {_1} to {_2}, not {s} scale: स्केल {_1} से {_2} के लिए एक नंबर होना चाहिए, नहीं {s} @msg Scale must be drawing:real, for example 1:10 स्केल ड्राइंग होना चाहिए:वास्तविक, उदाहरण के लिए 1:10 @msg scale must be non-zero scale: स्केल होना चाहिए गैर-zero @msg Scale ratio must be between 1:1000000 and 1000000:1 स्केल अनुपात 1:1000000 और 1000000:1 के बीच होना चाहिए -@msg Scale Text Attributes: selected type has no scale to bake स्केल टेक्स्ट एट्रिब्यूट्स: सिलेक्टेड प्रकार में बेक करने के लिए कोई स्केल नहीं है +@msg Scale Text Attributes: selected type has no scale to bake स्केल टेक्स्ट एट्रिब्यूट्स: सिलेक्टेड टाइप में बेक करने के लिए कोई स्केल नहीं है @msg scope must be selection or all (got {other}) scope: गुंजाइश का सिलेक्शन होना चाहिए या सभी ({other}) होना चाहिए। @msg scribble is too long स्क्रिबल बहुत लंबा है @msg searching a folder isn't available on the web एक फ़ोल्डर खोज वेब पर उपलब्ध नहीं है @@ -7731,10 +7762,11 @@ antiAlias Sharp शार्प @msg select an object with a freeform gradient एक फ्रीफॉर्म ग्रेडिएंट के साथ एक ऑब्जेक्ट सिलेक्ट करें @msg select an object with an opacity mask एक opacity मास्क के साथ एक ऑब्जेक्ट सिलेक्ट करें @msg select an Objects on Path group पाथ समूह पर एक ऑब्जेक्ट चुनें -@msg select area type क्षेत्र प्रकार सिलेक्ट करें -@msg select area type (text in a frame) क्षेत्र प्रकार (एक फ्रेम में टेक्स्ट) सिलेक्ट करें -@msg select area type and closed paths (at least one area type object) क्षेत्र प्रकार और बंद पाथ (कम से कम एक क्षेत्र प्रकार ऑब्जेक्ट) सिलेक्ट करें -@msg select area type whose first line can fit its frame क्षेत्र के प्रकार सिलेक्ट करें जिनकी पहली पंक्ति अपने फ्रेम को फिट कर सकती है +@msg choose the object to detach with its Select/Move widget अलग करने वाले ऑब्जेक्ट को उसके चुनें/ले जाएँ विजेट से चुनें +@msg select area type एरिया टाइप सिलेक्ट करें +@msg select area type (text in a frame) एरिया टाइप (फ़्रेम में टेक्स्ट) सिलेक्ट करें +@msg select area type and closed paths (at least one area type object) एरिया टाइप और बंद पाथ सिलेक्ट करें (कम से कम एक एरिया टाइप ऑब्जेक्ट) +@msg select area type whose first line can fit its frame ऐसा एरिया टाइप सिलेक्ट करें जिसकी पहली लाइन उसके फ़्रेम में आ सके @msg select artwork (or a colour group) to recolor फिर से रंग करने के लिए आर्टवर्क (या एक रंग समूह) सिलेक्ट करें @msg Recolor completed; {locked} and {hidden} were left unchanged रीकलर पूरा हुआ; ये अपरिवर्तित रहे: {locked} और {hidden} @msg Recolor completed; {objects} were left unchanged रीकलर पूरा हुआ; ये अपरिवर्तित रहे: {objects} @@ -7750,7 +7782,7 @@ antiAlias Sharp शार्प @msg Select dimensions to update अद्यतन करने के लिए आयाम सिलेक्ट करें @msg select exactly two objects वास्तव में दो ऑब्जेक्ट चुनें @msg select exactly two path ends to join वास्तव में दो पाथ में शामिल होने के लिए समाप्त होता है चुनें -@msg Select horizontal point or area type whose text fits क्षैतिज बिंदु या क्षेत्र के प्रकार सिलेक्ट करें जिसका टेक्स्ट फिट बैठता है +@msg Select horizontal point or area type whose text fits ऐसा क्षैतिज पॉइंट टाइप या एरिया टाइप सिलेक्ट करें जिसका टेक्स्ट फ़िट होता है @msg select instances of one symbol एक सिंबल के उदाहरण चुनें @msg select members of a compound shape एक मिश्रित आकार के सदस्यों सिलेक्ट करें @msg select objects or give `color` ऑब्जेक्ट्स सिलेक्ट करें या `color` देना @@ -7770,13 +7802,13 @@ antiAlias Sharp शार्प @msg select paths with dashed strokes to expand विस्तृत करने के लिए डैश स्ट्रोक के साथ पाथ सिलेक्ट करें @msg select paths, or segments, to divide विभाजित करने के लिए पाथ, या खंड चुनें @msg select plain rectangles, ellipses or regular polygons to convert बदलने के लिए सादे आयत, अंडाकार या नियमित बहुभुजों सिलेक्ट करें -@msg Select point type बिंदु प्रकार चुनें +@msg Select point type पॉइंट टाइप सिलेक्ट करें @msg select rows or objects to collect इकट्ठा करने के लिए पंक्तियों या ऑब्जेक्ट्स सिलेक्ट करें @msg select segments, or paths, to add points to खंडों, या पाथ का सिलेक्ट करने के लिए, अंक जोड़ने के लिए -@msg select some type first पहले कुछ प्रकार का सिलेक्शन +@msg select some type first पहले कुछ टाइप सिलेक्ट करें @msg select something to export निर्यात करने के लिए कुछ का सिलेक्शन @msg select something to export (or turn off selectedOnly) एक्सपोर्ट करने के लिए कुछ चुनें (या selectedOnly बंद करें) -@msg Select text or click into text with the Type tool to insert glyphs टेक्स्ट सिलेक्ट करें या glyphs डालने के लिए टाइप टूल के साथ टेक्स्ट में क्लिक करें +@msg Select text or click into text with the Type tool to insert glyphs टेक्स्ट सिलेक्ट करें या ग्लिफ़ डालने के लिए टाइप टूल से टेक्स्ट में क्लिक करें @msg select text to redefine the style from से स्टाइल को फिर से परिभाषित करने के लिए टेक्स्ट सिलेक्ट करें @msg select the art and, on top of it, the mask object कला सिलेक्ट करें और इसके शीर्ष पर, मास्क ऑब्जेक्ट @msg select the art for the design डिजाइन के लिए कला का सिलेक्शन @@ -7800,8 +7832,8 @@ antiAlias Sharp शार्प @msg select two or more objects to distribute वितरित करने के लिए दो या दो से अधिक ऑब्जेक्ट चुनें @msg select two or more objects to intertwine दो या दो से अधिक ऑब्जेक्ट्स सिलेक्ट करें @msg Select two or more objects, or a group दो या अधिक ऑब्जेक्ट चुनें, या एक समूह चुनें -@msg Select two or more one-line point type objects on one baseline to combine गठबंधन करने के लिए एक बेसलाइन पर दो या एक से अधिक बिंदु प्रकार की ऑब्जेक्टएं चुनें -@msg Select two or more one-line point type objects to join शामिल होने के लिए दो या दो से अधिक एक लाइन बिंदु प्रकार के ऑब्जेक्ट चुनें +@msg Select two or more one-line point type objects on one baseline to combine मिलाने के लिए एक ही बेसलाइन पर दो या अधिक एक-लाइन पॉइंट टाइप ऑब्जेक्ट सिलेक्ट करें +@msg Select two or more one-line point type objects to join जोड़ने के लिए दो या अधिक एक-लाइन पॉइंट टाइप ऑब्जेक्ट सिलेक्ट करें @msg select two or more slices to combine गठबंधन करने के लिए दो या दो से अधिक स्लाइस चुनें @msg select type in perspective पर्सपेक्टिव में टाइप करें @msg select type on a path एक पाथ पर टाइप करें @@ -7821,14 +7853,14 @@ antiAlias Sharp शार्प @msg Show All Hidden Attributes: nothing is hidden Show all Hidden Attributes: कुछ भी छिपा नहीं है @msg show must be true or false show को true या false होना चाहिए @msg Simplify Anchors: the paths have too many anchors to simplify at once एंकर को सरलीकृत करें: पाथ में एक बार में सरल बनाने के लिए कई एंकर हैं -@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "small", "medium", "large" या 0 से अधिक अंक {_1} तक होना चाहिए +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "small", "medium", "large" या पॉइंट में 0 से अधिक और {_1} तक की संख्या होना चाहिए @msg size must be positive size: आकार सकारात्मक होना चाहिए @msg sizeMode is "fixed" or "pressure", not {other} sizeMode को "fixed" या "pressure" होना चाहिए, {other} नहीं @msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode को "fixed" या "pressure" होना चाहिए, `{other}` नहीं @msg sizeVariation must be 0 or more sizeVariation को 0 या अधिक होना चाहिए @msg smallCaps must be true or false smallCaps को true या false होना चाहिए @msg some text is hidden or locked, so its font wasn't replaced कुछ टेक्स्ट छिपा हुआ है या बंद कर दिया गया है, इसलिए इसका फ़ॉन्ट प्रतिस्थापित नहीं किया गया था -@msg sort `{x}`: name, kind or status टाइप `{x}`: नाम, प्रकार या स्थिति +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind या status होना चाहिए @msg source must be the id of a Shaper Group's original shape source: स्रोत एक शेपर ग्रुप के मूल आकार का आईडी होना चाहिए @msg source paths are too large for Shaper स्रोत पाथ शेपर के लिए बहुत बड़े हैं @msg span must be "object" or "selection", got {v} span: स्पैन को "object" या "selection" होना चाहिए, {v} मिला @@ -7921,7 +7953,9 @@ antiAlias Sharp शार्प @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` का face, front या back होना चाहिए @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` का {key}, {lo} से {hi} तक की संख्या होना चाहिए @msg `{GRAPHICS}` must be an array `{GRAPHICS}` एक ऐरे होना चाहिए -@msg Graphics map onto planar faces: use the Extrude or Plane object type ग्राफ़िक्स समतल फ़ेस पर लगते हैं: एक्सट्रूड या समतल ऑब्जेक्ट प्रकार का उपयोग करें +@msg Graphics map onto the Plane, Extrude and Revolve object types ग्राफ़िक्स समतल, एक्सट्रूड और रिवॉल्व ऑब्जेक्ट प्रकारों पर लगते हैं +@msg Revolve maps graphics onto its side: map them onto the front रिवॉल्व ग्राफ़िक्स को अपनी बगल वाली सतह पर लगाता है: उन्हें सामने वाले फ़ेस पर लगाएँ +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments ग्राफ़िक्स इस सतह के लिए बहुत जटिल हैं: सिंबल्स के आर्ट को सरल बनाएँ या सेगमेंट कम करें @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` अधिकतम {MAX_GRAPHICS} ग्राफ़िक्स लेता है @msg A plane has one face: map its graphics onto the front समतल का एक ही फ़ेस होता है: उसके ग्राफ़िक्स को सामने वाले फ़ेस पर लगाएँ @msg The graphics are too complex: simplify the symbols' art ग्राफ़िक्स बहुत जटिल हैं: सिंबल्स के आर्ट को सरल बनाएँ @@ -7961,7 +7995,7 @@ antiAlias Sharp शार्प @msg the Color Picker is not open रंग पिकर खुला नहीं है @msg the compound shape is too complex to expand मिश्रित आकार का विस्तार करने के लिए बहुत जटिल है @msg the compressed file unpacks to more than {_1} MB संकुचित फ़ाइल {_1} MB से अधिक के लिए अनपैक -@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) संकुचित फ़ाइल unpacks से अधिक वेक्टर क्रेफ्ट reads ({_1} MB of document, {_2} MB per image) +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) संपीड़ित फ़ाइल खुलकर उससे बड़ी हो जाती है जितना VectorCraft पढ़ता है (डॉक्यूमेंट के {_1} MB, हर इमेज के {_2} MB) @msg the container of the listed objects is gone सूचीबद्ध ऑब्जेक्ट्स का कंटेनर चला गया है @msg the copy `{user_id}` can't be read as a library प्रतिलिपि `{user_id}` को लाइब्रेरी के रूप में नहीं पढ़ा जा सकता है। @msg the copy would leave the canvas प्रतिलिपि कैनवास को छोड़ देगी @@ -7973,16 +8007,16 @@ antiAlias Sharp शार्प @msg the design is too big to draw in every column of this graph डिजाइन इस ग्राफ़ के हर स्तंभ में आकर्षित करने के लिए बहुत बड़ा है @msg the dialog it was opened over is no longer open जिस डायलॉग के ऊपर इसे ओपन किया गया था, वह अब खुला नहीं है @msg the dictionary stack overflowed शब्दकोश स्टैक अतिप्रवाह -@msg the document has never been saved कभी नहीं बचाया गया है -@msg the document has never been saved: give `folder` डॉक्यूमेंट को कभी बचाया नहीं गया है: `folder` देना +@msg the document has never been saved डॉक्यूमेंट कभी सेव नहीं किया गया है +@msg the document has never been saved: give `folder` डॉक्यूमेंट कभी सेव नहीं किया गया है: `folder` दें @msg the document has no artboard डॉक्यूमेंट में कोई आर्टबोर्ड नहीं है @msg the document has no artboards डॉक्यूमेंट में कोई आर्टबोर्ड नहीं है -@msg the document has no brushes to save डॉक्यूमेंट को बचाने के लिए कोई ब्रश नहीं है +@msg the document has no brushes to save डॉक्यूमेंट में सेव करने के लिए कोई ब्रश नहीं है @msg the document has no patterns डॉक्यूमेंट में कोई पैटर्न नहीं है @msg the document has no slices डॉक्यूमेंट में कोई स्लाइस नहीं है @msg the document has no symbols डॉक्यूमेंट में कोई सिंबल नहीं है @msg the document has no symbols to save डॉक्यूमेंट में सेव करने के लिए कोई सिंबल नहीं है -@msg the document is too large to save compressed ({0}): save it with Use Compression off डॉक्यूमेंट संपीड़ित ({0}) को बचाने के लिए बहुत बड़ा है: इसे बंद संपीड़न का उपयोग करें +@msg the document is too large to save compressed ({0}): save it with Use Compression off डॉक्यूमेंट संपीड़ित करके सेव करने के लिए बहुत बड़ा है ({0}): इसे संपीड़न का उपयोग करें बंद करके सेव करें @msg the drawing has no layout `{_1}` (it has {_2}) ड्राइंग में कोई लेआउट नहीं है `{_1}` (इसमें {_2} है) @msg the drawing is no longer loaded ड्राइंग अब लोड नहीं है @msg the drawing is too large to open ड्राइंग ओपन करने के लिए बहुत बड़ा है @@ -7996,11 +8030,11 @@ antiAlias Sharp शार्प @msg the export returned unreadable data निर्यात अपठनीय डेटा वापस आ गया @msg the export wrote no file निर्यात ने कोई फ़ाइल नहीं लिखी @msg the file holds no `variableSets` element फ़ाइल में `variableSets` एलिमेंट नहीं है -@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG फ़ाइल केवल एक प्लेसहोल्डर पेज रखती है, इसकी कला नहीं है (यह PDF संगतता के बिना बचाया गया था): इसे फिर से PDF संगतता के साथ सेव करें, या PDF या SVG के रूप में +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG फ़ाइल में केवल एक प्लेसहोल्डर पेज है, उसका आर्टवर्क नहीं (इसे PDF संगतता के बिना सेव किया गया था): इसे PDF संगतता चालू करके फिर से सेव करें, या PDF या SVG के रूप में सेव करें @msg the file is larger than {_1} MB फ़ाइल {_1} MB से बड़ा है @msg the file is too large फ़ाइल बहुत बड़ी है @msg the file stopped being read (please report this bug) फ़ाइल को पढ़ना बंद कर दिया गया (कृपया इस बग की रिपोर्ट करें) -@msg the file was read up to an error ({why}): what follows it was left out फ़ाइल को एक त्रुटि ({why}) तक पढ़ा गया था: इस प्रकार यह छोड़ दिया गया था +@msg the file was read up to an error ({why}): what follows it was left out फ़ाइल एक त्रुटि ({why}) तक पढ़ी गई: उसके बाद का हिस्सा छोड़ दिया गया @msg the file was updated or encrypted already फ़ाइल को पहले से ही अद्यतन या एन्क्रिप्ट किया गया था @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer फ़ाइल की लेयरें नहीं पढ़ी गईं ({_1}): यह अपने पृष्ठ के रूप में आता है, एक लेयर में @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part फ़ाइल की लेयर्स को इसके संपादन डेटा ({_1}) से नहीं पढ़ा गया था: वे अपने PDF भाग से आते हैं @@ -8086,7 +8120,7 @@ antiAlias Sharp शार्प @msg the operator needs more values than the stack holds ऑपरेटर को स्टैक की तुलना में अधिक मूल्यों की आवश्यकता होती है @msg the package came back damaged पैकेज वापस क्षतिग्रस्त हो गया @msg the page has no area पृष्ठ का कोई क्षेत्र नहीं है -@msg the page's type in an unread object's box isn't in its page art पृष्ठ का प्रकार एक अपठित ऑब्जेक्ट के बॉक्स में नहीं है इसके पृष्ठ कला में +@msg the page's type in an unread object's box isn't in its page art किसी न पढ़े गए ऑब्जेक्ट के बॉक्स में मौजूद पेज का टाइप उसके पेज आर्टवर्क में नहीं है @msg the paint behind the proxy is not a gradient प्रॉक्सी के पीछे पेंट एक ग्रेडिएंट नहीं है @msg the parent must be a layer or sublayer माता-पिता को एक लेयर या सबलायर होना चाहिए @msg the path can't be extended पाथ को बढ़ाया नहीं जा सकता @@ -8114,7 +8148,7 @@ antiAlias Sharp शार्प @msg the Pixar file's header is damaged पिक्सार फ़ाइल का हेडर क्षतिग्रस्त हो गया है @msg the Pixar file's image data is damaged Pixar फ़ाइल की इमेज डेटा क्षतिग्रस्त हो गया है @msg The point type has no transform to reset पॉइंट टाइप में रीसेट करने के लिए कोई परिवर्तन नहीं होता है -@msg The point type is aligned that way already बिंदु प्रकार को पहले से ही संरेखित किया जाता है +@msg The point type is aligned that way already पॉइंट टाइप पहले से ही उसी तरह अलाइन है @msg the pointer is beyond the plane's horizon सूचक विमान के क्षितिज से परे है @msg the preview image is empty प्रीव्यू इमेज खाली है @msg the print job has no data प्रिंट नौकरी के पास कोई डेटा नहीं है @@ -8122,7 +8156,7 @@ antiAlias Sharp शार्प @msg the program ends a dictionary it didn't begin कार्यक्रम एक शब्दकोश को समाप्त करता है यह शुरू नहीं हुआ था @msg the program goes past one of the reader's limits कार्यक्रम रीडर की सीमाओं में से एक के पास जाता है @msg the program opens a file, which the reader can't do कार्यक्रम एक फ़ाइल खोलता है, जिसे टेक्स्टक नहीं कर सकता -@msg the program restores a saved state that was already restored कार्यक्रम एक बचाया राज्य है कि पहले से ही बहाल किया गया था बहाल कर देता है +@msg the program restores a saved state that was already restored प्रोग्राम एक सेव की गई स्थिति को फिर से बहाल करता है, जो पहले ही बहाल हो चुकी थी @msg the program runs too long कार्यक्रम बहुत लंबे समय तक चलता है @msg the program runs, deletes or renames a file, which the reader can't do कार्यक्रम एक फ़ाइल को चलाता है, हटाता है या उसका नाम बदलता है, जिसे टेक्स्टक नहीं कर सकता @msg the program uses too much memory कार्यक्रम बहुत ज्यादा मेमोरी का उपयोग करता है @@ -8136,7 +8170,7 @@ antiAlias Sharp शार्प @msg the rectangle needs a size and must lie on the canvas आयत एक आकार की जरूरत है और कैनवास पर झूठ होना चाहिए @msg the replacement font is not available प्रतिस्थापन फ़ॉन्ट उपलब्ध नहीं है @msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF फ़ाइल में कई पैराग्राफ या प्रारूपण परिवर्तन होते हैं जो टाइप (अधिकांश {MAX_PARAGRAPHS} पैराग्राफ और {MAX_RUNS} रन पर) होते हैं। -@msg the RTF file is too large to place as type (at most {_1} MB) RTF फ़ाइल बहुत बड़े प्रकार के रूप में जगह है (अधिकांश {_1} MB पर) +@msg the RTF file is too large to place as type (at most {_1} MB) RTF फ़ाइल टाइप के रूप में प्लेस करने के लिए बहुत बड़ी है (अधिकतम {_1} MB) @msg the runtime panicked रनटाइम क्रैश हो गया @msg the Save PDF dialog didn't open सेव करें PDF संवाद खुला नहीं था @msg the scale is not a usable number स्केल एक प्रयोग योग्य संख्या नहीं है @@ -8183,7 +8217,7 @@ antiAlias Sharp शार्प @msg the top object must be a path, compound path or text object to use as a clipping mask शीर्ष ऑब्जेक्ट एक क्लिपिंग मास्क के रूप में उपयोग करने के लिए एक पाथ, यौगिक पाथ या टेक्स्ट ऑब्जेक्ट होना चाहिए @msg the trace was cancelled निशान रद्द कर दिया गया था @msg the trailer has no size ट्रेलर में कोई आकार नहीं है -@msg the type object is flattened to nothing प्रकार ऑब्जेक्ट कुछ भी नहीं करने के लिए flattened है +@msg the type object is flattened to nothing फ़्लैटन करने पर टाइप ऑब्जेक्ट में कुछ नहीं रहता @msg the value of axis `{tag}` must be a number or null धुरी का मूल्य `{tag}` होना चाहिए एक नंबर या null होना चाहिए @msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_DATASETS} डेटा सेट से अधिक घोषित करता है @msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities वेरिएबल लाइब्रेरी {MAX_LIBRARY_ENTITIES} संस्थाओं से अधिक की घोषणा करती है। @@ -8206,12 +8240,12 @@ antiAlias Sharp शार्प @msg the Word document is encrypted or protected with a password; save it without one to place it वर्ड डॉक्यूमेंट को एन्क्रिप्ट किया गया है या पासवर्ड के साथ संरक्षित किया गया है; इसे बिना किसी स्थान के सेव करें @msg the Word document is too large to place (its {name} stream holds more than {_1} MB) वर्ड डॉक्यूमेंट बहुत बड़ा होता है (यह {name} स्ट्रीम {_1} MB से अधिक रखता है) @msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off वर्ड डॉक्यूमेंट को फास्ट सेव के साथ आखिरी सेव किया गया था, जो VectorCraft को पढ़ा नहीं जाता है; इसे फिर से फास्ट सेव के साथ सेव करना बंद हो गया -@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document वर्ड डॉक्यूमेंट को वर्ड 6.0 या वर्ड 95 द्वारा बचाया गया था, जो VectorCraft को पढ़ा नहीं जाता है; इसे वर्ड 97-2003 डॉक्यूमेंट के रूप में सेव करें +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document Word डॉक्यूमेंट Word 6.0 या Word 95 से सेव किया गया था, जिसे VectorCraft नहीं पढ़ता; इसे Word 97-2003 डॉक्यूमेंट के रूप में सेव करें @msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place वर्ड डॉक्यूमेंट का टेक्स्ट {MAX_PIECES} टुकड़ों से भी अधिक है, बहुत से जगह @msg the written PDF has no catalog लिखित PDF में कोई कैटलॉग नहीं है @msg the written PDF has no cross-reference table लिखित PDF में कोई क्रॉस-रिफरेंस टेबल नहीं है @msg the written PDF's metadata can't be edited PDF के मेटाडाटा को संपादित नहीं किया जा सकता है -@msg there are no colour swatches to draw आकर्षित करने के लिए कोई रंग नहीं है +@msg there are no colour swatches to draw ड्रॉ करने के लिए कोई रंग स्वॉच नहीं है @msg there are no objects to release जारी करने के लिए कोई ऑब्जेक्ट नहीं है @msg there are no printer settings to open here यहां ओपन करने के लिए कोई प्रिंटर सेटिंग नहीं है @msg there is already a design named `{name}` there: पहले से ही `{name}` नामक एक डिज़ाइन है। @@ -8223,12 +8257,12 @@ antiAlias Sharp शार्प @msg there is no library: create one first वहाँ कोई लाइब्रेरी नहीं है: पहले एक बनाना @msg these paths are too degenerate for Shaper इन पाथ को शेपर के लिए बहुत अलग किया जाता है @msg thickness must be from 0.01 to 5000 pt thickness: मोटाई 0.01 से 5000 तक होना चाहिए -@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) इस फ़ाइल को PDF संगतता के बिना बचाया गया था, और इसके संपादन डेटा को पढ़ा नहीं जा सकता ({why}) +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) यह फ़ाइल PDF संगतता के बिना सेव की गई थी, और इसका एडिटिंग डेटा पढ़ा नहीं जा सकता ({why}) @msg this file's PostScript couldn't be read ({why}): {what} इस फ़ाइल के पोस्टस्क्रिप्ट को पढ़ा नहीं जा सकता ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens इस फ़ॉर्मैट में एक आर्टबोर्ड रहता है: कई आर्टबोर्ड PDF में या document.exportForScreens से एक्सपोर्ट करें @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that this: यह एक डीडब्ल्यूजी ड्राइंग है, जिसे पढ़ा नहीं जा सकता है: इसे सीएडी ऐप से ASCII DXF के रूप में सेव करें और इसे ओपन करें कि -@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS this: यह एक पोस्टस्क्रिप्ट फ़ाइल है (एक .ai एक पुराने फ़ॉर्मैट में या बिना PDF संगतता, या एक ईपीएस फ़ाइल में बचाया), एक PDF नहीं: फ़ाइल> खुला और फ़ाइल> इसे ईपीएस के रूप में पढ़े -@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG this: यह एक पोस्टस्क्रिप्ट फ़ाइल है (एक .ai एक पुराने फ़ॉर्मैट में या बिना PDF संगतता, या एक ईपीएस फ़ाइल में बचाया), जिसे अभी तक ओपन किया नहीं जा सकता है: इसे PDF, PDF-संगत .ai या SVG के रूप में सेव करें +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS यह एक PostScript फ़ाइल है (पुराने फ़ॉर्मैट में या PDF संगतता के बिना सेव की गई .ai, या EPS फ़ाइल), PDF नहीं: फ़ाइल › ओपन और फ़ाइल › प्लेस इसे EPS के रूप में पढ़ते हैं +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG यह एक PostScript फ़ाइल है (पुराने फ़ॉर्मैट में या PDF संगतता के बिना सेव की गई .ai, या EPS फ़ाइल), जिसे अभी ओपन नहीं किया जा सकता: इसे PDF, PDF-संगत .ai या SVG के रूप में सेव करें @msg this is not a PostScript file this: यह पोस्टस्क्रिप्ट फ़ाइल नहीं है @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead यह एक फ्लैट रंग का लोगो नहीं है ({_1}% अपने ठोस क्षेत्रों में कुछ फ्लैट रंगों से मेल खाता है), इसलिए फ्लैट लोगो मोड अपने ग्रेडिएंट को पैच में बदल देगा: इसके बजाय कलर मोड का उपयोग करें @msg this kind of brush has no options dialog yet इस तरह के ब्रश में अभी तक कोई ऑप्शन्स संवाद नहीं है @@ -8258,10 +8292,10 @@ antiAlias Sharp शार्प @msg too many objects selected (at most 20000) बहुत सारे ऑब्जेक्ट्स सिलेक्टेड (अधिकांश 20000 पर) @msg too many overlapping paths for Shaper शेपर के लिए कई ओवरलैपिंग पाथ @msg Too many pieces to make at once बहुत सारे टुकड़े एक बार में बनाने के लिए -@msg too many saved clips कई बचाया क्लिप -@msg too many saved graphics states कई बचाया ग्राफिक्स राज्यों +@msg too many saved clips बहुत अधिक सेव किए गए क्लिप +@msg too many saved graphics states बहुत अधिक सेव की गई ग्राफ़िक्स स्टेट्स @msg too many Shaper edits बहुत सारे शेपर संपादन -@msg Too many type objects to change at once एक बार में बदलने के लिए कई प्रकार की ऑब्जेक्टएं +@msg Too many type objects to change at once एक साथ बदलने के लिए बहुत अधिक टाइप ऑब्जेक्ट्स @msg tool (a tool id) is required टूल (एक टूल आईडी) की आवश्यकता होती है @msg tool shortcuts must be a single key (optionally with Shift) टूल शॉर्टकट एक कुंजी होने चाहिए (वैकल्पिक रूप से Shift के साथ) @msg toolbar (a toolbar's name) must be a string टूलबार (एक टूलबार का नाम) एक स्ट्रिंग होना चाहिए @@ -8270,14 +8304,14 @@ antiAlias Sharp शार्प @msg Trap: no edges of different colours meet among the selected objects ट्रैप: विभिन्न रंगों के कोई किनारे सिलेक्टेड ऑब्जेक्ट्स के बीच मिलते हैं @msg Trap: select two or more objects filled with solid colours ट्रैप: ठोस रंगों से भरे दो या दो से अधिक ऑब्जेक्ट चुनें @msg truncated छंटनी -@msg two saved selections would be named `{_1}` दो बचाया सिलेक्शन `{_1}` नाम दिया जाएगा +@msg two saved selections would be named `{_1}` दो सेव किए गए सिलेक्शन का नाम `{_1}` होगा @msg type dimension cannot be represented with this text transform इस टेक्स्ट को बदलने के साथ टाइप आयाम का प्रतिनिधित्व नहीं किया जा सकता है -@msg type dimension must be finite प्रकार आयाम को परिमित होना चाहिए +@msg type dimension must be finite टाइप का आयाम परिमित होना चाहिए @msg type must be static or dynamic, not {v} type: प्रकार स्थिर या गतिशील होना चाहिए, न कि {v} -@msg type objects can't be isolated प्रकार ऑब्जेक्ट पृथक नहीं किया जा सकता है +@msg type objects can't be isolated टाइप ऑब्जेक्ट्स को आइसोलेट नहीं किया जा सकता @msg type that changes writing direction inside a text object opens in the direction of its first character टेक्स्ट ऑब्जेक्ट के भीतर लेखन दिशा बदलने वाला टेक्स्ट अपने पहले वर्ण की दिशा में खुलता है -@msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip प्रकार जुड़े क्षेत्रों में एक मास्क के रूप में इस्तेमाल किया, रैपराउंड टेक्स्ट या एक मिश्रित पाथ क्लिप नहीं करता है -@msg Unembedded to {_1} Unembed to {_1} +@msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip लिंक्ड एरिया, रैपअराउंड टेक्स्ट या कंपाउंड पाथ में मास्क के रूप में इस्तेमाल किया गया टाइप क्लिप नहीं करता +@msg Unembedded to {_1} एम्बेड हटाकर {_1} में रखा गया @msg unexpected host trap होस्ट में अप्रत्याशित ट्रैप @msg unexpected resources अप्रत्याशित संसाधन @msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (आकार, स्थिति) @@ -8309,9 +8343,9 @@ antiAlias Sharp शार्प @msg unknown effect `{id}` अज्ञात इफ़ेक्ट `{id}` @msg unknown end {e} अज्ञात अंत {e} @msg unknown File Info field `{k}` अज्ञात फ़ाइल जानकारी क्षेत्र `{k}` -@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) अज्ञात flattenerPreset `{name}` (उच्च संकल्प, मध्यम संकल्प, कम संकल्प या एक बचाया: flattener.presets.list देखें) +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) अज्ञात flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution या कोई सेव किया गया प्रीसेट: flattener.presets.list देखें) @msg unknown format अज्ञात स्वरूप -@msg unknown format `{f}` (Save writes {_1}) अज्ञात फ़ॉर्मैट `{f}` (Save लिखते हैं {_1}) +@msg unknown format `{f}` (Save writes {_1}) अज्ञात फ़ॉर्मैट `{f}` (सेव {_1} लिखता है) @msg unknown format `{f}` (vcswatches, gpl, ase or css) अज्ञात फ़ॉर्मैट `{f}` (vcswatches, gpl, ase या css) @msg unknown freeform mode `{m}` (points, lines) अज्ञात फ्रीफॉर्म मोड `{m}` (बिंदुओं, लाइनों) @msg unknown from {f} {f} से अज्ञात @@ -8338,7 +8372,7 @@ antiAlias Sharp शार्प @msg unknown model `{other}` (keep, new or document) अज्ञात मॉडल `{other}` (keep, नया या डॉक्यूमेंट) @msg unknown op {o} अज्ञात op {o} @msg unknown OpenType feature `{t}` अज्ञात ओपन टाइप सुविधा `{t}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off) अज्ञात ओपन टाइप सुविधा `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01-ss20, नमक, titl, pnum, lnum, sups, subs, numr, dnom; prefix - बंद करने के लिए) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off) अज्ञात OpenType फ़ीचर `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; बंद करने के लिए आगे - लगाएँ) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges में अज्ञात OpenType फ़ीचर `{t}` (वे टैग जो `features` लेता है) @msg unknown option `{path}.{k}` अज्ञात ऑप्शन `{path}.{k}` @msg unknown order `{o}`: toggle, front, forward, backward or back अज्ञात आदेश `{o}`: टॉगल, फ्रंट, फॉरवर्ड, बैकवर्ड या बैक @@ -8370,7 +8404,7 @@ antiAlias Sharp शार्प @msg not a preferences file: no `format` प्रेफ़रेंसेज़ फ़ाइल नहीं: `format` नहीं है @msg not a preferences file: no `version` प्रेफ़रेंसेज़ फ़ाइल नहीं: `version` नहीं है @msg not a preferences file: no `prefs` प्रेफ़रेंसेज़ फ़ाइल नहीं: `prefs` नहीं है -@msg the file was saved by a newer version of VectorCraft (version {v}) फ़ाइल VectorCraft के नए संस्करण से सहेजी गई थी (संस्करण {v}) +@msg the file was saved by a newer version of VectorCraft (version {v}) फ़ाइल VectorCraft के नए संस्करण (संस्करण {v}) से सेव की गई थी @msg the file holds no preferences फ़ाइल में कोई प्रेफ़रेंस नहीं है @msg `{key}` names a folder, which preferences files leave out `{key}` एक फ़ोल्डर बताता है, और प्रेफ़रेंसेज़ फ़ाइलों में फ़ोल्डर नहीं होते @msg the file is larger than 1 MB फ़ाइल 1 MB से बड़ी है @@ -8378,14 +8412,14 @@ antiAlias Sharp शार्प @msg `{key}` must be an object `{key}` एक ऑब्जेक्ट होना चाहिए @msg `prefs.import` runs on its own, not as a batch step `prefs.import` अकेले चलता है, बैच के चरण के रूप में नहीं @msg unknown preset (one of {_1}) अज्ञात प्रीसेट ({_1} में से एक) -@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) अज्ञात प्रीसेट `{name}` (उच्च, मध्यम, निम्न या एक बचाया: flattener.presets.list देखें) +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) अज्ञात प्रीसेट `{name}` (high, medium, low या कोई सेव किया गया प्रीसेट: flattener.presets.list देखें) @msg unknown preset `{name}` (see flattener.presets.list) अज्ञात प्रीसेट `{name}` (देखें flattener.presets.list) @msg unknown preset `{name}` (see webExport.presets.list) अज्ञात प्रीसेट `{name}` (see webExport.presets.list) @msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) अज्ञात प्रीसेट `{name}`: एक अंतर्निहित या सेव किए गए प्रीसेट नहीं (imageTrace.presets देखें) @msg unknown profile {id} (see stroke.widthProfile.list) अज्ञात प्रोफ़ाइल {id} (view stroke.widthProfile.list) @msg unknown proof target `{t}` अज्ञात प्रमाण लक्ष्य `{t}` @msg unknown quotes key `{k}` (double, single) अज्ञात उद्धरण कुंजी `{k}` (डबल, सिंगल) -@msg unknown resolution `{v}` अज्ञात संकल्प `{v}` +@msg unknown resolution `{v}` अज्ञात रिज़ॉल्यूशन `{v}` @msg unknown rule `{r}` (see color.harmony) अज्ञात नियम `{r}` (देखें color.harmony) @msg unknown rule `{rule}` ({_1}) अज्ञात नियम `{rule}` ({_1}) @msg unknown setting `{k}` अज्ञात सेटिंग `{k}` @@ -8661,7 +8695,7 @@ theme Appearance अपीयरेंस @msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally सुरक्षित मोड चालू है: जब तक VectorCraft सामान्य रूप से शुरू नहीं होता, प्लग-इन लोड या चलाए नहीं जाते @msg give `id` or `file` (see plugin.safeMode) `id` या `file` दें (plugin.safeMode देखें) @msg that plug-in isn't disabled (see plugin.safeMode) वह प्लग-इन अक्षम नहीं है (plugin.safeMode देखें) -@msg Couldn't save the disabled plug-ins for the next launch अगली बार लॉन्च के लिए अक्षम प्लग-इन सहेजे नहीं जा सके +@msg Couldn't save the disabled plug-ins for the next launch अगली बार लॉन्च के लिए अक्षम प्लग-इन सेव नहीं किए जा सके @msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode उपस्थिति का विस्तार: सिलेक्शन में किसी प्लग-इन इफ़ेक्ट का सुरक्षित मोड में विस्तार नहीं किया जा सकता # Align to Pixel Grid @@ -8736,6 +8770,19 @@ theme Appearance अपीयरेंस Show where this tool is दिखाएँ कि यह टूल कहाँ है @msg missing `tutorial` (see learn.list) लापता `tutorial` (learn.list देखें) @msg unknown tutorial `{id}` (see learn.list) अज्ञात ट्यूटोरियल `{id}` (learn.list देखें) + View more और देखें + Finish tutorial ट्यूटोरियल पूरा करें + Next Up आगे + You finished the last tutorial in this skill. आपने इस कौशल का अंतिम ट्यूटोरियल पूरा कर लिया है। + Back to skills कौशल पर वापस जाएँ + Open Tutorial Sample ट्यूटोरियल का नमूना खोलें + Finish Tutorial ट्यूटोरियल पूरा करें + Next Tutorial अगला ट्यूटोरियल + Back to Skills कौशल पर वापस जाएँ +@msg tutorial `{id}` has no practice sample ट्यूटोरियल `{id}` का कोई अभ्यास नमूना नहीं है +@msg open the practice sample of `{id}` first (see learn.openSample) पहले `{id}` का अभ्यास नमूना खोलें (learn.openSample देखें) +@msg finish `{id}` first (see learn.finish) पहले `{id}` पूरा करें (learn.finish देखें) +@msg `{id}` is the last tutorial in its skill `{id}` अपने कौशल का अंतिम ट्यूटोरियल है # Deferred plug-in effects Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 06122f94ee..89b517cdb2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1114,6 +1114,10 @@ font-technology Variable Változó Dock Panel Panel dokkolása Dock to Top Dokkolás felülre Dock to Bottom Dokkolás alulra + Dock to Left Dokkolás balra + Dock to Right Dokkolás jobbra + Dock Toolbar Eszköztár dokkolása + Float Toolbar Eszköztár lebegtetése Control Bar Options Vezérlősáv beállításai Tool Options Eszközbeállítások Object Options Objektumbeállítások @@ -1999,6 +2003,8 @@ file-filter Templates Sablonok Objects on Path Tool Objektumok útvonalon Attach Objects on Path Objektumok csatolása útvonalhoz Expand Objects on Path Objektumok útvonalon kibontása + Detach Leválasztás + Detach Object from Path Objektum leválasztása az útvonalról Release Live Paint Élő festés megszüntetése Release Repeat Ismétlés megszüntetése Release Slice Szelet megszüntetése @@ -3464,6 +3470,9 @@ mark-style Japanese Japán Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire Color Theme Picker Színtéma-választó Click or drag across the art to pick its colors; Shift adds more Kattintson vagy húzzon a grafikán a színei felvételéhez; a Shift továbbiakat ad hozzá + Prominent Colors Domináns színek + Save All Colors Összes szín mentése + Save Prominent Colors Domináns színek mentése Reset to default Alapérték visszaállítása Reset to Default Alapérték visszaállítása Reset to Defaults Alapértékek visszaállítása @@ -4116,6 +4125,19 @@ mark-style Japanese Japán Spot Color Objects Direkt színeket használó objektumok Pattern Objects Mintázatokat használó objektumok Gradient Objects Színátmeneteket használó objektumok + Font Details Betűtípus-részletek + Linked Images Csatolt képek + Embedded Images Beágyazott képek + Compound Paths Összetett útvonalak + Text Objects Szövegobjektumok + Placed Documents Elhelyezett dokumentumok + Symbol Instances Szimbólumpéldányok + Gradient Meshes Színátmenetes hálók + Blends Összemosások + Envelopes Burkolók + Intertwine Groups Összefonási csoportok + Opacity Masks Átlátszósági maszkok + Objects with Effects Effektusokat használó objektumok Spot Colors Direktszínek Spot Colors (0) Direktszínek (0) Spot Colors… Direktszínek… @@ -5151,6 +5173,7 @@ imageTrace Outline Körvonal @msg every numbered name for it is taken in the Fonts folder a Fonts mappában minden sorszámozott név foglalt hozzá @msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name} @msg No linked files to look for Nincs keresendő csatolt fájl +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon @msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát) @msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van @msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás @@ -5457,6 +5480,7 @@ imageTrace Outline Körvonal @msg paths contain invalid coordinates az útvonalakban érvénytelen koordináták vannak @msg paths: give at least one file paths: legalább egy fájlt adj meg @msg replace: the object to replace was changed or deleted replace: a cserélendő objektumot módosították vagy törölték +@msg the document it was prepared for is no longer active az a dokumentum, amelyhez előkészítették, már nem aktív @msg pattern missing hiányzik a mintázat @msg patterns nested too deeply are filled with mid-grey a túl mélyen egymásba ágyazott mintázatok helyén középszürke kitöltés áll @msg PDF settings are not an object a PDF-beállítások nem objektumot alkotnak @@ -5539,6 +5563,7 @@ imageTrace Outline Körvonal @msg select a repeat jelölj ki egy ismétlést @msg select an Intertwine group jelölj ki egy összefonási csoportot @msg select an Objects on Path group jelölj ki egy Objektumok útvonalon csoportot +@msg choose the object to detach with its Select/Move widget válaszd ki a leválasztandó objektumot a Kijelölés/Mozgatás vezérlőjével @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects összetett útvonalban, összemosásban, burkolóban, ismétlésben vagy összefonási csoportban lévő útvonalhoz nem csatolhatók objektumok @msg a clipping path can't take objects vágóútvonalhoz nem csatolhatók objektumok @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path összemosásban, burkolóban, ismétlésben vagy összefonási csoportban lévő objektumok nem csatolhatók útvonalhoz @@ -5921,7 +5946,9 @@ imageTrace Outline Körvonal @msg `{GRAPHICS}` face must be front or back a `{GRAPHICS}` face értéke front vagy back lehet @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} a `{GRAPHICS}` {key} értéke {lo} és {hi} közötti szám lehet @msg `{GRAPHICS}` must be an array a `{GRAPHICS}` értéke tömb kell legyen -@msg Graphics map onto planar faces: use the Extrude or Plane object type A grafikák sík lapokra kerülnek: használja a Kihúzás vagy a Sík objektumtípust +@msg Graphics map onto the Plane, Extrude and Revolve object types A grafikák a Sík, a Kihúzás és a Forgatás tengely körül objektumtípusra kerülnek +@msg Revolve maps graphics onto its side: map them onto the front A Forgatás tengely körül a grafikákat a palástjára helyezi: az elülső oldalra helyezze őket +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments A grafikák túl összetettek ehhez a felülethez: egyszerűsítse a szimbólumok rajzát, vagy csökkentse a Szakaszok értékét @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics a `{GRAPHICS}` legfeljebb {MAX_GRAPHICS} grafikát fogad @msg A plane has one face: map its graphics onto the front A síknak egy oldala van: a grafikáit az elülső oldalra helyezze @msg The graphics are too complex: simplify the symbols' art A grafikák túl összetettek: egyszerűsítse a szimbólumok rajzát @@ -6516,6 +6543,7 @@ axis Y Axis Y tengely @msg message must be text message: szöveg kell @msg allowContinue must be true or false allowContinue: true vagy false kell @msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet +@msg Batch cannot play an action with a View menu step A kötegelt feldolgozás nem játszhat le Nézet menübeli lépést tartalmazó műveletet Insert Menu Item… Menüelem beszúrása… Insert Menu Item Menüelem beszúrása Insert Select Path Útvonal kijelölésének beszúrása @@ -6699,6 +6727,13 @@ axis Y Axis Y tengely Area Text Területszöveg # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Szöveg mérése a glifahatárok szerint +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Pixelhez illesztés beállításai + Pixel Snapping Options… Pixelhez illesztés beállításai… + Snap to Pixel While Drawing Illesztés pixelhez rajzoláskor + Snap to Pixel While Moving Illesztés pixelhez mozgatáskor + Snap to Pixel While Scaling Illesztés pixelhez méretezéskor + These options take effect when View › Snap to Pixel is on. Ezek a beállítások akkor érvényesek, ha a Nézet › Illesztés pixelhez be van kapcsolva. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glifához illesztés beállításainak megjelenítése Hide Snap to Glyph Options Glifához illesztés beállításainak elrejtése @@ -7327,6 +7362,19 @@ breakText Glyphs Glifák Show where this tool is Megmutatja, hol van ez az eszköz @msg missing `tutorial` (see learn.list) hiányzik: `tutorial` (lásd: learn.list) @msg unknown tutorial `{id}` (see learn.list) ismeretlen oktatóanyag: `{id}` (lásd: learn.list) + View more Továbbiak + Finish tutorial Oktatóanyag befejezése + Next Up Következő + You finished the last tutorial in this skill. Befejezte a készség utolsó oktatóanyagát. + Back to skills Vissza a készségekhez + Open Tutorial Sample Oktatóanyag mintafájljának megnyitása + Finish Tutorial Oktatóanyag befejezése + Next Tutorial Következő oktatóanyag + Back to Skills Vissza a készségekhez +@msg tutorial `{id}` has no practice sample nincs gyakorló mintafájl ehhez az oktatóanyaghoz: `{id}` +@msg open the practice sample of `{id}` first (see learn.openSample) előbb nyissa meg a gyakorló mintafájlt ehhez: `{id}` (lásd: learn.openSample) +@msg finish `{id}` first (see learn.finish) előbb fejezze be ezt: `{id}` (lásd: learn.finish) +@msg `{id}` is the last tutorial in its skill a készsége utolsó oktatóanyaga: `{id}` # Deferred plug-in effects Refresh Deferred Effects Elhalasztott effektusok frissítése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 00d2b09600..543723217f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -853,6 +853,10 @@ Dock Panel Tambatkan panel Dock to Top Tambatkan ke atas Dock to Bottom Tambatkan ke bawah + Dock to Left Tambatkan ke kiri + Dock to Right Tambatkan ke kanan + Dock Toolbar Tambatkan bilah alat + Float Toolbar Mengambangkan bilah alat Control Bar Options Opsi bilah kontrol Tool Options Opsi alat Object Options Opsi objek @@ -2675,6 +2679,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Setel ulang baris ke warna karya seni Color Theme Picker Pemilih tema warna Click or drag across the art to pick its colors; Shift adds more Klik atau seret melintasi karya seni untuk mengambil warnanya; Shift menambahkan lagi + Prominent Colors Warna menonjol + Save All Colors Simpan semua warna + Save Prominent Colors Simpan warna menonjol Separate colors into rows Pisahkan warna ke dalam baris Unlink harmony colors Lepaskan tautan warna harmoni 1 Color Job Pekerjaan 1 warna @@ -3731,6 +3738,19 @@ theme Appearance Tampilan Spot Color Objects Objek dengan Warna Spot Pattern Objects Objek dengan Pola Gradient Objects Objek dengan Gradien + Font Details Detail Font + Linked Images Gambar Tertaut + Embedded Images Gambar Tersemat + Compound Paths Jalur Majemuk + Text Objects Objek Teks + Placed Documents Dokumen yang Ditempatkan + Symbol Instances Instans Simbol + Gradient Meshes Mesh Gradien + Blends Blend + Envelopes Amplop + Intertwine Groups Grup Jalinan + Opacity Masks Mask Opasitas + Objects with Effects Objek dengan Efek Spot Colors Warna spot Spot Colors (0) Warna spot (0) Swatch Options Opsi sampel @@ -4594,6 +4614,7 @@ imageTrace Outline Kontur @msg paths contain invalid coordinates jalur berisi koordinat yang tidak valid @msg paths: give at least one file jalur: berikan setidaknya satu berkas @msg replace: the object to replace was changed or deleted ganti: objek yang akan diganti telah diubah atau dihapus +@msg the document it was prepared for is no longer active dokumen yang menjadi tujuan persiapannya tidak lagi aktif @msg pattern missing pola hilang @msg patterns nested too deeply are filled with mid-grey pola yang bersarang terlalu dalam diisi dengan abu-abu tengah @msg percent must be above 0 and at most 6400, not {p} percent harus di atas 0 dan paling banyak 6400, bukan {p} @@ -5821,6 +5842,8 @@ swatch Bright Violet Violet cerah Objects on Path Tool Alat objek pada jalur Attach Objects on Path Tempelkan objek pada jalur Expand Objects on Path Perluas objek pada jalur + Detach Lepaskan + Detach Object from Path Lepaskan objek dari jalur Release Live Paint Lepaskan Live Paint Release Repeat Lepaskan perulangan Release Slice Lepaskan slice @@ -6015,6 +6038,7 @@ axis Y Axis Sumbu Y @msg message must be text message harus berupa teks @msg allowContinue must be true or false allowContinue harus true atau false @msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti +@msg Batch cannot play an action with a View menu step Batch tidak dapat memutar tindakan dengan langkah menu Tampilan Insert Menu Item… Sisipkan item menu… Insert Menu Item Sisipkan item menu Insert Select Path Sisipkan pemilihan jalur @@ -6179,6 +6203,13 @@ axis Y Axis Sumbu Y Area Text Teks area # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Ukur teks menurut batas glif +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opsi snap ke piksel + Pixel Snapping Options… Opsi snap ke piksel… + Snap to Pixel While Drawing Snap ke piksel saat menggambar + Snap to Pixel While Moving Snap ke piksel saat memindahkan + Snap to Pixel While Scaling Snap ke piksel saat menskalakan + These options take effect when View › Snap to Pixel is on. Opsi ini berlaku saat Tampilan › Snap ke piksel aktif. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Tampilkan opsi snap ke glif Hide Snap to Glyph Options Sembunyikan opsi snap ke glif @@ -6763,6 +6794,19 @@ breakText Glyphs Glif Show where this tool is Tampilkan letak alat ini @msg missing `tutorial` (see learn.list) `tutorial` tidak ada (lihat learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial `{id}` tidak dikenal (lihat learn.list) + View more Lihat selengkapnya + Finish tutorial Selesaikan tutorial + Next Up Berikutnya + You finished the last tutorial in this skill. Anda telah menyelesaikan tutorial terakhir dalam keterampilan ini. + Back to skills Kembali ke keterampilan + Open Tutorial Sample Buka contoh tutorial + Finish Tutorial Selesaikan tutorial + Next Tutorial Tutorial berikutnya + Back to Skills Kembali ke keterampilan +@msg tutorial `{id}` has no practice sample tutorial `{id}` tidak memiliki contoh latihan +@msg open the practice sample of `{id}` first (see learn.openSample) buka contoh latihan `{id}` terlebih dahulu (lihat learn.openSample) +@msg finish `{id}` first (see learn.finish) selesaikan `{id}` terlebih dahulu (lihat learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` adalah tutorial terakhir dalam keterampilannya # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 869fe2e4a0..2b17326766 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1623,6 +1623,8 @@ file-filter Templates Modelli Objects on Path Tool Strumento Oggetti su tracciato Attach Objects on Path Aggancia oggetti al tracciato Expand Objects on Path Espandi oggetti su tracciato + Detach Sgancia + Detach Object from Path Sgancia oggetto dal tracciato Release Live Paint Rilascia pittura dinamica Release Repeat Rilascia ripetizione Release Slice Rilascia sezione @@ -2955,6 +2957,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Reimposta le righe sui colori della grafica Color Theme Picker Selettore tema colore Click or drag across the art to pick its colors; Shift adds more Fai clic o trascina sulla grafica per prelevarne i colori; Maiusc ne aggiunge altri + Prominent Colors Colori prominenti + Save All Colors Salva tutti i colori + Save Prominent Colors Salva colori prominenti Reset to Default Reimposta valori predefiniti Reset to White, Black Reimposta su bianco, nero Reset with Mesh Reimposta con trama @@ -3507,6 +3512,20 @@ digits Malayalam Malayalam Spot Color Objects Oggetti con colori spot Pattern Objects Oggetti con motivi Gradient Objects Oggetti con sfumature + Font Details Dettagli font + Linked Images Immagini collegate + Embedded Images Immagini incorporate + Compound Paths Tracciati composti + Text Objects Oggetti di testo + Placed Documents Documenti inseriti + Symbol Instances Istanze di simbolo + Gradient Meshes Trame sfumatura + Blends Fusioni + Envelopes Involucri + Intertwine Groups Gruppi intrecciati + Opacity Masks Maschere di opacità + Objects with Effects Oggetti con effetti + Pattern Swatches Campioni motivo Spot Colors Colori spot Spot Colors… Colori spot… Spread: Estensione: @@ -4203,6 +4222,10 @@ axis Both Entrambi Dock Panel Ancora pannello Dock to Top Ancora in alto Dock to Bottom Ancora in basso + Dock to Left Ancora a sinistra + Dock to Right Ancora a destra + Dock Toolbar Ancora barra degli strumenti + Float Toolbar Rendi mobile barra degli strumenti Control Bar Options Opzioni barra di controllo Tool Options Opzioni strumento Object Options Opzioni oggetto @@ -4799,6 +4822,7 @@ axis Both Entrambi @msg paths contain invalid coordinates i tracciati contengono coordinate non valide @msg paths: give at least one file paths: specifica almeno un file @msg replace: the object to replace was changed or deleted replace: l’oggetto da sostituire è stato modificato o eliminato +@msg the document it was prepared for is no longer active il documento per cui è stato preparato non è più attivo @msg pattern missing motivo mancante @msg patterns nested too deeply are filled with mid-grey i motivi annidati troppo in profondità vengono riempiti di grigio medio @msg percent must be above 0 and at most 6400, not {p} percent deve essere maggiore di 0 e al massimo 6400, non {p} @@ -4834,6 +4858,7 @@ axis Both Entrambi @msg select a repeat seleziona una ripetizione @msg select an Intertwine group seleziona un gruppo intrecciato @msg select an Objects on Path group seleziona un gruppo di oggetti su tracciato +@msg choose the object to detach with its Select/Move widget scegli l’oggetto da sganciare con il suo controllo Seleziona/Sposta @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un tracciato all'interno di un tracciato composto, una fusione, un involucro, una ripetizione o un gruppo intrecciato non può ricevere oggetti @msg a clipping path can't take objects un tracciato di ritaglio non può ricevere oggetti @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path gli oggetti all'interno di una fusione, un involucro, una ripetizione o un gruppo intrecciato non possono essere agganciati a un tracciato @@ -5158,6 +5183,7 @@ axis Both Entrambi @msg every numbered name for it is taken in the Fonts folder tutti i nomi numerati per il file sono già usati nella cartella Fonts @msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento @msg No linked files to look for Nessun file collegato da cercare +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi @@ -5555,7 +5581,9 @@ lighting Light Luce @msg `{GRAPHICS}` face must be front or back face di `{GRAPHICS}` deve essere front o back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} di `{GRAPHICS}` deve essere un numero da {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve essere un array -@msg Graphics map onto planar faces: use the Extrude or Plane object type Le grafiche si applicano a facce piane: usate il tipo di oggetto Estrusione o Piano +@msg Graphics map onto the Plane, Extrude and Revolve object types Le grafiche si applicano ai tipi di oggetto Piano, Estrusione e Rivoluzione +@msg Revolve maps graphics onto its side: map them onto the front Rivoluzione applica le grafiche sul suo lato: applicatele sulla faccia anteriore +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Le grafiche sono troppo complesse per questa superficie: semplificate il disegno dei simboli o riducete Segmenti @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accetta al massimo {MAX_GRAPHICS} grafiche @msg A plane has one face: map its graphics onto the front Un piano ha una sola faccia: applicate le sue grafiche sulla faccia anteriore @msg The graphics are too complex: simplify the symbols' art Le grafiche sono troppo complesse: semplificate il disegno dei simboli @@ -6158,6 +6186,7 @@ axis Y Axis Asse Y @msg message must be text message deve essere testo @msg allowContinue must be true or false allowContinue deve essere true o false @msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione +@msg Batch cannot play an action with a View menu step L'elaborazione batch non può eseguire un'azione con un passaggio del menu Visualizza Insert Menu Item… Inserisci voce di menu… Insert Menu Item Inserisci voce di menu Insert Select Path Inserisci selezione tracciato @@ -6341,6 +6370,13 @@ axis Y Axis Asse Y Area Text Testo in area # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Misura il testo in base ai limiti del glifo +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opzioni di aggancio ai pixel + Pixel Snapping Options… Opzioni di aggancio ai pixel… + Snap to Pixel While Drawing Aggancia ai pixel durante il disegno + Snap to Pixel While Moving Aggancia ai pixel durante lo spostamento + Snap to Pixel While Scaling Aggancia ai pixel durante il ridimensionamento + These options take effect when View › Snap to Pixel is on. Queste opzioni hanno effetto quando Visualizza › Aggancia ai pixel è attivo. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra opzioni di aggancio a glifo Hide Snap to Glyph Options Nascondi opzioni di aggancio a glifo @@ -6972,6 +7008,19 @@ breakText Glyphs Glifi Show where this tool is Mostra dove si trova questo strumento @msg missing `tutorial` (see learn.list) manca `tutorial` (vedi learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial sconosciuto `{id}` (vedi learn.list) + View more Mostra altro + Finish tutorial Termina il tutorial + Next Up Prossimo + You finished the last tutorial in this skill. Hai terminato l’ultimo tutorial di questa competenza. + Back to skills Torna alle competenze + Open Tutorial Sample Apri l’esempio del tutorial + Finish Tutorial Termina il tutorial + Next Tutorial Tutorial successivo + Back to Skills Torna alle competenze +@msg tutorial `{id}` has no practice sample il tutorial `{id}` non ha un esempio di esercitazione +@msg open the practice sample of `{id}` first (see learn.openSample) apri prima l’esempio di esercitazione di `{id}` (vedi learn.openSample) +@msg finish `{id}` first (see learn.finish) termina prima `{id}` (vedi learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` è l’ultimo tutorial della sua competenza # Deferred plug-in effects Refresh Deferred Effects Aggiorna effetti rimandati diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a0ce9c93b9..3d71e2eb0e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1992,6 +1992,8 @@ antiAlias Sharp シャープ Objects on Path Tool パス上のオブジェクトツール Attach Objects on Path パス上のオブジェクトをアタッチ Expand Objects on Path パス上のオブジェクトを拡張 + Detach 切り離し + Detach Object from Path オブジェクトをパスから切り離し Release Live Paint ライブペイントを解除 Release Repeat リピートを解除 Release Slice スライスを解除 @@ -2960,6 +2962,9 @@ mark-style Japanese 日本式 Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Color Theme Picker カラーテーマピッカー Click or drag across the art to pick its colors; Shift adds more アートワークをクリックまたはドラッグしてカラーを取得します。Shift キーで追加します + Prominent Colors 主要カラー + Save All Colors すべてのカラーを保存 + Save Prominent Colors 主要カラーを保存 Reset to Default 初期設定に戻す Reset to White, Black 白、黒にリセット Reset {name} {name}をリセット @@ -3284,6 +3289,20 @@ mark-style Japanese 日本式 Spot Color Objects 特色を使用したオブジェクト Pattern Objects パターンを使用したオブジェクト Gradient Objects グラデーションを使用したオブジェクト + Font Details フォントの詳細 + Linked Images リンク画像 + Embedded Images 埋め込み画像 + Compound Paths 複合パス + Text Objects テキストオブジェクト + Placed Documents 配置ドキュメント + Symbol Instances シンボルインスタンス + Gradient Meshes グラデーションメッシュ + Blends ブレンド + Envelopes エンベロープ + Intertwine Groups 織り交ぜグループ + Opacity Masks 不透明マスク + Objects with Effects 効果を使用したオブジェクト + Pattern Swatches パターンスウォッチ Spot Colors 特色 Spot Colors… 特色… Spread: 拡散: @@ -4145,6 +4164,10 @@ axis Both 両方 Dock Panel パネルをドッキング Dock to Top 上部にドッキング Dock to Bottom 下部にドッキング + Dock to Left 左側にドッキング + Dock to Right 右側にドッキング + Dock Toolbar ツールバーをドッキング + Float Toolbar ツールバーをフローティング Control Bar Options コントロールバーオプション Tool Options ツールオプション Object Options オブジェクトオプション @@ -4556,6 +4579,7 @@ swatch Bright Violet ビビッドバイオレット @msg select a repeat リピートを選択してください @msg select an Intertwine group 織り交ぜグループを選択してください @msg select an Objects on Path group パス上のオブジェクトグループを選択してください +@msg choose the object to detach with its Select/Move widget 切り離すオブジェクトを選択/移動ウィジェットで選んでください @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 複合パス、ブレンド、エンベロープ、リピートまたは織り交ぜグループ内のパスにはオブジェクトをアタッチできません @msg a clipping path can't take objects クリッピングパスにはオブジェクトをアタッチできません @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path ブレンド、エンベロープ、リピートまたは織り交ぜグループ内のオブジェクトはパスにアタッチできません @@ -5162,6 +5186,7 @@ swatch Bright Violet ビビッドバイオレット @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` は VectorCraft ドキュメント、PDF、PDF 互換の .ai ファイルのいずれでもありません @msg paths: give at least one file paths: ファイルを 1 つ以上指定してください @msg replace: the object to replace was changed or deleted replace: 置き換えるオブジェクトは変更または削除されています +@msg the document it was prepared for is no longer active 準備したときのドキュメントがもうアクティブではありません @msg Couldn't place {name}: {e} {name} を配置できませんでした: {e} @msg no print service プリントサービスがありません @msg the print job has no data プリントジョブにデータがありません @@ -5225,6 +5250,7 @@ swatch Bright Violet ビビッドバイオレット @msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています @msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています @msg No linked files to look for 検索するリンクファイルがありません +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) @@ -5597,7 +5623,10 @@ lighting Light ライト @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` の face には front または back を指定してください @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` の {key} は {lo} から {hi} までの数値にしてください @msg `{GRAPHICS}` must be an array `{GRAPHICS}` は配列にしてください -@msg Graphics map onto planar faces: use the Extrude or Plane object type グラフィックは平らな面にマッピングされます。オブジェクトの種類に押し出しまたは平面を使用してください +@msg Graphics map onto the Plane, Extrude and Revolve object types グラフィックはオブジェクトの種類が平面、押し出し、回転体のときにマッピングされます + Graphics map onto the Plane, Extrude and Revolve object types. グラフィックはオブジェクトの種類が平面、押し出し、回転体のときにマッピングされます。 +@msg Revolve maps graphics onto its side: map them onto the front 回転体はグラフィックを側面にマッピングします。前面にマッピングしてください +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments グラフィックがこのサーフェスには複雑すぎます。シンボルのアートを単純化するか、分割数を減らしてください @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` に指定できるグラフィックは {MAX_GRAPHICS} 個までです @msg A plane has one face: map its graphics onto the front 平面には面が 1 つしかありません。グラフィックは前面にマッピングしてください @msg The graphics are too complex: simplify the symbols' art グラフィックが複雑すぎます。シンボルのアートを単純化してください @@ -6223,6 +6252,7 @@ axis Y Axis Y 軸 @msg message must be text message にはテキストを指定してください @msg allowContinue must be true or false allowContinue には true または false を指定してください @msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません +@msg Batch cannot play an action with a View menu step バッチでは表示メニューのステップを含むアクションを再生できません Insert Menu Item… メニュー項目を挿入… Insert Menu Item メニュー項目を挿入 Insert Select Path パスの選択を挿入 @@ -6406,6 +6436,13 @@ axis Y Axis Y 軸 Area Text エリア内文字 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 文字を字形の境界で測定 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options ピクセルにスナップのオプション + Pixel Snapping Options… ピクセルにスナップのオプション… + Snap to Pixel While Drawing 描画時にピクセルにスナップ + Snap to Pixel While Moving 移動時にピクセルにスナップ + Snap to Pixel While Scaling 拡大・縮小時にピクセルにスナップ + These options take effect when View › Snap to Pixel is on. これらのオプションは、表示 › ピクセルにスナップがオンのときに有効になります。 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options グリフにスナップのオプションを表示 Hide Snap to Glyph Options グリフにスナップのオプションを隠す @@ -7037,6 +7074,19 @@ breakText Glyphs 字形 Show where this tool is このツールの場所を表示 @msg missing `tutorial` (see learn.list) `tutorial` が指定されていません (learn.list を参照) @msg unknown tutorial `{id}` (see learn.list) `{id}` は不明なチュートリアルです (learn.list を参照) + View more 詳細を見る + Finish tutorial チュートリアルを完了 + Next Up 次へ + You finished the last tutorial in this skill. このスキルの最後のチュートリアルを完了しました。 + Back to skills スキルに戻る + Open Tutorial Sample チュートリアルのサンプルを開く + Finish Tutorial チュートリアルを完了 + Next Tutorial 次のチュートリアル + Back to Skills スキルに戻る +@msg tutorial `{id}` has no practice sample チュートリアル `{id}` には練習用サンプルがありません +@msg open the practice sample of `{id}` first (see learn.openSample) 先に `{id}` の練習用サンプルを開いてください (learn.openSample を参照) +@msg finish `{id}` first (see learn.finish) 先に `{id}` を完了してください (learn.finish を参照) +@msg `{id}` is the last tutorial in its skill `{id}` はこのスキルの最後のチュートリアルです # Deferred plug-in effects Refresh Deferred Effects 保留中の効果を更新 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b19ed4009a..0c228dfb32 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -816,6 +816,10 @@ Dock Panel პანელის მიმაგრება Dock to Top ზემოთ მიმაგრება Dock to Bottom ქვემოთ მიმაგრება + Dock to Left მარცხნივ მიმაგრება + Dock to Right მარჯვნივ მიმაგრება + Dock Toolbar ხელსაწყოთა ზოლის მიმაგრება + Float Toolbar მცურავი ხელსაწყოთა ზოლი Control Bar Options მართვის ზოლის პარამეტრები Tool Options ხელსაწყოს პარამეტრები Object Options ობიექტის პარამეტრები @@ -2591,6 +2595,9 @@ digits Malayalam მალაიალამური Reset the rows to the artwork's colors სტრიქონებისთვის ნახატის ფერების დაბრუნება Color Theme Picker ფერთა თემის ამრჩევი Click or drag across the art to pick its colors; Shift adds more დააწკაპუნეთ ან გადაატარეთ ნახატზე მისი ფერების ასაღებად; Shift ამატებს კიდევ + Prominent Colors გამოკვეთილი ფერები + Save All Colors ყველა ფერის შენახვა + Save Prominent Colors გამოკვეთილი ფერების შენახვა Separate colors into rows ფერების სტრიქონებად დაყოფა Unlink harmony colors ჰარმონიის ფერების კავშირის გაწყვეტა 1 Color Job 1-ფერიანი ბეჭდვა @@ -3570,6 +3577,19 @@ theme Appearance იერსახე Spot Color Objects ობიექტები სპოტ-ფერებით Pattern Objects ობიექტები პატერნებით Gradient Objects ობიექტები გრადიენტებით + Font Details შრიფტების დეტალები + Linked Images დაკავშირებული გამოსახულებები + Embedded Images ჩაშენებული გამოსახულებები + Compound Paths შედგენილი ტრაექტორიები + Text Objects ტექსტური ობიექტები + Placed Documents განთავსებული დოკუმენტები + Symbol Instances სიმბოლოს ეგზემპლარები + Gradient Meshes გრადიენტული ბადეები + Blends გადასვლები + Envelopes გარსები + Intertwine Groups გადახლართვის ჯგუფები + Opacity Masks გაუმჭვირვალობის ნიღბები + Objects with Effects ობიექტები ეფექტებით Spot Colors სპოტ-ფერები Swatch Options ფერის ნიმუშის პარამეტრები Ungroup Color Group ფერების ჯგუფის დაშლა @@ -4423,6 +4443,7 @@ font-technology Variable ცვლადი @msg paths contain invalid coordinates ტრაექტორიებში არასწორი კოორდინატებია @msg paths: give at least one file paths: მიუთითეთ ერთი ფაილი მაინც @msg replace: the object to replace was changed or deleted replace: ჩასანაცვლებელი ობიექტი შეიცვალა ან წაიშალა +@msg the document it was prepared for is no longer active დოკუმენტი, რომლისთვისაც მომზადდა, აღარ არის აქტიური @msg pattern missing პატერნი აკლია @msg patterns nested too deeply are filled with mid-grey ზედმეტად ღრმად ჩადგმული პატერნები საშუალო ნაცრისფრით ივსება @msg percent must be above 0 and at most 6400, not {p} percent უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p} @@ -5125,6 +5146,7 @@ font-technology Variable ცვლადი @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same მათზე არსებული ნახატი გვერდისგან {_1}%-ით განსხვავდება: მიუხედავად ამისა, ისინი რედაქტირების მონაცემებიდან აიღება @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული @@ -5226,7 +5248,9 @@ font-technology Variable ცვლადი @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-ის face უნდა იყოს front ან back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-ის {key} უნდა იყოს რიცხვი {lo}-დან {hi}-მდე @msg `{GRAPHICS}` must be an array `{GRAPHICS}` უნდა იყოს მასივი -@msg Graphics map onto planar faces: use the Extrude or Plane object type გრაფიკა ბრტყელ წახნაგებზე თავსდება: გამოიყენეთ ობიექტის ტიპი „ამოწევა“ ან „სიბრტყე“ +@msg Graphics map onto the Plane, Extrude and Revolve object types გრაფიკა თავსდება ობიექტის ტიპებზე „სიბრტყე“, „ამოწევა“ და „ბრუნვა“ +@msg Revolve maps graphics onto its side: map them onto the front ბრუნვა გრაფიკას თავის გვერდით ზედაპირზე ათავსებს: განათავსეთ ისინი წინა მხარეზე +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments გრაფიკა ზედმეტად რთულია ამ ზედაპირისთვის: გაამარტივეთ სიმბოლოების ნახატი ან შეამცირეთ სეგმენტები @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` იღებს მაქსიმუმ {MAX_GRAPHICS} გრაფიკას @msg A plane has one face: map its graphics onto the front სიბრტყეს ერთი მხარე აქვს: მისი გრაფიკა წინა მხარეზე განათავსეთ @msg The graphics are too complex: simplify the symbols' art გრაფიკა ზედმეტად რთულია: გაამარტივეთ სიმბოლოების ნახატი @@ -5715,6 +5739,7 @@ imageTrace Outline კონტური Arrange Intertwine Area გადახლართვის არის დალაგება @msg select an Intertwine group მონიშნეთ გადახლართვის ჯგუფი @msg select an Objects on Path group მონიშნეთ ტრაექტორიაზე ობიექტების ჯგუფი +@msg choose the object to detach with its Select/Move widget მოსახსნელი ობიექტი აირჩიეთ მისი არჩევა/გადატანის ვიჯეტით @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects შედგენილ ტრაექტორიაში, გადასვლაში, გარსში, გამეორებაში ან გადახლართვის ჯგუფში მყოფ ტრაექტორიაზე ობიექტების მიმაგრება ვერ ხერხდება @msg a clipping path can't take objects მომჭრელ ტრაექტორიაზე ობიექტების მიმაგრება ვერ ხერხდება @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path გადასვლაში, გარსში, გამეორებაში ან გადახლართვის ჯგუფში მყოფი ობიექტების ტრაექტორიაზე მიმაგრება ვერ ხერხდება @@ -5977,6 +6002,8 @@ imageTrace Outline კონტური Objects on Path Tool ტრაექტორიაზე ობიექტების ხელსაწყო Attach Objects on Path ობიექტების ტრაექტორიაზე მიმაგრება Expand Objects on Path ტრაექტორიაზე ობიექტების გაშლა + Detach მოხსნა + Detach Object from Path ობიექტის ტრაექტორიიდან მოხსნა Release Live Paint ცოცხალი შეღებვის მოხსნა Release Repeat გამეორების მოხსნა Release Slice ნაჭრის მოხსნა @@ -6484,6 +6511,7 @@ axis Y Axis Y ღერძი @msg message must be text message უნდა იყოს ტექსტი @msg allowContinue must be true or false allowContinue უნდა იყოს true ან false @msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება +@msg Batch cannot play an action with a View menu step პაკეტურ დამუშავებას არ შეუძლია ხედის მენიუს ნაბიჯის მქონე მოქმედების შესრულება Insert Menu Item… მენიუს ელემენტის ჩასმა… Insert Menu Item მენიუს ელემენტის ჩასმა Insert Select Path ტრაექტორიის მონიშვნის ჩასმა @@ -6740,6 +6768,13 @@ swatch Bright Violet კაშკაშა იისფერი Area Text არის ტექსტი # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds ტექსტის გაზომვა გლიფის საზღვრებით +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options პიქსელზე მიბმის პარამეტრები + Pixel Snapping Options… პიქსელზე მიბმის პარამეტრები… + Snap to Pixel While Drawing პიქსელზე მიბმა ხატვისას + Snap to Pixel While Moving პიქსელზე მიბმა გადაადგილებისას + Snap to Pixel While Scaling პიქსელზე მიბმა მასშტაბირებისას + These options take effect when View › Snap to Pixel is on. ეს პარამეტრები მოქმედებს, როცა ხედი › პიქსელზე მიბმა ჩართულია. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options გლიფზე მიბმის პარამეტრების ჩვენება Hide Snap to Glyph Options გლიფზე მიბმის პარამეტრების დამალვა @@ -7457,6 +7492,19 @@ font-script Japanese იაპონური Show where this tool is ამ ხელსაწყოს მდებარეობის ჩვენება @msg missing `tutorial` (see learn.list) აკლია `tutorial` (იხ. learn.list) @msg unknown tutorial `{id}` (see learn.list) უცნობი გაკვეთილი `{id}` (იხ. learn.list) + View more მეტის ნახვა + Finish tutorial გაკვეთილის დასრულება + Next Up შემდეგი + You finished the last tutorial in this skill. თქვენ დაასრულეთ ამ უნარის ბოლო გაკვეთილი. + Back to skills უნარებზე დაბრუნება + Open Tutorial Sample გაკვეთილის ნიმუშის გახსნა + Finish Tutorial გაკვეთილის დასრულება + Next Tutorial შემდეგი გაკვეთილი + Back to Skills უნარებზე დაბრუნება +@msg tutorial `{id}` has no practice sample გაკვეთილს `{id}` არ აქვს სავარჯიშო ნიმუში +@msg open the practice sample of `{id}` first (see learn.openSample) ჯერ გახსენით `{id}` გაკვეთილის სავარჯიშო ნიმუში (იხ. learn.openSample) +@msg finish `{id}` first (see learn.finish) ჯერ დაასრულეთ `{id}` (იხ. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` თავისი უნარის ბოლო გაკვეთილია # Deferred plug-in effects Refresh Deferred Effects გადადებული ეფექტების განახლება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3f518f00c4..53b8b5ed47 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1982,6 +1982,8 @@ antiAlias Sharp 날카롭게 Objects on Path Tool 패스 위 개체 도구 Attach Objects on Path 패스 위 개체 부착 Expand Objects on Path 패스 위 개체 확장 + Detach 분리 + Detach Object from Path 패스에서 개체 분리 Release Live Paint 라이브 페인트 해제 Release Repeat 반복 해제 Release Slice 분할 해제 @@ -2949,6 +2951,9 @@ mark-style Japanese 일본식 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Color Theme Picker 색상 테마 피커 Click or drag across the art to pick its colors; Shift adds more 아트웍을 클릭하거나 드래그하여 색상을 가져옵니다. Shift 키로 더 추가합니다 + Prominent Colors 주요 색상 + Save All Colors 모든 색상 저장 + Save Prominent Colors 주요 색상 저장 Reset to Default 기본값으로 재설정 Reset to White, Black 흰색, 검정으로 재설정 Reset {name} {name} 재설정 @@ -3273,6 +3278,20 @@ mark-style Japanese 일본식 Spot Color Objects 별색을 사용한 개체 Pattern Objects 패턴을 사용한 개체 Gradient Objects 그레이디언트를 사용한 개체 + Font Details 글꼴 세부 정보 + Linked Images 연결된 이미지 + Embedded Images 포함된 이미지 + Compound Paths 컴파운드 패스 + Text Objects 텍스트 개체 + Placed Documents 가져온 문서 + Symbol Instances 심볼 인스턴스 + Gradient Meshes 그레이디언트 메시 + Blends 블렌드 + Envelopes 엔벨로프 + Intertwine Groups 교차 그룹 + Opacity Masks 불투명 마스크 + Objects with Effects 효과를 사용한 개체 + Pattern Swatches 패턴 견본 Spot Colors 별색 Spot Colors… 별색… Spread: 확산: @@ -4129,6 +4148,10 @@ axis Both 양쪽 Dock Panel 패널 고정 Dock to Top 위쪽에 고정 Dock to Bottom 아래쪽에 고정 + Dock to Left 왼쪽에 고정 + Dock to Right 오른쪽에 고정 + Dock Toolbar 도구 막대 고정 + Float Toolbar 도구 막대 분리 Control Bar Options 컨트롤 막대 옵션 Tool Options 도구 옵션 Object Options 개체 옵션 @@ -4483,6 +4506,7 @@ theme Appearance 외관 @msg select a repeat 반복을 선택하세요 @msg select an Intertwine group 교차 그룹을 선택하세요 @msg select an Objects on Path group 패스 위 개체 그룹을 선택하세요 +@msg choose the object to detach with its Select/Move widget 분리할 개체를 선택/이동 위젯으로 고르세요 @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 컴파운드 패스, 블렌드, 엔벨로프, 반복 또는 교차 그룹 안의 패스에는 개체를 부착할 수 없습니다 @msg a clipping path can't take objects 클리핑 패스에는 개체를 부착할 수 없습니다 @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 블렌드, 엔벨로프, 반복 또는 교차 그룹 안의 개체는 패스에 부착할 수 없습니다 @@ -5089,6 +5113,7 @@ theme Appearance 외관 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}`은(는) VectorCraft 문서, PDF 또는 PDF 호환 .ai 파일이 아닙니다 @msg paths: give at least one file paths: 파일을 하나 이상 지정하세요 @msg replace: the object to replace was changed or deleted replace: 바꿀 개체가 변경되었거나 삭제되었습니다 +@msg the document it was prepared for is no longer active 준비한 문서가 더 이상 활성 상태가 아닙니다 @msg Couldn't place {name}: {e} {name}을(를) 가져올 수 없습니다: {e} @msg no print service 인쇄 서비스가 없습니다 @msg the print job has no data 인쇄 작업에 데이터가 없습니다 @@ -5152,6 +5177,7 @@ theme Appearance 외관 @msg every numbered name for it is taken in the Fonts folder 글꼴 폴더에서 이를 위한 번호 이름이 모두 사용 중입니다 @msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다 @msg No linked files to look for 찾을 링크된 파일이 없습니다 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 @msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 @@ -5516,7 +5542,9 @@ lighting Light 광원 @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`의 face는 front 또는 back이어야 합니다 @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`의 {key}은(는) {lo}에서 {hi} 사이의 숫자여야 합니다 @msg `{GRAPHICS}` must be an array `{GRAPHICS}`은(는) 배열이어야 합니다 -@msg Graphics map onto planar faces: use the Extrude or Plane object type 그래픽은 평평한 면에 매핑됩니다. 돌출 또는 평면 개체 유형을 사용하십시오 +@msg Graphics map onto the Plane, Extrude and Revolve object types 그래픽은 평면, 돌출, 회전체 개체 유형에 매핑됩니다 +@msg Revolve maps graphics onto its side: map them onto the front 회전체는 그래픽을 옆면에 매핑합니다. 앞면에 매핑하십시오 +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments 그래픽이 이 표면에 비해 너무 복잡합니다. 심볼의 아트를 단순화하거나 세그먼트를 줄이십시오 @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}`에는 그래픽을 최대 {MAX_GRAPHICS}개까지 넣을 수 있습니다 @msg A plane has one face: map its graphics onto the front 평면에는 면이 하나뿐입니다. 그래픽을 앞면에 매핑하십시오 @msg The graphics are too complex: simplify the symbols' art 그래픽이 너무 복잡합니다. 심볼의 아트를 단순화하십시오 @@ -6119,6 +6147,7 @@ axis Y Axis Y축 @msg message must be text message의 값은 텍스트여야 합니다 @msg allowContinue must be true or false allowContinue의 값은 true 또는 false여야 합니다 @msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다 +@msg Batch cannot play an action with a View menu step 일괄 처리에서는 보기 메뉴 단계가 있는 액션을 재생할 수 없습니다 Insert Menu Item… 메뉴 항목 삽입… Insert Menu Item 메뉴 항목 삽입 Insert Select Path 패스 선택 삽입 @@ -6302,6 +6331,13 @@ axis Y Axis Y축 Area Text 영역 문자 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 글리프 경계로 문자 측정 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options 픽셀에 스냅 옵션 + Pixel Snapping Options… 픽셀에 스냅 옵션… + Snap to Pixel While Drawing 그리는 동안 픽셀에 스냅 + Snap to Pixel While Moving 이동하는 동안 픽셀에 스냅 + Snap to Pixel While Scaling 크기를 조절하는 동안 픽셀에 스냅 + These options take effect when View › Snap to Pixel is on. 이 옵션은 보기 › 픽셀에 스냅이 켜져 있을 때 적용됩니다. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 글리프에 스냅 옵션 표시 Hide Snap to Glyph Options 글리프에 스냅 옵션 숨기기 @@ -6933,6 +6969,19 @@ breakText Glyphs 글리프 Show where this tool is 이 도구의 위치 표시 @msg missing `tutorial` (see learn.list) `tutorial`이 없습니다(learn.list 참조) @msg unknown tutorial `{id}` (see learn.list) 알 수 없는 튜토리얼 `{id}`(learn.list 참조) + View more 자세히 보기 + Finish tutorial 튜토리얼 완료 + Next Up 다음 + You finished the last tutorial in this skill. 이 기술의 마지막 튜토리얼을 완료했습니다. + Back to skills 기술로 돌아가기 + Open Tutorial Sample 튜토리얼 샘플 열기 + Finish Tutorial 튜토리얼 완료 + Next Tutorial 다음 튜토리얼 + Back to Skills 기술로 돌아가기 +@msg tutorial `{id}` has no practice sample 튜토리얼 `{id}`에 연습용 샘플이 없습니다 +@msg open the practice sample of `{id}` first (see learn.openSample) 먼저 `{id}`의 연습용 샘플을 여세요(learn.openSample 참조) +@msg finish `{id}` first (see learn.finish) 먼저 `{id}`을(를) 완료하세요(learn.finish 참조) +@msg `{id}` is the last tutorial in its skill `{id}`은(는) 해당 기술의 마지막 튜토리얼입니다 # Deferred plug-in effects Refresh Deferred Effects 보류된 효과 새로 고침 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 40facfda42..d38e0c113f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -185,6 +185,7 @@ @msg node {_1} is on a locked artboard knooppunt {_1} staat op een vergrendeld tekengebied @msg thumbnail: no page {n} (the file has 1) miniatuur: geen pagina {n} (het bestand heeft er 1) @msg select an Objects on Path group selecteer een groep Objecten op pad +@msg choose the object to detach with its Select/Move widget kies het object dat je wilt loskoppelen met zijn widget Selecteren/verplaatsen @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects een pad binnen een samengesteld pad, mengsel, omvorming, herhaling of verstrengelingsgroep kan geen objecten opnemen @msg a clipping path can't take objects een knippad kan geen objecten opnemen @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objecten binnen een mengsel, omvorming, herhaling of verstrengelingsgroep kunnen niet aan een pad worden gekoppeld @@ -1368,6 +1369,10 @@ Dock Panel Paneel dokken Dock to Top Bovenaan dokken Dock to Bottom Onderaan dokken + Dock to Left Links dokken + Dock to Right Rechts dokken + Dock Toolbar Werkbalk dokken + Float Toolbar Werkbalk laten zweven Control Bar Options Opties voor bedieningsbalk Tool Options Gereedschapsopties Object Options Objectopties @@ -3190,6 +3195,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors De rijen herstellen naar de kleuren van de artwork Color Theme Picker Kleurthemakiezer Click or drag across the art to pick its colors; Shift adds more Klik of sleep over het artwork om de kleuren ervan op te nemen; Shift voegt er meer toe + Prominent Colors Prominente kleuren + Save All Colors Alle kleuren opslaan + Save Prominent Colors Prominente kleuren opslaan Separate colors into rows Kleuren over rijen verdelen Unlink harmony colors Harmoniekleuren ontkoppelen 1 Color Job Opdracht in 1 kleur @@ -4246,6 +4254,19 @@ theme Appearance Vormgeving Spot Color Objects Objecten met spotkleuren Pattern Objects Objecten met patronen Gradient Objects Objecten met verlopen + Font Details Lettertypedetails + Linked Images Gekoppelde afbeeldingen + Embedded Images Ingesloten afbeeldingen + Compound Paths Samengestelde paden + Text Objects Tekstobjecten + Placed Documents Geplaatste documenten + Symbol Instances Symboolinstanties + Gradient Meshes Verlooprasters + Blends Vermengingen + Envelopes Omvormingen + Intertwine Groups Verstrengelingsgroepen + Opacity Masks Dekkingmaskers + Objects with Effects Objecten met effecten Spot Colors Spotkleuren Spot Colors (0) Spotkleuren (0) Swatch Options Kleurstaalopties @@ -5108,6 +5129,7 @@ imageTrace Outline Contour @msg paths contain invalid coordinates paden bevatten ongeldige coördinaten @msg paths: give at least one file paden: geef minstens één bestand @msg replace: the object to replace was changed or deleted vervangen: het te vervangen object is gewijzigd of verwijderd +@msg the document it was prepared for is no longer active het document waarvoor het is voorbereid, is niet langer actief @msg pattern missing patroon ontbreekt @msg patterns nested too deeply are filled with mid-grey patronen die te diep genest zijn, worden gevuld met middelgrijs @msg percent must be above 0 and at most 6400, not {p} percent moet boven 0 en maximaal 6400 zijn, niet {p} @@ -5884,6 +5906,9 @@ mark-style Japanese Japans @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} in `{GRAPHICS}` moet een getal van {lo} tot {hi} zijn @msg `{GRAPHICS}` must be an array `{GRAPHICS}` moet een array zijn @msg Graphics map onto planar faces: use the Extrude or Plane object type Illustraties worden op platte vlakken geplaatst: gebruik het objecttype Extruderen of Vlak +@msg Graphics map onto the Plane, Extrude and Revolve object types Illustraties worden op de objecttypen Vlak, Extruderen en Draaien geplaatst +@msg Revolve maps graphics onto its side: map them onto the front Draaien plaatst illustraties op de zijkant: plaats ze op de voorkant +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments De illustraties zijn te complex voor dit oppervlak: vereenvoudig de illustraties van de symbolen of verlaag Segmenten @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` neemt maximaal {MAX_GRAPHICS} illustraties op @msg A plane has one face: map its graphics onto the front Een vlak heeft één zijde: plaats de illustraties ervan op de voorkant @msg The graphics are too complex: simplify the symbols' art De illustraties zijn te complex: vereenvoudig de illustraties van de symbolen @@ -6391,6 +6416,8 @@ swatch Bright Violet Felviolet Objects on Path Tool Objecten-op-pad-gereedschap Attach Objects on Path Objecten aan pad koppelen Expand Objects on Path Objecten op pad uitbreiden + Detach Loskoppelen + Detach Object from Path Object van pad loskoppelen Release Live Paint Live Paint vrijgeven Release Repeat Herhaling vrijgeven Release Slice Slice vrijgeven @@ -6628,6 +6655,7 @@ axis Y Axis Y-as @msg message must be text message moet tekst zijn @msg allowContinue must be true or false allowContinue moet true of false zijn @msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen +@msg Batch cannot play an action with a View menu step Batch kan geen actie met een stap uit het menu Beeld afspelen Insert Menu Item… Menu-item invoegen… Insert Menu Item Menu-item invoegen Insert Select Path Pad selecteren invoegen @@ -6792,6 +6820,13 @@ axis Y Axis Y-as Area Text Vlaktekst # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Tekst meten op glyphbegrenzing +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opties voor uitlijnen op pixel + Pixel Snapping Options… Opties voor uitlijnen op pixel… + Snap to Pixel While Drawing Uitlijnen op pixel bij tekenen + Snap to Pixel While Moving Uitlijnen op pixel bij verplaatsen + Snap to Pixel While Scaling Uitlijnen op pixel bij schalen + These options take effect when View › Snap to Pixel is on. Deze opties werken wanneer Beeld › Uitlijnen op pixel is ingeschakeld. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Opties voor uitlijnen op glyph tonen Hide Snap to Glyph Options Opties voor uitlijnen op glyph verbergen @@ -7376,6 +7411,19 @@ breakText Glyphs Glyphs Show where this tool is Tonen waar dit gereedschap zich bevindt @msg missing `tutorial` (see learn.list) `tutorial` ontbreekt (zie learn.list) @msg unknown tutorial `{id}` (see learn.list) onbekende tutorial `{id}` (zie learn.list) + View more Meer weergeven + Finish tutorial Tutorial voltooien + Next Up Hierna + You finished the last tutorial in this skill. Je hebt de laatste tutorial van deze vaardigheid voltooid. + Back to skills Terug naar vaardigheden + Open Tutorial Sample Oefenbestand van tutorial openen + Finish Tutorial Tutorial voltooien + Next Tutorial Volgende tutorial + Back to Skills Terug naar vaardigheden +@msg tutorial `{id}` has no practice sample tutorial `{id}` heeft geen oefenbestand +@msg open the practice sample of `{id}` first (see learn.openSample) open eerst het oefenbestand van `{id}` (zie learn.openSample) +@msg finish `{id}` first (see learn.finish) voltooi eerst `{id}` (zie learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` is de laatste tutorial van zijn vaardigheid # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 770622cc1e..0ae5431a81 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -868,6 +868,10 @@ Dock Panel Zadokuj panel Dock to Top Zadokuj u góry Dock to Bottom Zadokuj u dołu + Dock to Left Zadokuj po lewej + Dock to Right Zadokuj po prawej + Dock Toolbar Zadokuj pasek narzędzi + Float Toolbar Odepnij pasek narzędzi Control Bar Options Opcje paska sterowania Tool Options Opcje narzędzia Object Options Opcje obiektu @@ -1757,6 +1761,8 @@ digits Malayalam Malajalam Objects on Path Tool Narzędzie Obiekty na ścieżce Attach Objects on Path Dołącz obiekty do ścieżki Expand Objects on Path Rozszerz obiekty na ścieżce + Detach Odłącz + Detach Object from Path Odłącz obiekt od ścieżki Release Live Paint Uwolnij aktywne malowanie Release Repeat Uwolnij powtórzenie Release Slice Uwolnij plasterek @@ -2800,6 +2806,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Przywróć w wierszach kolory kompozycji Color Theme Picker Próbnik motywu kolorów Click or drag across the art to pick its colors; Shift adds more Kliknij lub przeciągnij po kompozycji, aby pobrać jej kolory; Shift dodaje kolejne + Prominent Colors Dominujące kolory + Save All Colors Zapisz wszystkie kolory + Save Prominent Colors Zapisz dominujące kolory Separate colors into rows Rozdziel kolory na wiersze Unlink harmony colors Odłącz kolory harmonii 1 Color Job Praca 1-kolorowa @@ -3871,6 +3880,20 @@ theme Appearance Wygląd Spot Color Objects Obiekty z kolorami dodatkowymi Pattern Objects Obiekty ze wzorkami Gradient Objects Obiekty z gradientami + Font Details Szczegóły czcionek + Linked Images Połączone obrazy + Embedded Images Osadzone obrazy + Compound Paths Ścieżki złożone + Text Objects Obiekty tekstowe + Placed Documents Umieszczone dokumenty + Symbol Instances Wystąpienia symboli + Gradient Meshes Siatki gradientowe + Blends Przejścia + Envelopes Koperty + Intertwine Groups Grupy przeplatania + Opacity Masks Maski krycia + Objects with Effects Obiekty z efektami + Pattern Swatches Próbki wzorków Spot Colors Kolory dodatkowe Swatch Options Opcje próbki Ungroup Color Group Rozgrupuj grupę kolorów @@ -4881,6 +4904,7 @@ imageTrace Outline Kontur @msg paths contain invalid coordinates ścieżki zawierają nieprawidłowe współrzędne @msg paths: give at least one file paths: podaj co najmniej jeden plik @msg replace: the object to replace was changed or deleted replace: obiekt do zastąpienia został zmieniony lub usunięty +@msg the document it was prepared for is no longer active dokument, dla którego to przygotowano, nie jest już aktywny @msg pattern missing brak wzorka @msg patterns nested too deeply are filled with mid-grey zbyt głęboko zagnieżdżone wzorki są wypełniane średnią szarością @msg percent must be above 0 and at most 6400, not {p} percent musi być większy od 0 i nie większy niż 6400, nie {p} @@ -4916,6 +4940,7 @@ imageTrace Outline Kontur @msg select a repeat zaznacz powtórzenie @msg select an Intertwine group zaznacz grupę przeplotu @msg select an Objects on Path group zaznacz grupę obiektów na ścieżce +@msg choose the object to detach with its Select/Move widget wybierz obiekt do odłączenia jego uchwytem Zaznacz/Przesuń @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects do ścieżki wewnątrz ścieżki złożonej, przejścia, koperty, powtórzenia lub grupy przeplotu nie można dołączyć obiektów @msg a clipping path can't take objects do ścieżki przycinającej nie można dołączyć obiektów @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path obiektów wewnątrz przejścia, koperty, powtórzenia lub grupy przeplotu nie można dołączyć do ścieżki @@ -5241,6 +5266,7 @@ imageTrace Outline Kontur @msg every numbered name for it is taken in the Fonts folder wszystkie numerowane nazwy dla niego są zajęte w folderze czcionek @msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu @msg No linked files to look for Brak połączonych plików do wyszukania +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} @msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} @@ -5768,7 +5794,9 @@ mark-style Japanese Japońskie @msg `{GRAPHICS}` face must be front or back face w `{GRAPHICS}` musi mieć wartość front lub back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} w `{GRAPHICS}` musi być liczbą od {lo} do {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` musi być tablicą -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiki są nakładane na płaskie ściany: użyj typu obiektu Wyciągnięcie lub Płaszczyzna +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafiki są nakładane na obiekty typu Płaszczyzna, Wyciągnięcie i Obrót +@msg Revolve maps graphics onto its side: map them onto the front Obrót nakłada grafiki na swoją powierzchnię boczną: umieść je na przedniej stronie +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafiki są zbyt złożone dla tej powierzchni: uprość rysunki symboli lub zmniejsz Segmenty @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` przyjmuje najwyżej {MAX_GRAPHICS} grafik @msg A plane has one face: map its graphics onto the front Płaszczyzna ma jedną stronę: umieść jej grafiki na przedniej stronie @msg The graphics are too complex: simplify the symbols' art Grafiki są zbyt złożone: uprość rysunki symboli @@ -6387,6 +6415,7 @@ axis Y Axis Oś Y @msg message must be text message musi być tekstem @msg allowContinue must be true or false allowContinue musi mieć wartość true lub false @msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem +@msg Batch cannot play an action with a View menu step Przetwarzanie wsadowe nie może odtworzyć operacji z krokiem z menu Widok Insert Menu Item… Wstaw polecenie menu… Insert Menu Item Wstaw polecenie menu Insert Select Path Wstaw zaznaczenie ścieżki @@ -6570,6 +6599,13 @@ axis Y Axis Oś Y Area Text Tekst akapitowy # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mierz tekst według granic glifów +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opcje przyciągania do pikseli + Pixel Snapping Options… Opcje przyciągania do pikseli… + Snap to Pixel While Drawing Przyciągaj do pikseli podczas rysowania + Snap to Pixel While Moving Przyciągaj do pikseli podczas przesuwania + Snap to Pixel While Scaling Przyciągaj do pikseli podczas skalowania + These options take effect when View › Snap to Pixel is on. Te opcje działają, gdy włączona jest opcja Widok › Przyciągaj do pikseli. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Pokaż opcje przyciągania do glifów Hide Snap to Glyph Options Ukryj opcje przyciągania do glifów @@ -7199,6 +7235,19 @@ breakText Glyphs Glify Show where this tool is Pokaż, gdzie jest to narzędzie @msg missing `tutorial` (see learn.list) brak `tutorial` (patrz learn.list) @msg unknown tutorial `{id}` (see learn.list) nieznany samouczek `{id}` (patrz learn.list) + View more Pokaż więcej + Finish tutorial Zakończ samouczek + Next Up Następny + You finished the last tutorial in this skill. Ukończono ostatni samouczek tej umiejętności. + Back to skills Powrót do umiejętności + Open Tutorial Sample Otwórz przykład samouczka + Finish Tutorial Zakończ samouczek + Next Tutorial Następny samouczek + Back to Skills Powrót do umiejętności +@msg tutorial `{id}` has no practice sample samouczek `{id}` nie ma przykładu do ćwiczeń +@msg open the practice sample of `{id}` first (see learn.openSample) najpierw otwórz przykład do ćwiczeń samouczka `{id}` (patrz learn.openSample) +@msg finish `{id}` first (see learn.finish) najpierw zakończ `{id}` (patrz learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` to ostatni samouczek swojej umiejętności # Deferred plug-in effects Refresh Deferred Effects Odśwież odroczone efekty diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 77fce38c53..854894cb68 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1410,6 +1410,8 @@ antiAlias Sharp Nítido Objects on Path Tool Ferramenta Objetos no Caminho Attach Objects on Path Anexar Objetos ao Caminho Expand Objects on Path Expandir Objetos no Caminho + Detach Desanexar + Detach Object from Path Desanexar Objeto do Caminho Release Live Paint Liberar Pintura ao Vivo Release Repeat Liberar Repetição Release Slice Liberar Fatia @@ -2446,6 +2448,9 @@ digits Malayalam Malaiala Reset the rows to the artwork's colors Redefinir as linhas para as cores da arte Color Theme Picker Seletor de Tema de Cores Click or drag across the art to pick its colors; Shift adds more Clique ou arraste sobre a arte para pegar suas cores; Shift adiciona mais + Prominent Colors Cores Proeminentes + Save All Colors Salvar Todas as Cores + Save Prominent Colors Salvar Cores Proeminentes Reset to Default Redefinir para o Padrão Reset to White, Black Redefinir para Branco, Preto Reset with Mesh… Redefinir com Malha… @@ -2910,6 +2915,19 @@ digits Malayalam Malaiala Spot Color Objects Objetos com cores spot Pattern Objects Objetos com padrões Gradient Objects Objetos com gradientes + Font Details Detalhes das fontes + Linked Images Imagens vinculadas + Embedded Images Imagens incorporadas + Compound Paths Caminhos compostos + Text Objects Objetos de texto + Placed Documents Documentos colocados + Symbol Instances Instâncias de símbolo + Gradient Meshes Malhas de gradiente + Blends Mesclagens + Envelopes Envelopes + Intertwine Groups Grupos entrelaçados + Opacity Masks Máscaras de opacidade + Objects with Effects Objetos com efeitos Spot Colors Cores Spot Spot Colors… Cores Spot… Spread: Propagação: @@ -3379,6 +3397,10 @@ imageTrace Outline Contorno Dock Panel Encaixar Painel Dock to Top Encaixar na Parte Superior Dock to Bottom Encaixar na Parte Inferior + Dock to Left Encaixar à Esquerda + Dock to Right Encaixar à Direita + Dock Toolbar Encaixar Barra de Ferramentas + Float Toolbar Flutuar Barra de Ferramentas Control Bar Options Opções da Barra de Controle Tool Options Opções de Ferramenta Object Options Opções de Objeto @@ -4204,6 +4226,7 @@ axis Both Ambos @msg paths contain invalid coordinates os traçados contêm coordenadas inválidas @msg paths: give at least one file paths: informe pelo menos um arquivo @msg replace: the object to replace was changed or deleted replace: o objeto a ser substituído foi alterado ou excluído +@msg the document it was prepared for is no longer active o documento para o qual foi preparado não está mais ativo @msg pattern missing padrão ausente @msg patterns nested too deeply are filled with mid-grey padrões aninhados com profundidade excessiva são preenchidos com cinza médio @msg percent must be above 0 and at most 6400, not {p} percent deve ser maior que 0 e no máximo 6400, não {p} @@ -4239,6 +4262,7 @@ axis Both Ambos @msg select a repeat selecione uma repetição @msg select an Intertwine group selecione um grupo entrelaçado @msg select an Objects on Path group selecione um grupo de objetos no caminho +@msg choose the object to detach with its Select/Move widget escolha o objeto a desanexar com o controle Selecionar/Mover dele @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects um caminho dentro de um caminho composto, mesclagem, envelope, repetição ou grupo entrelaçado não pode receber objetos @msg a clipping path can't take objects um caminho de recorte não pode receber objetos @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objetos dentro de uma mesclagem, envelope, repetição ou grupo entrelaçado não podem ser anexados a um caminho @@ -5343,6 +5367,7 @@ axis Both Ambos @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise o texto de área em molduras baixas demais para uma linha da fonte em que aparece recebeu molduras mais altas ({_1}): senão o texto não apareceria @msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento @msg No linked files to look for Nenhum arquivo vinculado para procurar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela De-Interlace Desentrelaçar Eliminate Eliminar Create New Fields by Criar Novos Campos por @@ -5887,7 +5912,9 @@ mark-style Japanese Japonesas @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` deve ser front ou back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` deve ser um número de {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve ser uma matriz -@msg Graphics map onto planar faces: use the Extrude or Plane object type Os gráficos são mapeados em faces planas: use o tipo de objeto Extrusão ou Plano +@msg Graphics map onto the Plane, Extrude and Revolve object types Os gráficos são mapeados nos tipos de objeto Plano, Extrusão e Revolver +@msg Revolve maps graphics onto its side: map them onto the front Revolver mapeia os gráficos na sua lateral: mapeie-os na face frontal +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Os gráficos são complexos demais para esta superfície: simplifique a arte dos símbolos ou reduza Segmentos @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` aceita no máximo {MAX_GRAPHICS} gráficos @msg A plane has one face: map its graphics onto the front Um plano tem uma única face: mapeie seus gráficos na face frontal @msg The graphics are too complex: simplify the symbols' art Os gráficos são complexos demais: simplifique a arte dos símbolos @@ -6448,6 +6475,7 @@ axis Y Axis Eixo Y @msg message must be text message deve ser texto @msg allowContinue must be true or false allowContinue deve ser true ou false @msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada +@msg Batch cannot play an action with a View menu step Lote não pode reproduzir uma ação com uma etapa do menu Exibir Insert Menu Item… Inserir Item de Menu… Insert Menu Item Inserir Item de Menu Insert Select Path Inserir Seleção de Caminho @@ -6631,6 +6659,13 @@ axis Y Axis Eixo Y Area Text Texto de Área # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Medir Texto pelos Limites do Glifo +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opções de Ajuste ao Pixel + Pixel Snapping Options… Opções de Ajuste ao Pixel… + Snap to Pixel While Drawing Ajustar ao Pixel ao Desenhar + Snap to Pixel While Moving Ajustar ao Pixel ao Mover + Snap to Pixel While Scaling Ajustar ao Pixel ao Dimensionar + These options take effect when View › Snap to Pixel is on. Essas opções têm efeito quando Exibir › Ajustar ao Pixel está ativado. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar Opções de Ajuste ao Glifo Hide Snap to Glyph Options Ocultar Opções de Ajuste ao Glifo @@ -7416,6 +7451,19 @@ swatch Yellow Green Verde-amarelado Show where this tool is Mostrar onde está esta ferramenta @msg missing `tutorial` (see learn.list) falta `tutorial` (veja learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconhecido `{id}` (veja learn.list) + View more Ver mais + Finish tutorial Concluir tutorial + Next Up A seguir + You finished the last tutorial in this skill. Você concluiu o último tutorial desta habilidade. + Back to skills Voltar às habilidades + Open Tutorial Sample Abrir Amostra do Tutorial + Finish Tutorial Concluir Tutorial + Next Tutorial Próximo Tutorial + Back to Skills Voltar às Habilidades +@msg tutorial `{id}` has no practice sample o tutorial `{id}` não tem amostra de prática +@msg open the practice sample of `{id}` first (see learn.openSample) abra primeiro a amostra de prática de `{id}` (veja learn.openSample) +@msg finish `{id}` first (see learn.finish) conclua primeiro `{id}` (veja learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` é o último tutorial da sua habilidade # Deferred plug-in effects Refresh Deferred Effects Atualizar Efeitos Adiados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b546e87258..9af5f4ceb9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1612,6 +1612,8 @@ file-filter Templates Шаблоны Objects on Path Tool Инструмент «Объекты на контуре» Attach Objects on Path Прикрепить объекты к контуру Expand Objects on Path Разобрать объекты на контуре + Detach Открепить + Detach Object from Path Открепить объект от контура Release Live Paint Освободить быструю заливку Release Repeat Освободить повтор Release Slice Освободить фрагмент @@ -2942,6 +2944,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Сбросить строки к цветам рисунка Color Theme Picker Выбор цветовой темы Click or drag across the art to pick its colors; Shift adds more Щёлкните или проведите по рисунку, чтобы взять его цвета; Shift добавляет ещё + Prominent Colors Преобладающие цвета + Save All Colors Сохранить все цвета + Save Prominent Colors Сохранить преобладающие цвета Reset to Default Сбросить к значениям по умолчанию Reset to White, Black Сбросить к белому, чёрному Reset with Mesh Сбросить с сеткой @@ -3494,6 +3499,19 @@ digits Malayalam Малаялам Spot Color Objects Объекты с плашечными цветами Pattern Objects Объекты с узорами Gradient Objects Объекты с градиентами + Font Details Сведения о шрифтах + Linked Images Связанные изображения + Embedded Images Внедрённые изображения + Compound Paths Составные контуры + Text Objects Текстовые объекты + Placed Documents Размещённые документы + Symbol Instances Экземпляры символов + Gradient Meshes Градиентные сетки + Blends Переходы + Envelopes Оболочки + Intertwine Groups Группы переплетения + Opacity Masks Непрозрачные маски + Objects with Effects Объекты с эффектами Spot Colors Плашечные цвета Spot Colors… Плашечные цвета… Spread: Разворот: @@ -4189,6 +4207,10 @@ axis Both Оба Dock Panel Прикрепить панель Dock to Top Прикрепить сверху Dock to Bottom Прикрепить снизу + Dock to Left Прикрепить слева + Dock to Right Прикрепить справа + Dock Toolbar Прикрепить панель инструментов + Float Toolbar Сделать панель инструментов плавающей Control Bar Options Параметры панели управления Tool Options Параметры инструмента Object Options Параметры объекта @@ -4785,6 +4807,7 @@ axis Both Оба @msg paths contain invalid coordinates контуры содержат недопустимые координаты @msg paths: give at least one file paths: укажите как минимум один файл @msg replace: the object to replace was changed or deleted replace: заменяемый объект был изменён или удалён +@msg the document it was prepared for is no longer active документ, для которого это было подготовлено, больше не активен @msg pattern missing узор отсутствует @msg patterns nested too deeply are filled with mid-grey слишком глубоко вложенные узоры заполняются серым @msg percent must be above 0 and at most 6400, not {p} percent должен быть больше 0 и не более 6400, а не {p} @@ -4820,6 +4843,7 @@ axis Both Оба @msg select a repeat выделите повтор @msg select an Intertwine group выделите группу переплетения @msg select an Objects on Path group выделите группу объектов на контуре +@msg choose the object to detach with its Select/Move widget выберите открепляемый объект его элементом «Выбрать/Переместить» @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects к контуру внутри составного контура, перехода, оболочки, повтора или группы переплетения нельзя прикрепить объекты @msg a clipping path can't take objects к контуру обрезки нельзя прикрепить объекты @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path объекты внутри перехода, оболочки, повтора или группы переплетения нельзя прикрепить к контуру @@ -5144,6 +5168,7 @@ axis Both Оба @msg every numbered name for it is taken in the Fonts folder все нумерованные имена для него уже заняты в папке Fonts @msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа @msg No linked files to look for Нет связанных файлов для поиска +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы @@ -5541,7 +5566,9 @@ lighting Light Свет @msg `{GRAPHICS}` face must be front or back face в `{GRAPHICS}` должен быть front или back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} в `{GRAPHICS}` должен быть числом от {lo} до {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` должен быть массивом -@msg Graphics map onto planar faces: use the Extrude or Plane object type Графика наносится на плоские грани: используйте тип объекта «Выдавливание» или «Плоскость» +@msg Graphics map onto the Plane, Extrude and Revolve object types Графика наносится на объекты типов «Плоскость», «Выдавливание» и «Вращение» +@msg Revolve maps graphics onto its side: map them onto the front «Вращение» наносит графику на свою боковую поверхность: размещайте её на передней стороне +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Графика слишком сложна для этой поверхности: упростите иллюстрации символов или уменьшите «Сегменты» @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` принимает не более {MAX_GRAPHICS} графических элементов @msg A plane has one face: map its graphics onto the front У плоскости одна сторона: размещайте её графику на передней стороне @msg The graphics are too complex: simplify the symbols' art Графика слишком сложна: упростите иллюстрации символов @@ -6144,6 +6171,7 @@ axis Y Axis Ось Y @msg message must be text message должно быть текстом @msg allowContinue must be true or false allowContinue должно быть true или false @msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой +@msg Batch cannot play an action with a View menu step Пакет не может воспроизвести действие с шагом из меню Просмотр Insert Menu Item… Вставить пункт меню… Insert Menu Item Вставить пункт меню Insert Select Path Вставить выделение контура @@ -6693,6 +6721,13 @@ theme Appearance Оформление Area Text Текст в области # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Измерять текст по границам глифа +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Параметры привязки к пикселю + Pixel Snapping Options… Параметры привязки к пикселю… + Snap to Pixel While Drawing Привязать к пикселю при рисовании + Snap to Pixel While Moving Привязать к пикселю при перемещении + Snap to Pixel While Scaling Привязать к пикселю при масштабировании + These options take effect when View › Snap to Pixel is on. Эти параметры действуют, когда включено Просмотр › Привязать к пикселю. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показать параметры привязки к глифу Hide Snap to Glyph Options Скрыть параметры привязки к глифу @@ -7432,6 +7467,19 @@ shape Open Открытый Show where this tool is Показать, где находится этот инструмент @msg missing `tutorial` (see learn.list) отсутствует `tutorial` (см. learn.list) @msg unknown tutorial `{id}` (see learn.list) неизвестный урок `{id}` (см. learn.list) + View more Подробнее + Finish tutorial Завершить урок + Next Up Далее + You finished the last tutorial in this skill. Вы завершили последний урок этого навыка. + Back to skills Назад к навыкам + Open Tutorial Sample Открыть пример урока + Finish Tutorial Завершить урок + Next Tutorial Следующий урок + Back to Skills Назад к навыкам +@msg tutorial `{id}` has no practice sample у урока `{id}` нет учебного примера +@msg open the practice sample of `{id}` first (see learn.openSample) сначала откройте учебный пример урока `{id}` (см. learn.openSample) +@msg finish `{id}` first (see learn.finish) сначала завершите `{id}` (см. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` является последним уроком своего навыка # Deferred plug-in effects Refresh Deferred Effects Обновить отложенные эффекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9271ac4bac..7c9d977648 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1550,6 +1550,8 @@ font-technology Variable Variabla Objects on Path Tool Verktyg för objekt på bana Attach Objects on Path Fäst objekt på bana Expand Objects on Path Expandera objekt på bana + Detach Lossa + Detach Object from Path Lossa objekt från bana Release Live Paint Släpp direktmålning Release Repeat Släpp upprepning Release Slice Släpp skiva @@ -2848,6 +2850,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Återställ raderna till teckningens färger Color Theme Picker Färgtemaväljare Click or drag across the art to pick its colors; Shift adds more Klicka eller dra över teckningen för att hämta dess färger; Skift lägger till fler + Prominent Colors Framträdande färger + Save All Colors Spara alla färger + Save Prominent Colors Spara framträdande färger Reset to Default Återställ till standard Reset to White, Black Återställ till vitt, svart Reset with Mesh Återställ med nät @@ -3384,6 +3389,19 @@ digits Malayalam Malayalam Spot Color Objects Objekt med dekorfärger Pattern Objects Objekt med mönster Gradient Objects Objekt med gradienter + Font Details Typsnittsdetaljer + Linked Images Länkade bilder + Embedded Images Inbäddade bilder + Compound Paths Sammansatta banor + Text Objects Textobjekt + Placed Documents Placerade dokument + Symbol Instances Symbolinstanser + Gradient Meshes Gradientnät + Blends Blandningar + Envelopes Kuvert + Intertwine Groups Sammanflätningsgrupper + Opacity Masks Opacitetsmasker + Objects with Effects Objekt med effekter Spot Colors Dekorfärger Spot Colors… Dekorfärger… Spread: Spridning: @@ -4068,6 +4086,10 @@ axis Both Båda Dock Panel Docka panel Dock to Top Docka överst Dock to Bottom Docka nederst + Dock to Left Docka till vänster + Dock to Right Docka till höger + Dock Toolbar Docka verktygsfältet + Float Toolbar Gör verktygsfältet flytande Control Bar Options Alternativ för kontrollfältet Tool Options Verktygsalternativ Object Options Objektalternativ @@ -4649,6 +4671,7 @@ axis Both Båda @msg paths contain invalid coordinates banorna innehåller ogiltiga koordinater @msg paths: give at least one file paths: ange minst en fil @msg replace: the object to replace was changed or deleted replace: objektet som skulle ersättas har ändrats eller tagits bort +@msg the document it was prepared for is no longer active dokumentet som det förbereddes för är inte längre aktivt @msg pattern missing mönster saknas @msg patterns nested too deeply are filled with mid-grey mönster som är kapslade för djupt fylls med mellangrått @msg percent must be above 0 and at most 6400, not {p} percent måste vara över 0 och högst 6400, inte {p} @@ -5002,6 +5025,7 @@ axis Both Båda @msg every numbered name for it is taken in the Fonts folder alla numrerade namn för den är upptagna i mappen Typsnitt @msg every numbered name for {name} is taken in the document's folder alla numrerade namn för {name} är upptagna i dokumentets mapp @msg No linked files to look for Inga länkade filer att leta efter +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Länken finns i en symbol, en diagramdesign, ett mönster eller en opacitetsmask och har ingen plats på arbetsytan Services Tjänster Hide VectorCraft Göm VectorCraft Zoom Zoom @@ -5409,7 +5433,9 @@ lighting Light Ljus @msg `{GRAPHICS}` face must be front or back face i `{GRAPHICS}` måste vara front eller back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} i `{GRAPHICS}` måste vara ett tal från {lo} till {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` måste vara en matris -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafik placeras på plana sidor: använd objekttypen Extrudera eller Plan +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafik placeras på objekttyperna Plan, Extrudera och Rotera runt axel +@msg Revolve maps graphics onto its side: map them onto the front Rotera runt axel placerar grafik på sin mantelyta: placera den på framsidan +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafiken är för komplex för den här ytan: förenkla symbolernas teckning eller minska Segment @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` tar högst {MAX_GRAPHICS} grafiker @msg A plane has one face: map its graphics onto the front Ett plan har en sida: placera dess grafik på framsidan @msg The graphics are too complex: simplify the symbols' art Grafiken är för komplex: förenkla symbolernas teckning @@ -6019,6 +6045,7 @@ theme Appearance Utseende @msg no bound object reports a value (locked objects and embedded images report none) inget bundet objekt rapporterar ett värde (låsta objekt och inbäddade bilder rapporterar inget) @msg select an Intertwine group markera en sammanflätningsgrupp @msg select an Objects on Path group markera en grupp med objekt på bana +@msg choose the object to detach with its Select/Move widget välj objektet som ska lossas med dess Markera/Flytta-reglage @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects en bana inuti en sammansatt bana, en blandning, ett kuvert, en upprepning eller en sammanflätningsgrupp kan inte ta emot objekt @msg a clipping path can't take objects en klippbana kan inte ta emot objekt @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekt inuti en blandning, ett kuvert, en upprepning eller en sammanflätningsgrupp kan inte fästas på en bana @@ -6514,6 +6541,7 @@ axis Y Axis Y-axel @msg message must be text message måste vara text @msg allowContinue must be true or false allowContinue måste vara true eller false @msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp +@msg Batch cannot play an action with a View menu step Batch kan inte spela upp en åtgärd med ett steg från menyn Visa Insert Menu Item… Infoga menyalternativ… Insert Menu Item Infoga menyalternativ Insert Select Path Infoga markering av bana @@ -6697,6 +6725,13 @@ axis Y Axis Y-axel Area Text Områdestext # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mät text efter glyfgränser +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Alternativ för fäst mot pixel + Pixel Snapping Options… Alternativ för fäst mot pixel… + Snap to Pixel While Drawing Fäst mot pixel vid ritning + Snap to Pixel While Moving Fäst mot pixel vid flyttning + Snap to Pixel While Scaling Fäst mot pixel vid skalning + These options take effect when View › Snap to Pixel is on. De här alternativen gäller när Visa › Fäst mot pixel är på. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Visa alternativ för fäst mot glyf Hide Snap to Glyph Options Dölj alternativ för fäst mot glyf @@ -8782,6 +8817,19 @@ digits Hindi Hindi Show where this tool is Visa var det här verktyget finns @msg missing `tutorial` (see learn.list) `tutorial` saknas (se learn.list) @msg unknown tutorial `{id}` (see learn.list) okänd självstudie `{id}` (se learn.list) + View more Visa mer + Finish tutorial Slutför självstudien + Next Up Nästa + You finished the last tutorial in this skill. Du har slutfört den sista självstudien i den här färdigheten. + Back to skills Tillbaka till färdigheter + Open Tutorial Sample Öppna övningsexempel för självstudie + Finish Tutorial Slutför självstudie + Next Tutorial Nästa självstudie + Back to Skills Tillbaka till färdigheter +@msg tutorial `{id}` has no practice sample självstudien `{id}` har inget övningsexempel +@msg open the practice sample of `{id}` first (see learn.openSample) öppna först övningsexemplet för `{id}` (se learn.openSample) +@msg finish `{id}` first (see learn.finish) slutför först `{id}` (se learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` är den sista självstudien i sin färdighet # Deferred plug-in effects Refresh Deferred Effects Uppdatera uppskjutna effekter diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 605ff0382b..a18f4d1a58 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -32,6 +32,59 @@ fn hindi_resolves_locales_and_uses_zero_one_plurals() { } } +/// Every Hindi row uses the terms `docs/localization-hi.md` sets for what its English names: +/// सेव for saving, स्वॉच for a swatch, क्रॉप for cropping, रिज़ॉल्यूशन for a resolution and पाथ for +/// a path, never पाठ (a reading), and a number of points is a number (संख्या) in points (पॉइंट). +#[test] +fn hindi_rows_use_the_glossary_term_for_what_their_english_names() { + let hi = Lang::from_code("hi").expect("hi registered"); + let (entries, _) = parse_entries(hi.0.source); + let mut wrong = Vec::new(); + for (_, src, translation) in &entries { + let english = src.to_lowercase(); + let names = |words: &[&str]| english.split(|c: char| !c.is_ascii_alphabetic()).any(|w| words.contains(&w)); + let banned: [(bool, &[&str]); 6] = [ + (names(&["save", "saves", "saved", "saving"]), &["बचा", "सहेज"]), + (names(&["swatch", "swatches"]), &["घड़ी"]), + (names(&["crop"]), &["फसल"]), + (names(&["resolution", "resolutions"]), &["संकल्प"]), + (names(&["path", "paths"]), &["पथ"]), + (true, &["पाठ", "कई अंक"]), + ]; + for (applies, words) in banned { + if let Some(word) = words.iter().find(|w| applies && translation.contains(**w)) { + wrong.push(format!("{word} in {src:?} -> {translation:?}")); + } + } + if english.contains("number of points") && !(translation.contains("संख्या") && translation.contains("पॉइंट")) { + wrong.push(format!("no number in points in {src:?} -> {translation:?}")); + } + } + assert!(wrong.is_empty(), "{} Hindi rows use the wrong term:\n{}", wrong.len(), wrong.join("\n")); +} + +/// Hindi messages and labels keep the condition, quantity and clauses of their English. +#[test] +fn hindi_rows_keep_the_meaning_of_their_english() { + let hi = Lang::from_code("hi").expect("hi registered"); + assert_eq!(message(hi, "`shift` must be a number of points"), "`shift` पॉइंट में एक संख्या होनी चाहिए"); + assert_eq!( + message(hi, "`spacing` must be a number of points, 0 or more, or \"auto\""), + "`spacing` पॉइंट में 0 या उससे बड़ी संख्या, या \"auto\" होनी चाहिए" + ); + assert_eq!(tr(hi, "Check an artboard to save."), "सेव करने के लिए किसी आर्टबोर्ड पर चेक लगाएँ।"); + assert_eq!(tr(hi, "Save the applied preferences to a JSON file"), "लागू प्रेफ़रेंसेज़ को JSON फ़ाइल में सेव करें"); + assert_eq!(message(hi, "the script is not UTF-8 text"), "स्क्रिप्ट UTF-8 टेक्स्ट नहीं है"); + assert_eq!(message(hi, "only solid-colour swatches can be spot colours"), "केवल ठोस रंग वाले स्वॉच स्पॉट रंग हो सकते हैं"); + assert_eq!(message(hi, "select area type"), "एरिया टाइप सिलेक्ट करें"); + assert_eq!(message(hi, "12 MB of images, at most 10 MB"), "इमेज के 12 MB, अधिकतम 10 MB"); + let all = + message(hi, "SVG keeps 1 of the document's 3 artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all."); + assert!(all.contains("3 आर्टबोर्ड में से 1") && all.contains("सभी आर्टबोर्ड रखने के लिए"), "{all}"); + let left_out = message(hi, "the file was read up to an error (bad xref): what follows it was left out"); + assert!(left_out.contains("उसके बाद का हिस्सा छोड़ दिया गया"), "{left_out}"); +} + /// Locale tags name their language by region or script (in either case, with an encoding or /// modifier), and a tag that names no language resolves to none. #[test] @@ -184,6 +237,23 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// The Objects on Path Options section's Detach button, its undo step and the message for a +/// Detach with no object chosen. +#[test] +fn objects_on_path_detach_strings_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in ["Detach", "Detach Object from Path"] { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } + let choose = "choose the object to detach with its Select/Move widget"; + assert!(message_literals().contains(choose)); + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + assert_ne!(message(lang, choose), choose, "{code}"); + } +} + /// The Window › Brush Libraries menu, built from the built-in library table, names each library. #[test] fn built_in_brush_library_names_are_in_complete_catalogs() { @@ -239,24 +309,33 @@ fn control_bar_panel_menu_labels_are_in_complete_catalogs() { } } -/// The titles of Document Info's Spot Color Objects, Pattern Objects and Gradient Objects -/// categories, which the panel, its menu and the Objects category's counts show. +/// Document Info's labels: every category title, which the panel and its menu show, every count +/// label of the Objects category, and the Document category's fixed row labels (the artboard rows +/// show artboard names and are left out). #[test] -fn document_info_object_category_titles_are_in_complete_catalogs() { +fn document_info_labels_are_in_complete_catalogs() { let mut session = vectorcraft_engine::Session::new(); session.execute("file.new", &serde_json::json!({"width": 100, "height": 100})).unwrap(); + session.execute("shape.rectangle", &serde_json::json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); let info = session.execute("document.info", &serde_json::json!({})).unwrap(); - let titles: Vec<&str> = info["sections"] - .as_array() - .unwrap() - .iter() - .filter(|s| matches!(s["id"].as_str(), Some("spotColors" | "patterns" | "gradients"))) - .filter_map(|s| s["title"].as_str()) - .collect(); - assert_eq!(titles, ["Spot Color Objects", "Pattern Objects", "Gradient Objects"]); + let sections = info["sections"].as_array().unwrap(); + let titles: Vec<&str> = sections.iter().filter_map(|s| s["title"].as_str()).collect(); + let categories: Vec<&str> = vectorcraft_engine::cmd::CATEGORIES.iter().map(|&(_, title)| title).collect(); + assert_eq!(titles, categories); + let mut labels = categories; + labels.extend(vectorcraft_engine::cmd::OBJECT_LABELS.iter().map(|&(_, label)| label)); + let objects = sections.iter().find(|s| s["id"] == "objects").unwrap()["rows"].as_array().unwrap(); + assert!(objects.iter().any(|r| r[0] == "Path Length"), "{objects:?}"); + labels.push("Path Length"); + let document = sections.iter().find(|s| s["id"] == "document").unwrap()["rows"].as_array().unwrap(); + let artboards = document.iter().position(|r| r[0] == "Artboards").unwrap(); + let names = artboards + 1..=artboards + document[artboards][1].as_str().unwrap().parse::().unwrap(); + let fixed: Vec<&str> = document.iter().enumerate().filter(|(k, _)| !names.contains(k)).filter_map(|(_, r)| r[0].as_str()).collect(); + assert_eq!(fixed.len(), 9, "{fixed:?}"); + labels.extend(fixed); for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { - for title in &titles { - assert!(lang.catalog().plain(title).is_some(), "{} is missing {title}", lang.code); + for label in &labels { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); } } } diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 107f30f3ea..6a9f8d51fc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2716,6 +2716,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın Color Theme Picker Renk Teması Seçici Click or drag across the art to pick its colors; Shift adds more Renklerini almak için resme tıklayın veya üzerinde sürükleyin; Shift daha fazlasını ekler + Prominent Colors Belirgin Renkler + Save All Colors Tüm Renkleri Kaydet + Save Prominent Colors Belirgin Renkleri Kaydet Reset to Default Varsayılana Sıfırla Reset to White, Black Beyaz, Siyah'a Sıfırla Reset with Mesh Mesh ile Sıfırla @@ -3283,6 +3286,19 @@ digits Malayalam Malayalam Spot Color Objects Spot Renkli Nesneler Pattern Objects Desenli Nesneler Gradient Objects Degradeli Nesneler + Font Details Yazı Tipi Ayrıntıları + Linked Images Bağlantılı Görseller + Embedded Images Gömülü Görseller + Compound Paths Bileşik Yollar + Text Objects Metin Nesneleri + Placed Documents Yerleştirilen Belgeler + Symbol Instances Sembol Örnekleri + Gradient Meshes Degrade Örgüler + Blends Karışımlar + Envelopes Zarflar + Intertwine Groups İç İçe Geçme Grupları + Opacity Masks Opaklık Maskeleri + Objects with Effects Efektli Nesneler Spot Colors Nokta Renkleri Spot Colors… Nokta Renkleri… Spread: Yayılma: @@ -3952,6 +3968,10 @@ axis Both Her İkisi Dock Panel Bağlantı İstasyonu Paneli Dock to Top Üste yerleştir Dock to Bottom Alta yerleştir + Dock to Left Sola yerleştir + Dock to Right Sağa yerleştir + Dock Toolbar Araç çubuğunu yerleştir + Float Toolbar Araç Çubuğunu Yüzer Yap Control Bar Options Kontrol Çubuğu Seçenekleri Tool Options Araç Seçenekleri Object Options Nesne Seçenekleri @@ -4502,6 +4522,7 @@ axis Both Her İkisi @msg paths contain invalid coordinates yollar geçersiz koordinatlar içeriyor @msg paths: give at least one file yollar: en az bir dosya verin @msg replace: the object to replace was changed or deleted değiştir: değiştirilecek nesne değiştirildi veya silindi +@msg the document it was prepared for is no longer active hazırlandığı belge artık etkin değil @msg pattern missing desen eksik @msg patterns nested too deeply are filled with mid-grey çok derin iç içe geçmiş desenler orta gri renkle dolu @msg percent must be above 0 and at most 6400, not {p} percent 0'ın üzerinde ve en fazla 6400 olmalıdır, {p} değil @@ -4840,6 +4861,7 @@ axis Both Her İkisi @msg every numbered name for it is taken in the Fonts folder bunun için numaralandırılmış her ad Yazı Tipleri klasöründe alınır @msg every numbered name for {name} is taken in the document's folder {name} için numaralandırılmış her ad belgenin klasöründe alınır @msg No linked files to look for Aranacak bağlantılı dosya yok +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Bağlantı bir sembolün, grafik tasarımının, desenin veya opaklık maskesinin içinde ve tuvalde yeri yok Services Hizmetler Hide VectorCraft VectorCraft'ı gizle Zoom Yakınlaştır @@ -5204,7 +5226,9 @@ lighting Light Işık @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` face değeri front veya back olmalıdır @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` {key} değeri {lo} ile {hi} arasında bir sayı olmalıdır @msg `{GRAPHICS}` must be an array `{GRAPHICS}` bir dizi olmalıdır -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafikler düz yüzlere eşlenir: Ekstrüzyon veya Düzlem nesne türünü kullan +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafikler Düzlem, Ekstrüzyon ve Döndür nesne türlerine eşlenir +@msg Revolve maps graphics onto its side: map them onto the front Döndür grafikleri yan yüzeyine eşler: onları ön yüze eşle +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafikler bu yüzey için çok karmaşık: sembollerin resmini basitleştir veya Segmentler değerini azalt @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` en fazla {MAX_GRAPHICS} grafik alır @msg A plane has one face: map its graphics onto the front Bir düzlemin tek yüzü vardır: grafiklerini ön yüze eşle @msg The graphics are too complex: simplify the symbols' art Grafikler çok karmaşık: sembollerin resmini basitleştir @@ -5977,6 +6001,7 @@ theme Appearance Görünüm @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for {name} veri kümesinden hiçbir şey uygulanmadı: değer içerdiği bir değişkene bağlı hiçbir nesne yok @msg no bound object reports a value (locked objects and embedded images report none) bağlı hiçbir nesne bir değer bildirmiyor (kilitli nesneler ve gömülü görüntüler değer bildirmez) @msg select an Objects on Path group bir Yol Üzerindeki Nesneler grubu seçin +@msg choose the object to detach with its Select/Move widget ayrılacak nesneyi Seç/Taşı denetimiyle seçin @msg the Simplify slider is not open Basitleştirme kaydırıcısı açık değil @msg the Smooth slider is not open Düzgünleştirme kaydırıcısı açık değil @msg {_1} is an exporter (File › Export › Export As…) {_1} bir dışa aktarma eklentisidir (Dosya › Dışa Aktar › Farklı Dışa Aktar…) @@ -6009,6 +6034,8 @@ theme Appearance Görünüm Chrome Krom Chrome… Krom… Expand Objects on Path Yol Üzerindeki Nesneleri Genişlet + Detach Ayır + Detach Object from Path Nesneyi Yoldan Ayır Graphic Pen Grafik Kalemi Graphic Pen… Grafik Kalemi… Move Anchor Point Bağlantı Noktasını Taşı @@ -6489,6 +6516,7 @@ axis Y Axis Y ekseni @msg message must be text "message" metin olmalıdır @msg allowContinue must be true or false "allowContinue" true veya false olmalıdır @msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz +@msg Batch cannot play an action with a View menu step Toplu işlem Görünüm menüsü adımı içeren bir eylemi oynatamaz Insert Menu Item… Menü Öğesi Ekle… Insert Menu Item Menü Öğesi Ekle Insert Select Path Yol Seçimi Ekle @@ -6672,6 +6700,13 @@ axis Y Axis Y ekseni Area Text Alan Metni # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Yazıyı Glif Sınırlarına Göre Ölç +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Piksel'e Yapışma Seçenekleri + Pixel Snapping Options… Piksel'e Yapışma Seçenekleri… + Snap to Pixel While Drawing Çizerken Piksel'e Yapış + Snap to Pixel While Moving Taşırken Piksel'e Yapış + Snap to Pixel While Scaling Ölçeklerken Piksel'e Yapış + These options take effect when View › Snap to Pixel is on. Bu seçenekler Görünüm › Piksel'e Yapış açıkken etkili olur. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glif'e Yapışma Seçeneklerini Göster Hide Snap to Glyph Options Glif'e Yapışma Seçeneklerini Gizle @@ -7299,6 +7334,19 @@ breakText Glyphs Glifler Show where this tool is Bu aracın nerede olduğunu göster @msg missing `tutorial` (see learn.list) `tutorial` eksik (bkz. learn.list) @msg unknown tutorial `{id}` (see learn.list) bilinmeyen öğretici `{id}` (bkz. learn.list) + View more Daha fazlasını gör + Finish tutorial Öğreticiyi bitir + Next Up Sıradaki + You finished the last tutorial in this skill. Bu becerinin son öğreticisini bitirdiniz. + Back to skills Becerilere dön + Open Tutorial Sample Öğretici Örneğini Aç + Finish Tutorial Öğreticiyi Bitir + Next Tutorial Sonraki Öğretici + Back to Skills Becerilere Dön +@msg tutorial `{id}` has no practice sample `{id}` öğreticisinin alıştırma örneği yok +@msg open the practice sample of `{id}` first (see learn.openSample) önce `{id}` öğreticisinin alıştırma örneğini açın (bkz. learn.openSample) +@msg finish `{id}` first (see learn.finish) önce `{id}` öğreticisini bitirin (bkz. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}`, becerisinin son öğreticisidir # Deferred plug-in effects Refresh Deferred Effects Ertelenen Efektleri Yenile diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 68aabf0253..8681297977 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1617,6 +1617,8 @@ file-filter Templates Шаблони Objects on Path Tool Інструмент «Об’єкти на контурі» Attach Objects on Path Прикріпити об’єкти до контуру Expand Objects on Path Розібрати об’єкти на контурі + Detach Відкріпити + Detach Object from Path Відкріпити об’єкт від контуру Release Live Paint Звільнити швидку заливку Release Repeat Звільнити повтор Release Slice Звільнити фрагмент @@ -2947,6 +2949,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Скинути рядки до кольорів малюнка Color Theme Picker Вибір колірної теми Click or drag across the art to pick its colors; Shift adds more Клацніть або проведіть по малюнку, щоб узяти його кольори; Shift додає ще + Prominent Colors Переважні кольори + Save All Colors Зберегти всі кольори + Save Prominent Colors Зберегти переважні кольори Reset to Default Скинути до значень за замовчуванням Reset to White, Black Скинути до білого, чорного Reset with Mesh Скинути із сіткою @@ -3500,6 +3505,20 @@ effect Points Точки Spot Color Objects Об’єкти з плашковими кольорами Pattern Objects Об’єкти з візерунками Gradient Objects Об’єкти з градієнтами + Font Details Відомості про шрифти + Linked Images Пов’язані зображення + Embedded Images Впроваджені зображення + Compound Paths Складені контури + Text Objects Текстові об’єкти + Placed Documents Розміщені документи + Symbol Instances Екземпляри символів + Gradient Meshes Градієнтні сітки + Blends Переходи + Envelopes Оболонки + Intertwine Groups Групи переплетення + Opacity Masks Маски непрозорості + Objects with Effects Об’єкти з ефектами + Pattern Swatches Зразки візерунків Spot Colors Плашкові кольори Spot Colors… Плашкові кольори… Spread: Розворот: @@ -4192,6 +4211,10 @@ axis Both Обидві Dock Panel Прикріпити панель Dock to Top Прикріпити вгорі Dock to Bottom Прикріпити внизу + Dock to Left Прикріпити ліворуч + Dock to Right Прикріпити праворуч + Dock Toolbar Прикріпити панель інструментів + Float Toolbar Від’єднати панель інструментів Control Bar Options Параметри панелі керування Tool Options Параметри інструменту Object Options Параметри об’єкта @@ -4783,6 +4806,7 @@ axis Both Обидві @msg paths contain invalid coordinates контури містять неприпустимі координати @msg paths: give at least one file paths: вкажіть щонайменше один файл @msg replace: the object to replace was changed or deleted replace: об’єкт для заміни було змінено або видалено +@msg the document it was prepared for is no longer active документ, для якого це було підготовлено, більше не активний @msg pattern missing візерунок відсутній @msg patterns nested too deeply are filled with mid-grey надто глибоко вкладені візерунки заповнюються сірим @msg percent must be above 0 and at most 6400, not {p} percent має бути більше 0 і не більше 6400, а не {p} @@ -4818,6 +4842,7 @@ axis Both Обидві @msg select a repeat виділіть повтор @msg select an Intertwine group виділіть групу переплетення @msg select an Objects on Path group виділіть групу об’єктів на контурі +@msg choose the object to detach with its Select/Move widget виберіть об’єкт для відкріплення його елементом «Вибрати/Перемістити» @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects до контуру всередині складеного контуру, переходу, оболонки, повтору або групи переплетення не можна прикріпити об’єкти @msg a clipping path can't take objects до відсічного контуру не можна прикріпити об’єкти @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path об’єкти всередині переходу, оболонки, повтору або групи переплетення не можна прикріпити до контуру @@ -5142,6 +5167,7 @@ axis Both Обидві @msg every numbered name for it is taken in the Fonts folder усі нумеровані імена для нього вже зайняті в папці Fonts @msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа @msg No linked files to look for Немає пов’язаних файлів для пошуку +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб @@ -5535,7 +5561,9 @@ lighting Light Світло @msg `{GRAPHICS}` face must be front or back face у `{GRAPHICS}` має бути front або back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} у `{GRAPHICS}` має бути числом від {lo} до {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` має бути масивом -@msg Graphics map onto planar faces: use the Extrude or Plane object type Графіка наноситься на плоскі грані: використайте тип об’єкта «Видавлювання» або «Площина» +@msg Graphics map onto the Plane, Extrude and Revolve object types Графіка наноситься на об’єкти типів «Площина», «Видавлювання» та «Обертання» +@msg Revolve maps graphics onto its side: map them onto the front «Обертання» наносить графіку на свою бічну поверхню: розміщуйте її на передній стороні +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Графіка надто складна для цієї поверхні: спростіть ілюстрації символів або зменште «Сегменти» @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` приймає не більше {MAX_GRAPHICS} графічних елементів @msg A plane has one face: map its graphics onto the front Площина має одну сторону: розміщуйте її графіку на передній стороні @msg The graphics are too complex: simplify the symbols' art Графіка надто складна: спростіть ілюстрації символів @@ -6138,6 +6166,7 @@ axis Y Axis Вісь Y @msg message must be text message має бути текстом @msg allowContinue must be true or false allowContinue має бути true або false @msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою +@msg Batch cannot play an action with a View menu step Пакет не може відтворити дію з кроком із меню Перегляд Insert Menu Item… Вставити пункт меню… Insert Menu Item Вставити пункт меню Insert Select Path Вставити виділення контуру @@ -6321,6 +6350,13 @@ axis Y Axis Вісь Y Area Text Текст в області # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Вимірювати текст за межами гліфа +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Параметри прив’язки до пікселя + Pixel Snapping Options… Параметри прив’язки до пікселя… + Snap to Pixel While Drawing Прив’язати до пікселя під час малювання + Snap to Pixel While Moving Прив’язати до пікселя під час переміщення + Snap to Pixel While Scaling Прив’язати до пікселя під час масштабування + These options take effect when View › Snap to Pixel is on. Ці параметри діють, коли ввімкнено Перегляд › Прив’язати до пікселя. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показати параметри прив’язки до гліфа Hide Snap to Glyph Options Приховати параметри прив’язки до гліфа @@ -6952,6 +6988,19 @@ breakText Glyphs Гліфи Show where this tool is Показати, де розташований цей інструмент @msg missing `tutorial` (see learn.list) відсутній `tutorial` (див. learn.list) @msg unknown tutorial `{id}` (see learn.list) невідомий урок `{id}` (див. learn.list) + View more Докладніше + Finish tutorial Завершити урок + Next Up Далі + You finished the last tutorial in this skill. Ви завершили останній урок цієї навички. + Back to skills Назад до навичок + Open Tutorial Sample Відкрити приклад уроку + Finish Tutorial Завершити урок + Next Tutorial Наступний урок + Back to Skills Назад до навичок +@msg tutorial `{id}` has no practice sample урок `{id}` не має навчального прикладу +@msg open the practice sample of `{id}` first (see learn.openSample) спочатку відкрийте навчальний приклад уроку `{id}` (див. learn.openSample) +@msg finish `{id}` first (see learn.finish) спочатку завершіть `{id}` (див. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` є останнім уроком своєї навички # Deferred plug-in effects Refresh Deferred Effects Оновити відкладені ефекти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 36793e6c56..516ba15672 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1012,6 +1012,10 @@ font-technology Variable Biến thiên Dock Panel Neo bảng Dock to Top Neo lên trên Dock to Bottom Neo xuống dưới + Dock to Left Neo sang trái + Dock to Right Neo sang phải + Dock Toolbar Neo thanh công cụ + Float Toolbar Thả nổi thanh công cụ Control Bar Options Tùy chọn thanh điều khiển Tool Options Tùy chọn công cụ Object Options Tùy chọn đối tượng @@ -1847,6 +1851,8 @@ digits Hindi Hindi Objects on Path Tool Công cụ Đối tượng trên đường dẫn Attach Objects on Path Gắn đối tượng lên đường dẫn Expand Objects on Path Mở rộng đối tượng trên đường dẫn + Detach Tách + Detach Object from Path Tách đối tượng khỏi đường dẫn Release Live Paint Giải phóng tô trực tiếp Release Repeat Giải phóng lặp lại Release Slice Giải phóng lát cắt @@ -3237,6 +3243,9 @@ mark-style Japanese Kiểu Nhật Reset the rows to the artwork's colors Đặt lại các hàng về màu của tác phẩm Color Theme Picker Bộ chọn bộ màu Click or drag across the art to pick its colors; Shift adds more Nhấp hoặc kéo qua tác phẩm để lấy màu của nó; Shift để thêm + Prominent Colors Màu nổi bật + Save All Colors Lưu tất cả màu + Save Prominent Colors Lưu màu nổi bật Reset to Default Đặt lại về mặc định Reset to default Đặt lại về mặc định Reset to White, Black Đặt lại về Trắng, Đen @@ -3848,6 +3857,20 @@ mark-style Japanese Kiểu Nhật Spot Color Objects Đối tượng có màu đặc biệt Pattern Objects Đối tượng có họa tiết Gradient Objects Đối tượng có chuyển sắc + Font Details Chi tiết phông chữ + Linked Images Hình ảnh đã liên kết + Embedded Images Hình ảnh đã nhúng + Compound Paths Đường dẫn phức + Text Objects Đối tượng văn bản + Placed Documents Tài liệu đã đặt + Symbol Instances Thực thể ký hiệu + Gradient Meshes Lưới chuyển sắc + Blends Hòa trộn + Envelopes Envelope + Intertwine Groups Nhóm đan xen + Opacity Masks Mặt nạ độ mờ + Objects with Effects Đối tượng có hiệu ứng + Pattern Swatches Mẫu màu họa tiết Spot Colors Màu đặc biệt Spot Colors… Màu đặc biệt… Spray Radius Bán kính phun @@ -4979,6 +5002,7 @@ theme Appearance Giao diện @msg the library has another item of that kind named `{name}` thư viện đã có một mục khác cùng loại tên là `{name}` @msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list}) @msg No linked files to look for Không có tệp liên kết để tìm kiếm +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas @msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó @msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó @msg no native format không có định dạng gốc @@ -5120,6 +5144,7 @@ theme Appearance Giao diện @msg paths contain invalid coordinates đường dẫn chứa tọa độ không hợp lệ @msg paths: give at least one file đường dẫn: hãy cung cấp ít nhất một tệp @msg replace: the object to replace was changed or deleted thay thế: đối tượng cần thay thế đã bị thay đổi hoặc xóa +@msg the document it was prepared for is no longer active tài liệu được chuẩn bị không còn hoạt động @msg pattern missing thiếu họa tiết @msg patterns nested too deeply are filled with mid-grey họa tiết lồng nhau quá sâu được tô bằng màu xám trung tính @msg PDF settings are not an object Cài đặt PDF không phải là đối tượng @@ -5202,6 +5227,7 @@ theme Appearance Giao diện @msg select a repeat hãy chọn một đối tượng lặp @msg select an Intertwine group hãy chọn một nhóm đan xen @msg select an Objects on Path group hãy chọn một nhóm đối tượng trên đường dẫn +@msg choose the object to detach with its Select/Move widget hãy chọn đối tượng cần tách bằng tiện ích Chọn/Di chuyển của nó @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects không thể gắn đối tượng lên một đường dẫn nằm trong đường dẫn phức, đối tượng hòa trộn, Envelope, đối tượng lặp hoặc nhóm đan xen @msg a clipping path can't take objects không thể gắn đối tượng lên đường dẫn cắt @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path không thể gắn các đối tượng nằm trong đối tượng hòa trộn, Envelope, đối tượng lặp hoặc nhóm đan xen lên đường dẫn @@ -5322,7 +5348,9 @@ theme Appearance Giao diện @msg `{GRAPHICS}` face must be front or back face của `{GRAPHICS}` phải là front hoặc back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} của `{GRAPHICS}` phải là số từ {lo} đến {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` phải là một mảng -@msg Graphics map onto planar faces: use the Extrude or Plane object type Đồ họa được đặt lên các mặt phẳng: hãy dùng loại đối tượng Kéo dài hoặc Mặt phẳng +@msg Graphics map onto the Plane, Extrude and Revolve object types Đồ họa được đặt lên các loại đối tượng Mặt phẳng, Kéo dài và Xoay tròn +@msg Revolve maps graphics onto its side: map them onto the front Xoay tròn đặt đồ họa lên mặt bên của nó: hãy đặt chúng lên mặt trước +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Đồ họa quá phức tạp cho bề mặt này: hãy đơn giản hóa hình vẽ của các ký hiệu hoặc giảm Đoạn @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nhận tối đa {MAX_GRAPHICS} đồ họa @msg A plane has one face: map its graphics onto the front Mặt phẳng chỉ có một mặt: hãy đặt đồ họa của nó lên mặt trước @msg The graphics are too complex: simplify the symbols' art Đồ họa quá phức tạp: hãy đơn giản hóa hình vẽ của các ký hiệu @@ -6139,6 +6167,7 @@ axis Y Axis Trục Y @msg message must be text message phải là văn bản @msg allowContinue must be true or false allowContinue phải là true hoặc false @msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng +@msg Batch cannot play an action with a View menu step Hàng loạt không thể phát hành động có bước của menu Hiển thị Insert Menu Item… Chèn mục menu… Insert Menu Item Chèn mục menu Insert Select Path Chèn chọn đường dẫn @@ -6322,6 +6351,13 @@ axis Y Axis Trục Y Area Text Chữ vùng # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Đo chữ theo giới hạn glyph +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Tùy chọn hút vào pixel + Pixel Snapping Options… Tùy chọn hút vào pixel… + Snap to Pixel While Drawing Hút vào pixel khi vẽ + Snap to Pixel While Moving Hút vào pixel khi di chuyển + Snap to Pixel While Scaling Hút vào pixel khi thay đổi tỉ lệ + These options take effect when View › Snap to Pixel is on. Các tùy chọn này có hiệu lực khi Hiển thị › Hút vào pixel đang bật. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Hiện tùy chọn hút vào glyph Hide Snap to Glyph Options Ẩn tùy chọn hút vào glyph @@ -6953,6 +6989,19 @@ breakText Glyphs Glyph Show where this tool is Hiện vị trí của công cụ này @msg missing `tutorial` (see learn.list) thiếu `tutorial` (xem learn.list) @msg unknown tutorial `{id}` (see learn.list) hướng dẫn không xác định `{id}` (xem learn.list) + View more Xem thêm + Finish tutorial Hoàn tất hướng dẫn + Next Up Tiếp theo + You finished the last tutorial in this skill. Bạn đã hoàn tất hướng dẫn cuối cùng của kỹ năng này. + Back to skills Quay lại kỹ năng + Open Tutorial Sample Mở tệp mẫu của hướng dẫn + Finish Tutorial Hoàn tất hướng dẫn + Next Tutorial Hướng dẫn tiếp theo + Back to Skills Quay lại kỹ năng +@msg tutorial `{id}` has no practice sample hướng dẫn `{id}` không có tệp mẫu thực hành +@msg open the practice sample of `{id}` first (see learn.openSample) hãy mở tệp mẫu thực hành của `{id}` trước (xem learn.openSample) +@msg finish `{id}` first (see learn.finish) hãy hoàn tất `{id}` trước (xem learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` là hướng dẫn cuối cùng trong kỹ năng của nó # Deferred plug-in effects Refresh Deferred Effects Làm mới hiệu ứng bị hoãn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 27cad80b4a..35d92d6434 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1543,6 +1543,8 @@ file-filter Templates 模板 Objects on Path Tool 路径上的对象工具 Attach Objects on Path 附加路径上的对象 Expand Objects on Path 扩展路径上的对象 + Detach 分离 + Detach Object from Path 将对象从路径分离 Release Live Paint 释放实时上色 Release Repeat 释放重复 Release Slice 释放切片 @@ -2808,6 +2810,9 @@ digits Malayalam 马拉雅拉姆数字 Reset the rows to the artwork's colors 将各行重置为图稿的颜色 Color Theme Picker 色彩主题拾取器 Click or drag across the art to pick its colors; Shift adds more 单击或拖过图稿以拾取其颜色;按住 Shift 可添加更多 + Prominent Colors 主要颜色 + Save All Colors 存储所有颜色 + Save Prominent Colors 存储主要颜色 Reset to Default 恢复默认 Reset to White, Black 重置为白、黑 Reset with Mesh 以网格重置 @@ -3323,6 +3328,19 @@ digits Malayalam 马拉雅拉姆数字 Spot Color Objects 使用专色的对象 Pattern Objects 使用图案的对象 Gradient Objects 使用渐变的对象 + Font Details 字体详细信息 + Linked Images 链接的图像 + Embedded Images 嵌入的图像 + Compound Paths 复合路径 + Text Objects 文本对象 + Placed Documents 置入的文档 + Symbol Instances 符号实例 + Gradient Meshes 渐变网格 + Blends 混合 + Envelopes 封套 + Intertwine Groups 交织编组 + Opacity Masks 不透明度蒙版 + Objects with Effects 使用效果的对象 Spot Colors 专色 Spot Colors… 专色… Spread: 扩散: @@ -4259,6 +4277,7 @@ imageTrace Outline 轮廓 @msg select a repeat 请选择一个重复对象 @msg select an Intertwine group 请选择一个交织组 @msg select an Objects on Path group 请选择一个路径上的对象组 +@msg choose the object to detach with its Select/Move widget 请用对象的“选择/移动”控件选择要分离的对象 @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 复合路径、混合对象、封套、重复对象或交织组中的路径无法附加对象 @msg a clipping path can't take objects 剪切路径无法附加对象 @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 混合对象、封套、重复对象或交织组中的对象无法附加到路径 @@ -4850,6 +4869,7 @@ imageTrace Outline 轮廓 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文档、PDF 或 PDF 兼容的 .ai 文件 @msg paths: give at least one file paths:请至少给出一个文件 @msg replace: the object to replace was changed or deleted replace:要替换的对象已被更改或删除 +@msg the document it was prepared for is no longer active 为其准备的文档已不再处于活动状态 @msg Couldn't place {name}: {e} 无法置入 {name}:{e} @msg no print service 没有打印服务 @msg the print job has no data 打印作业没有数据 @@ -4910,6 +4930,7 @@ imageTrace Outline 轮廓 @msg every numbered name for it is taken in the Fonts folder 字体文件夹中所有带编号的名称均已被占用 @msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 @msg No linked files to look for 没有要查找的链接文件 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} @msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} @@ -5440,6 +5461,10 @@ axis Both 两者 Dock Panel 停放面板 Dock to Top 停放到顶部 Dock to Bottom 停放到底部 + Dock to Left 停放到左侧 + Dock to Right 停放到右侧 + Dock Toolbar 停放工具栏 + Float Toolbar 浮动工具栏 Control Bar Options 控制栏选项 Tool Options 工具选项 Object Options 对象选项 @@ -5811,7 +5836,10 @@ lighting Light 光源 @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必须是 front 或 back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必须是 {lo} 到 {hi} 之间的数字 @msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必须是数组 -@msg Graphics map onto planar faces: use the Extrude or Plane object type 图形映射到平坦的面上:请使用突出或平面对象类型 +@msg Graphics map onto the Plane, Extrude and Revolve object types 图形映射到平面、突出和回转对象类型上 + Graphics map onto the Plane, Extrude and Revolve object types. 图形映射到平面、突出和回转对象类型上。 +@msg Revolve maps graphics onto its side: map them onto the front 回转将图形映射到其侧面:请将图形映射到正面 +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments 图形对此表面过于复杂:请简化符号的图稿或减少分段数 @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 个图形 @msg A plane has one face: map its graphics onto the front 平面只有一个面:请将其图形映射到正面 @msg The graphics are too complex: simplify the symbols' art 图形过于复杂:请简化符号的图稿 @@ -6371,6 +6399,7 @@ axis Y Axis Y 轴 @msg message must be text message 必须是文本 @msg allowContinue must be true or false allowContinue 必须是 true 或 false @msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作 +@msg Batch cannot play an action with a View menu step 批处理无法播放含有视图菜单步骤的动作 Insert Menu Item… 插入菜单项… Insert Menu Item 插入菜单项 Insert Select Path 插入选择路径 @@ -6554,6 +6583,13 @@ axis Y Axis Y 轴 Area Text 区域文字 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 按字形边界度量文字 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options 对齐像素选项 + Pixel Snapping Options… 对齐像素选项… + Snap to Pixel While Drawing 绘制时对齐像素 + Snap to Pixel While Moving 移动时对齐像素 + Snap to Pixel While Scaling 缩放时对齐像素 + These options take effect when View › Snap to Pixel is on. 这些选项在“视图 › 对齐像素”打开时生效。 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 显示靠齐字符选项 Hide Snap to Glyph Options 隐藏靠齐字符选项 @@ -7181,6 +7217,19 @@ breakText Glyphs 字形 Show where this tool is 显示此工具的位置 @msg missing `tutorial` (see learn.list) 缺少 `tutorial` (请参阅 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知教程 `{id}` (请参阅 learn.list) + View more 查看更多 + Finish tutorial 完成教程 + Next Up 下一个 + You finished the last tutorial in this skill. 你已完成此技能的最后一个教程。 + Back to skills 返回技能 + Open Tutorial Sample 打开教程示例 + Finish Tutorial 完成教程 + Next Tutorial 下一个教程 + Back to Skills 返回技能 +@msg tutorial `{id}` has no practice sample 教程 `{id}` 没有练习示例 +@msg open the practice sample of `{id}` first (see learn.openSample) 请先打开 `{id}` 的练习示例 (请参阅 learn.openSample) +@msg finish `{id}` first (see learn.finish) 请先完成 `{id}` (请参阅 learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最后一个教程 # Deferred plug-in effects Refresh Deferred Effects 刷新延迟的效果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab1fd359cd..dafc57aa29 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1588,6 +1588,8 @@ file-filter Templates 範本 Objects on Path Tool 路徑上的物件工具 Attach Objects on Path 附加路徑上的物件 Expand Objects on Path 展開路徑上的物件 + Detach 分離 + Detach Object from Path 將物件從路徑分離 Release Live Paint 釋放即時上色 Release Repeat 釋放重複 Release Slice 釋放切片 @@ -2021,6 +2023,7 @@ file-filter Templates 範本 @msg select a repeat 請選取重複物件 @msg select an Intertwine group 請選取交織群組 @msg select an Objects on Path group 請選取路徑上的物件群組 +@msg choose the object to detach with its Select/Move widget 請用物件的「選取/移動」控制項選取要分離的物件 @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 複合路徑、漸變物件、封套、重複物件或交織群組中的路徑無法附加物件 @msg a clipping path can't take objects 剪裁路徑無法附加物件 @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 漸變物件、封套、重複物件或交織群組中的物件無法附加到路徑 @@ -2615,6 +2618,7 @@ file-filter Templates 範本 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文件、PDF 或 PDF 相容的 .ai 檔案 @msg paths: give at least one file paths:請提供至少一個檔案 @msg replace: the object to replace was changed or deleted replace:要取代的物件已被變更或刪除 +@msg the document it was prepared for is no longer active 為其準備的文件已不再處於使用中 @msg Couldn't place {name}: {e} 無法置入 {name}:{e} @msg no print service 沒有列印服務 @msg the print job has no data 列印工作沒有資料 @@ -2675,6 +2679,7 @@ file-filter Templates 範本 @msg every numbered name for it is taken in the Fonts folder 字體資料夾中所有編號名稱都已被使用 @msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用 @msg No linked files to look for 沒有要尋找的連結檔案 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} @msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} @@ -4010,6 +4015,9 @@ digits Malayalam 馬來亞拉姆數字 Reset the rows to the artwork's colors 將各列重設為圖稿的顏色 Color Theme Picker 色彩主題選取器 Click or drag across the art to pick its colors; Shift adds more 按一下或拖過圖稿以選取其顏色;按住 Shift 可加入更多 + Prominent Colors 主要顏色 + Save All Colors 儲存所有顏色 + Save Prominent Colors 儲存主要顏色 Reset to Default 重設為預設值 Reset to White, Black 重設為白色、黑色 Reset with Mesh 以網格重設 @@ -4560,6 +4568,20 @@ digits Malayalam 馬來亞拉姆數字 Spot Color Objects 使用特別色的物件 Pattern Objects 使用圖樣的物件 Gradient Objects 使用漸層的物件 + Font Details 字體詳細資訊 + Linked Images 已連結的影像 + Embedded Images 嵌入的影像 + Compound Paths 複合路徑 + Text Objects 文字物件 + Placed Documents 已置入的文件 + Symbol Instances 符號範例 + Gradient Meshes 漸層網格 + Blends 漸變 + Envelopes 封套 + Intertwine Groups 交織群組 + Opacity Masks 不透明度遮色片 + Objects with Effects 使用效果的物件 + Pattern Swatches 圖樣色票 Spot Colors 特別色 Spot Colors… 特別色… Spread: 擴散: @@ -5269,6 +5291,10 @@ axis Both 兩者 Dock Panel 停駐面板 Dock to Top 停駐到頂部 Dock to Bottom 停駐到底部 + Dock to Left 停駐到左側 + Dock to Right 停駐到右側 + Dock Toolbar 停駐工具列 + Float Toolbar 浮動工具列 Control Bar Options 控制列選項 Tool Options 工具選項 Object Options 物件選項 @@ -5643,7 +5669,10 @@ lighting Light 光源 @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必須是 front 或 back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必須是介於 {lo} 到 {hi} 之間的數字 @msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必須是陣列 -@msg Graphics map onto planar faces: use the Extrude or Plane object type 圖形會對應到平坦的面:請使用「突出」或「平面」物件類型 +@msg Graphics map onto the Plane, Extrude and Revolve object types 圖形會對應到「平面」、「突出」與「迴轉」物件類型 + Graphics map onto the Plane, Extrude and Revolve object types. 圖形會對應到「平面」、「突出」與「迴轉」物件類型。 +@msg Revolve maps graphics onto its side: map them onto the front 迴轉會將圖形對應到其側面:請將圖形對應到正面 +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments 圖形對此表面過於複雜:請簡化符號的圖稿或減少分段數 @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 個圖形 @msg A plane has one face: map its graphics onto the front 平面只有一個面:請將其圖形對應到正面 @msg The graphics are too complex: simplify the symbols' art 圖形過於複雜:請簡化符號的圖稿 @@ -6176,6 +6205,7 @@ axis Y Axis Y 軸 @msg message must be text message 必須是文字 @msg allowContinue must be true or false allowContinue 必須是 true 或 false @msg Batch cannot play an action with a stop 批次無法播放含有停止的動作 +@msg Batch cannot play an action with a View menu step 批次無法播放含有檢視選單步驟的動作 Insert Menu Item… 插入選單項目… Insert Menu Item 插入選單項目 Insert Select Path 插入選取路徑 @@ -6359,6 +6389,13 @@ axis Y Axis Y 軸 Area Text 區域文字 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 依字符邊界測量文字 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options 靠齊像素選項 + Pixel Snapping Options… 靠齊像素選項… + Snap to Pixel While Drawing 繪製時靠齊像素 + Snap to Pixel While Moving 移動時靠齊像素 + Snap to Pixel While Scaling 縮放時靠齊像素 + These options take effect when View › Snap to Pixel is on. 這些選項會在「檢視 › 靠齊像素」開啟時生效。 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 顯示靠齊字符選項 Hide Snap to Glyph Options 隱藏靠齊字符選項 @@ -6987,6 +7024,19 @@ breakText Glyphs 字符 Show where this tool is 顯示此工具的位置 @msg missing `tutorial` (see learn.list) 未提供 `tutorial` (請參閱 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知的教學課程 `{id}` (請參閱 learn.list) + View more 檢視更多 + Finish tutorial 完成教學課程 + Next Up 接下來 + You finished the last tutorial in this skill. 你已完成此技能的最後一個教學課程。 + Back to skills 返回技能 + Open Tutorial Sample 開啟教學課程範例 + Finish Tutorial 完成教學課程 + Next Tutorial 下一個教學課程 + Back to Skills 返回技能 +@msg tutorial `{id}` has no practice sample 教學課程 `{id}` 沒有練習範例 +@msg open the practice sample of `{id}` first (see learn.openSample) 請先開啟 `{id}` 的練習範例 (請參閱 learn.openSample) +@msg finish `{id}` first (see learn.finish) 請先完成 `{id}` (請參閱 learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最後一個教學課程 # Deferred plug-in effects Refresh Deferred Effects 重新整理延後的效果 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4c6ded189..c527991ed0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -91,8 +91,8 @@ pub mod workspaces; mod document_drag; #[cfg(test)] use tests::{ - background as tests_background, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, svg as tests_svg, - synthetic as tests_synthetic, sysclip as tests_sysclip, + background as tests_background, folder as tests_folder, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, + svg as tests_svg, synthetic as tests_synthetic, sysclip as tests_sysclip, }; #[cfg(test)] mod tests; @@ -108,7 +108,7 @@ use vectorcraft_engine::{Session, ViewInfo}; pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket}; pub use state::{UiState, View}; -pub use sysclip::{ClipboardProbeFactory, ClipboardRead, MakeFlavour, SystemClipboard}; +pub use sysclip::{CLIPBOARD_PROBE_STOP_WAIT, ClipboardProbeFactory, ClipboardRead, MakeFlavour, SystemClipboard}; mod app; mod caches; @@ -138,9 +138,10 @@ pub struct VectorcraftApp { pub(crate) view_uids: Vec, /// The thread that checks the system clipboard for `system_paste` instead /// ([`Services::clipboard_probe`]). It is declared before `services` so that it is dropped - /// first: on X11 the last clipboard handle alive hands what the app copied to the clipboard - /// manager, and that must be the app's own handle in `services`, dropped on the UI thread - /// before the process exits. + /// first, waiting up to [`CLIPBOARD_PROBE_STOP_WAIT`] for its thread to let go of its handle: on + /// X11 the last clipboard handle alive hands what the app copied to the clipboard manager, and + /// that should be the app's own handle in `services`, dropped on the UI thread before the + /// process exits. clipboard_probe: Option, pub services: Services, pub canvas: CanvasCache, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26dd765d29..4e2d80bb45 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -65,7 +65,7 @@ pub(crate) use bar::{home_key, home_showing}; use bar::{menu_body, pointer_reaches_title}; pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_items}; pub use entries::{ - InsertableItem, MenuEntry, Row, context_entries, entry, insertable, insertable_items, menu_entries, pretty_shortcut, shown_shortcut, + InsertableItem, MenuEntry, Row, context_entries, entry, insertable, insertable_items, menu_entries, plays_in_app, pretty_shortcut, shown_shortcut, }; pub(crate) use invoke::graph_dialog; #[cfg(test)] diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index abf43a3584..bc11e703e0 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -73,6 +73,10 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { if !st.selection.anchors.is_empty() { v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]); } + // Object › Path › Smooth…, which opens the Smooth slider (`invoke`). `prune` leaves it out + // unless object.path.smooth can run: the selection holds a path, in a group or a compound + // path too. + v.push(c("Smooth…", "object.path.smooth")); if several { v.push(c("Make Clipping Mask", "object.clippingMask.make")); } diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 42ad7b6555..953b5b58f4 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -150,20 +150,65 @@ const NOT_INSERTED_MENUS: [&str; 4] = ["VectorCraft", "File", "Window", "Help"]; /// Options panel. const MENU_ONLY_COMMANDS: [&str; 4] = ["imageTrace.make", "imageTrace.makeAndExpand", "object.pattern.make", "object.pattern.edit"]; +/// The View menu commands that the app runs itself, not the engine, and that can be steps of an +/// action ([`plays_in_app`]): Outline, Pixel Preview, Trim View, Presentation Mode and Screen Mode, +/// the zoom, fit and rotate view items, and the items that show or hide or turn snapping on or off. +/// The saved-view slots are not among them. +const UI_STEP_COMMANDS: [&str; 30] = [ + "view.outline", + "view.pixelPreview", + "view.trimView", + "view.presentation", + "view.screenMode", + "view.zoomIn", + "view.zoomOut", + "view.fitArtboard", + "view.fitAll", + "view.actualSize", + "view.rotate", + "view.rotateToSelection", + "view.rotateReset", + "view.edges", + "view.artboards", + "view.template", + "view.rulers", + "view.videoRulers", + "view.boundingBox", + "view.textThreads", + "view.gradientAnnotator", + "view.cornerWidget", + "view.livePaintGaps", + "view.guides", + "view.smartGuides", + "view.grid", + "view.snapToGrid", + "view.snapToPixel", + "view.snapToPoint", + "view.snapToGlyph", +]; + +/// Whether a step that runs command `id` is a View menu command that the app runs itself +/// ([`UI_STEP_COMMANDS`]). Action playback runs such a step on its own, as its menu item runs it, +/// and not inside `command.batch`. +pub fn plays_in_app(id: &str) -> bool { + UI_STEP_COMMANDS.contains(&id) +} + /// Whether the item labeled `label` in menu `title`, which runs command `id` with `p`, can be a step /// of an action. It can when it runs an engine command, which action playback runs as the menu -/// does; its label does not end with "…", the mark of an item that asks for values first; it is not -/// in a menu of [`NOT_INSERTED_MENUS`]; and it is not Undo, Redo, a Paste, an item that stands for a -/// saved selection, a saved view or a recent font, a plug-in's item or an action's own command. Nor -/// is it Object › Align, whose menu items add the Align To choice and the selection's key to their -/// params when clicked ([`crate::panels::align::with_align_to`]), which playback does not, or another -/// item of [`MENU_ONLY_COMMANDS`]. +/// does, or is a View menu item that the app runs itself ([`plays_in_app`]); its label does not end +/// with "…", the mark of an item that asks for values first; it is not in a menu of +/// [`NOT_INSERTED_MENUS`]; and it is not Undo, Redo, a Paste, an item that stands for a saved +/// selection, a saved view or a recent font, a plug-in's item, an action's own command or another +/// item of [`MENU_ONLY_COMMANDS`]. An Object › Align item can be a step: inserting it adds the Align +/// To choice of that moment to its params, as clicking it does +/// ([`crate::panels::align::with_align_to`]). pub fn insertable(title: &str, label: &str, id: &str, p: &Value) -> bool { use super::slots::{RECENT_FONT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; - vectorcraft_engine::find_command(id).is_some() + (vectorcraft_engine::find_command(id).is_some() || (title == "View" && plays_in_app(id))) && !label.ends_with('…') && !NOT_INSERTED_MENUS.contains(&title) - && !matches!(id, "edit.undo" | "edit.redo" | "command.batch" | "object.align") + && !matches!(id, "edit.undo" | "edit.redo" | "command.batch") && !MENU_ONLY_COMMANDS.contains(&id) && !id.starts_with("edit.paste") && !id.starts_with("prefs.") diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 9d30b2a5b6..3aab5e1e44 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -178,10 +178,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { // Object → Align aligns to what the align buttons align to, unless the call names `to`. With // anchors selected (Direct Selection) it sends its params unchanged, and the points align to // their own bounds. - let follows_align_to = id == "object.align" - && p.as_object().is_some_and(|o| !o.contains_key("to")) - && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()); - let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p }; + let p = if id == "object.align" { crate::panels::align::menu_align_params(app, p) } else { p }; // Image Trace traces off the UI thread, with its progress (#3098). if matches!(id, "imageTrace.make" | "imageTrace.makeAndExpand") { if let Err(e) = crate::trace_runs::start(app, id, p, false) { @@ -206,7 +203,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } } -/// The dialogs the Envelope, Variables, Pathfinder, Repeat, type, graph and View commands open when called without +/// The dialogs the Envelope, Variables, Pathfinder, Pixel Snapping, Repeat, type, graph and View commands open when called without /// params; whether `id` was one of them. fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. @@ -226,6 +223,11 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { crate::dialogs::pathfinder_options::open(app); return true; } + // Pixel Snapping Options: a dialog with the current options. + if id == "view.snapToPixel.options" && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::pixel_snap_options::open(app); + return true; + } // Repeat Options: a dialog with the selected repeat's current values. if id == "object.repeat.options" && p.as_object().is_none_or(|o| o.is_empty()) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 20a2c58314..5f6da1ca36 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -196,7 +196,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), - ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ( + "view.snapToPixel", + "Snap to Pixel", + "", + "{} toggle: drawing, moving and scaling land on whole pixels, each while its option in view.snapToPixel.options is on", + ), ( "view.snapToGlyph", "Snap to Glyph", @@ -564,7 +569,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", ), - ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ( + "window.toolbar.dock", + "Dock Toolbar", + "", + "{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.float", + "Float Toolbar", + "", + "{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.list", + "List Toolbars", + "", + "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)", + ), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( "window.pathfinder.compoundShapes", @@ -647,6 +669,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", ), + ( + "learn.skills", + "Back to Skills", + "", + "{} show the Learn panel on its Skills tab with the list of skills, with no skill and no tutorial open, as Back to skills after a skill's last tutorial and the Quick skill guide card do; returns what learn.list returns", + ), ( "learn.show", "Show Tutorial", @@ -659,6 +687,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", ), + ( + "learn.openSample", + "Open Tutorial Sample", + "", + "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", + ), + ( + "learn.finish", + "Finish Tutorial", + "", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + ), + ( + "learn.next", + "Next Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", @@ -1213,7 +1259,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", diff --git a/crates/ui-egui/src/opening/tests.rs b/crates/ui-egui/src/opening/tests.rs index ef62509bf3..5840df50cc 100644 --- a/crates/ui-egui/src/opening/tests.rs +++ b/crates/ui-egui/src/opening/tests.rs @@ -56,13 +56,9 @@ fn install_importer(app: &mut VectorcraftApp, src: &str) { } /// A file for the importers below, in a folder of its own. -fn importer_file(tag: &str) -> (std::path::PathBuf, String) { - let dir = std::env::temp_dir().join(format!("vc-ui-opening-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("a.tob"); - std::fs::write(&file, "not json").unwrap(); - let file = file.to_string_lossy().into_owned(); +fn importer_file(tag: &str) -> (crate::tests_folder::Folder, String) { + let dir = crate::tests_folder::Folder::new(&format!("ui-opening-{tag}")); + let file = dir.file("a.tob", b"not json"); (dir, file) } @@ -73,7 +69,7 @@ fn importer_file(tag: &str) -> (std::path::PathBuf, String) { fn open_with_runs_the_importer_off_the_ui_thread() { use vectorcraft_plugins::wat::{self as tpl, Given}; let rect = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; - let (dir, file) = importer_file("with"); + let (_dir, file) = importer_file("with"); let mut app = VectorcraftApp::new(Session::new(), Services::default()); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; @@ -97,14 +93,13 @@ fn open_with_runs_the_importer_off_the_ui_thread() { assert!(app.ui.dialog.is_some(), "the failure is reported in a dialog"); assert!(app.ui.recent_files.is_empty()); app.run("plugin.remove", json!({"id": "test.opening.with"})).unwrap(); - let _ = std::fs::remove_dir_all(&dir); } /// Cancel stops an importer that never returns, on the thread reading the file. #[test] fn cancel_stops_a_running_importer() { use vectorcraft_plugins::wat as tpl; - let (dir, file) = importer_file("cancel"); + let (_dir, file) = importer_file("cancel"); let mut app = VectorcraftApp::new(Session::new(), Services::default()); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); install_importer(&mut app, &tpl::module(&tpl::importer("test.opening.spins", r#"["tob"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); @@ -115,7 +110,6 @@ fn cancel_stops_a_running_importer() { assert!(r.as_ref().err().is_some_and(|e| e.contains("cancelled")), "{:?}", r.err()); assert!(app.session.documents().is_empty()); app.run("plugin.remove", json!({"id": "test.opening.spins"})).unwrap(); - let _ = std::fs::remove_dir_all(&dir); } /// #1694: a file that finishes opening while a dialog is up waits for it to close, so the @@ -289,8 +283,7 @@ fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() { #[test] fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { use image::ImageFormat::{Bmp, Jpeg, Png, Tiff}; - let dir = std::env::temp_dir().join(format!("vc-opening-links-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let dir = crate::tests_folder::Folder::new("opening-links"); let (before, after) = (dir.join("before"), dir.join("after")); std::fs::create_dir_all(&before).unwrap(); let file = |folder: &std::path::Path, name: &str| folder.join(name).to_string_lossy().into_owned(); @@ -336,7 +329,6 @@ fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { assert!(st.history.undo.is_empty() && !st.is_dirty(), "{update}"); assert_eq!(asked(&app), question, "{update}"); } - let _ = std::fs::remove_dir_all(&dir); } /// A file opened under automation roots is installed under them too: the open keeps the scope it @@ -344,7 +336,7 @@ fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { #[test] fn a_file_opened_under_automation_roots_finishes_under_them() { use vectorcraft_engine::file_access; - let dir = scope_dir(); + let dir = crate::tests_folder::Folder::new("opening-scope"); let roots = file_access::AutomationRoots::new(&[dir.as_path()], &[]).unwrap(); let mut app = VectorcraftApp::new(Session::new(), Services::default()); file_access::confine(roots.as_ref(), || { @@ -355,12 +347,6 @@ fn a_file_opened_under_automation_roots_finishes_under_them() { assert!(scope.run(file_access::current).is_some(), "the roots go with the open"); } -fn scope_dir() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("vectorcraft-opening-scope-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - /// A file asked for again while it is still being read is not read a second time (#3455). #[test] fn a_file_asked_for_again_while_it_is_read_opens_once() { @@ -378,9 +364,7 @@ fn a_file_asked_for_again_while_it_is_read_opens_once() { /// the dialog acts on its own document (#5369). #[test] fn a_queued_reopen_that_asks_holds_back_files_ready_to_open() { - let dir = std::env::temp_dir().join(format!("vectorcraft-opening-queued-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let dir = crate::tests_folder::Folder::new("opening-queued"); let mut app = VectorcraftApp::new(Session::new(), Services::default()); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); let (doc, other) = (dir.join("doc.vectorcraft"), dir.join("other.vectorcraft")); @@ -407,7 +391,6 @@ fn a_queued_reopen_that_asks_holds_back_files_ready_to_open() { assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(&*doc), "the dialog's document stays active"); assert_eq!(app.opening.activate.first().map(|(p, _)| p), Some(&other), "the later activation waits"); assert_eq!(app.opening.pending.len(), 1, "the other file waits"); - let _ = std::fs::remove_dir_all(dir); } /// Of two files read off the UI thread and ready in the same frame, the first that opens and asks diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index cc72f74b77..e22dcd9cac 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1435,8 +1435,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { - let min = - if id == CANVAS { egui::vec2(200.0 + crate::toolbar::docked_width(&self.app.ui), 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; + // The canvas keeps room for Tools and the lane of toolbars docked beside it. + let beside = crate::toolbar::docked_width(&self.app.ui) + crate::custom_toolbars::beside_canvas_reserve(&self.app.ui); + let min = if id == CANVAS { egui::vec2(200.0 + beside, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; craft_ui::docking::PanelLimits { min, ..Default::default() } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index cbad3dd34f..62538d8f2f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -521,6 +521,34 @@ pub(crate) fn rows_take_delete(ctx: &egui::Context) -> bool { has_row_keys(ctx, layers::ROW_KEYS) || has_row_keys(ctx, appearance::ROW_KEYS) } +/// How near a scrolled list's top or bottom edge a drag scrolls it ([`scroll_at_edges`]). +const DRAG_SCROLL_EDGE: f32 = 28.0; + +/// The speed of that scrolling in points per second: at the band's inner side, and added to it at +/// the list's edge. +const DRAG_SCROLL_SPEED: (f32, f32) = (60.0, 280.0); + +/// While something is dragged near the top or bottom edge of a scrolled list (`ui`: its contents), +/// scroll that way, faster nearer the edge, and draw the next frame so a pointer held still keeps +/// it scrolling. The pointer must be over the list's width. Each frame scrolls by the time it +/// took (a stalled frame counts a tenth of a second at most), so the speed is the same at every +/// frame rate. +pub(crate) fn scroll_at_edges(ui: &mut Ui) { + let Some(pos) = ui.ctx().pointer_hover_pos() else { return }; + let view = ui.clip_rect(); + if !view.x_range().contains(pos.x) { + return; + } + let (slow, more) = DRAG_SCROLL_SPEED; + let speed = |depth: f32| if (0.0..DRAG_SCROLL_EDGE).contains(&depth) { slow + more * (1.0 - depth / DRAG_SCROLL_EDGE) } else { 0.0 }; + let per_second = speed(pos.y - view.top()) - speed(view.bottom() - pos.y); + if per_second != 0.0 { + let dt = ui.input(|i| i.stable_dt).min(0.1); + ui.scroll_with_delta_animation(vec2(0.0, per_second * dt), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + } +} + /// [`crate::i18n::label_or_name`] in the UI language. pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { crate::i18n::label_or_name(crate::i18n::current(), s, built_in) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 8196ad27c2..4f42eea008 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -220,13 +220,17 @@ pub fn default_sets() -> Vec { /// Plays an action at the speed [`UiState::action_playback`](crate::UiState::action_playback) sets. /// Accelerated runs every included step at once as a single undoable batch → what `command.batch` -/// returns. Step by Step and Pause For run the first step now, each as its own undo step, and leave -/// the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps left}. -/// While a paced playback is in progress, another action does not play. +/// returns, {results}. Step by Step and Pause For run the first step now, each as its own undo step, +/// and leave the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps +/// left}. While a paced playback is in progress, another action does not play. /// /// An action with an included stop ([`STOP_STEP`]) plays up to the stop and waits there at any speed /// ([`continue_playback`]). At Accelerated, the steps between two stops are one undo step. → {results: /// [the results of the steps run], pending: the steps left, stops included, atStop: true when it waits} +/// +/// A View menu step ([`is_ui_step`]) runs on its own at every speed, in its place among the steps, and +/// adds no undo step ([`run_steps`]). At Accelerated the steps before it and the steps after it are +/// then one undo step each. pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result { let Some(a) = app.ui.action_sets.get(set).and_then(|s| s.actions.get(idx)).cloned() else { return Err("no such action".into()) }; let commands = a.included_commands(); @@ -241,7 +245,7 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result = commands.into(); let Some(first) = steps.pop_front() else { return Err("the action has no included steps".into()) }; @@ -259,10 +263,59 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result { - let r = app.replay("command.batch", json!({"label": label, "commands": [step]}))?; - Ok(r.get("results").and_then(|r| r.get(0)).cloned().unwrap_or(Value::Null)) + Ok(run_steps(app, label, vec![step])?.into_iter().next().unwrap_or(Value::Null)) +} + +/// Whether a step as [`Action::included_commands`] gives it is a View menu command that the app runs +/// itself ([`crate::menus::plays_in_app`]), which `command.batch` does not run. +pub(crate) fn is_ui_step(step: &Value) -> bool { + step.get("command").and_then(Value::as_str).is_some_and(crate::menus::plays_in_app) +} + +/// Runs `steps` in order, as saved steps ([`VectorcraftApp::replay`]) → their results. The steps +/// between two View menu steps ([`is_ui_step`]) run as one `command.batch` named `label`, which is one +/// undo step; a View menu step runs on its own ([`play_ui_step`]). A step that fails ends the run with +/// its error: the batch it is in changes nothing, and the steps before that batch stay done. +fn run_steps(app: &mut VectorcraftApp, label: &str, steps: Vec) -> Result, String> { + fn batch(app: &mut VectorcraftApp, label: &str, run: Vec, results: &mut Vec) -> Result<(), String> { + if run.is_empty() { + return Ok(()); + } + let r = app.replay("command.batch", json!({"label": label, "commands": run}))?; + results.extend(r.get("results").and_then(Value::as_array).cloned().unwrap_or_default()); + Ok(()) + } + let mut results = Vec::with_capacity(steps.len()); + let mut run = vec![]; + for step in steps { + if is_ui_step(&step) { + batch(app, label, std::mem::take(&mut run), &mut results)?; + results.push(play_ui_step(app, &step)?); + } else { + run.push(step); + } + } + batch(app, label, run, &mut results)?; + Ok(results) +} + +/// Runs a View menu step as its menu item runs it, which adds no undo step → its result. While an +/// action records, the step goes into the recording too, as a stop does, unless it does not fit in +/// the action ([`recording_room`]): then the status bar shows the reason. +fn play_ui_step(app: &mut VectorcraftApp, step: &Value) -> Result { + let command = step.get("command").and_then(Value::as_str).unwrap_or_default().to_string(); + let params = step.get("params").filter(|p| !p.is_null()).cloned().unwrap_or_else(|| json!({})); + let r = app.replay(&command, params.clone())?; + if app.ui.recording.is_some() { + match recording_room(app, &command, ¶ms) { + Ok(()) => app.session.journal.push((command, params)), + Err(e) => app.status(e), + } + } + Ok(r) } /// [`play`] for an action with a stop: runs what comes before the first stop, or at Step by Step and @@ -271,18 +324,12 @@ fn play_with_stops(app: &mut VectorcraftApp, label: String, commands: Vec let mut steps: std::collections::VecDeque = commands.into(); let run = next_run(&mut steps, options.speed); let ran = !run.is_empty(); - let results = if ran { play_steps(app, &label, run)? } else { vec![] }; + let results = if ran { run_steps(app, &label, run)? } else { vec![] }; app.ui.action_playing = Some(Playing { label, steps, options, next_at: None, hold: false, doc: None, played_in: None, stop: None }); after_run(app, ran); Ok(playback_result(app, results)) } -/// Runs steps of a playback as one undo step, named `label` → the steps' results. -fn play_steps(app: &mut VectorcraftApp, label: &str, steps: Vec) -> Result, String> { - let r = app.run("command.batch", json!({"label": label, "commands": steps}))?; - Ok(r.get("results").and_then(Value::as_array).cloned().unwrap_or_default()) -} - /// Takes the steps that run next as one undo step off the front of `steps`: at Accelerated all of /// them up to the next stop, at the other speeds one. None when a stop comes next. fn next_run(steps: &mut std::collections::VecDeque, speed: PlaybackSpeed) -> Vec { @@ -429,7 +476,7 @@ pub fn continue_playback(app: &mut VectorcraftApp, _: &Value) -> Result Result { if !p.is_object() { return Err("`params` must be an object".into()); @@ -945,6 +997,7 @@ pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result Result<(), String> { } /// Before the engine command `id` runs with `params`: Err when the recording in progress would keep -/// it as a step that does not fit in its action ([`recording_room`]); else whether the recording -/// would keep it, for [`after_recorded_command`]. A command is kept as a step when it is journaled, -/// a batch (`command.batch`, which journals itself) too, and Find Objects when it selects what it -/// finds. Undo and Redo are steps too, and are refused when they do not fit. -pub(crate) fn before_recorded_command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result { +/// it as a step that does not fit in its action ([`recording_room`]). A command is kept as a step +/// when it is journaled, a batch (`command.batch`, which journals itself) too, and Find Objects +/// when it selects what it finds. Undo and Redo are steps too, and are refused when they do not +/// fit. +pub(crate) fn before_recorded_command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result<(), String> { let journaled = vectorcraft_engine::find_command(id).is_some_and(|c| c.journal) || id == "command.batch" || (id == "document.find" && params.get("select").and_then(Value::as_bool) == Some(true)); if app.ui.recording.is_none() || !journaled || app.session.active().is_some_and(|d| d.interaction.is_some()) { - return Ok(false); + return Ok(()); } - recording_room(app, id, params)?; - Ok(true) + recording_room(app, id, params) } -/// After a command that the recording keeps as a step (`recorded`, [`before_recorded_command`]) has -/// run with result `r`: when the values the command added to its step as it ran make the steps no -/// longer fit ([`recorded_steps_fit`]), the command's edit and its result stay, the status bar -/// names the limit, no later recorded command runs, and Stop Playing/Recording leaves the step out -/// ([`steps_that_fit`]) → the command's result. -pub(crate) fn after_recorded_command(app: &mut VectorcraftApp, recorded: bool, r: Result) -> Result { - if recorded - && r.is_ok() - && let Err(e) = recorded_steps_fit(app) - { - app.status(e); +/// After an engine command has run with result `r`: when the steps it journaled, the values a +/// command added to its step as it ran or the commands a script ran (`script.run`), make the steps +/// no longer fit ([`after_journaled_edit`]), the command's edit and its result stay, the status bar +/// names the limit, no later recorded command runs, and Stop Playing/Recording leaves the steps +/// out ([`steps_that_fit`]) → the command's result. +pub(crate) fn after_recorded_command(app: &mut VectorcraftApp, r: Result) -> Result { + if r.is_ok() { + after_journaled_edit(app); } r } +/// After an edit that journals without [`before_recorded_command`]'s check (a script run's +/// commands, a tool's or a panel's edit, a dialog's commit): when the journal changed since the +/// last check and the steps that the recording in progress keeps no longer fit in its action +/// ([`recorded_steps_fit`]), the edit and the journal stay, the status bar names the limit, no +/// later recorded command runs, and Stop Playing/Recording keeps the steps that fit +/// ([`steps_that_fit`]) → whether the status bar names a limit. +pub(crate) fn after_journaled_edit(app: &mut VectorcraftApp) -> bool { + if app.ui.recording.is_none() { + return false; + } + let journal = (app.session.journal.rewrites(), app.session.journal.len()); + if app.ui.recording_checked == Some(journal) { + return false; + } + app.ui.recording_checked = Some(journal); + match recorded_steps_fit(app) { + Ok(()) => false, + Err(e) => { + app.status(e); + true + } + } +} + /// What Stop Playing/Recording adds to a set ([`recorded_into`]). struct Recorded { /// The set's action that the recording records into; None for the new action that the @@ -1400,6 +1473,7 @@ fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Opt app.ui.recording_into = into; app.ui.recording_base = None; app.ui.recording_tail = None; + app.ui.recording_checked = Some((app.session.journal.rewrites(), start)); } /// How many of `steps`, from the first, Stop Playing/Recording can give the recording's action so @@ -1446,6 +1520,7 @@ fn stop_recording(app: &mut VectorcraftApp) { let Some((set, name, _)) = app.ui.recording.take() else { return }; app.ui.recording_base = None; app.ui.recording_tail = None; + app.ui.recording_checked = None; app.session.journal.hold(None); match app.ui.recording_into.take() { Some(id) => { @@ -3184,7 +3259,7 @@ mod tests { (json!(long.to_string()), "an action in the action set file has more than 10,000 steps"), (json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"), ( - json!("{\"format\": \"vcactions\", \"version\": 6, \"name\": \"S\", \"actions\": []}"), + json!("{\"format\": \"vcactions\", \"version\": 7, \"name\": \"S\", \"actions\": []}"), "the action set file needs a later version of VectorCraft", ), ] { @@ -4494,6 +4569,152 @@ mod tests { assert!(selected.is_some() && selected != rect, "the group step ran and selected the new group"); } + /// [`app_with_three_moves`] with View › Outline inserted after the first move: the moves of 1, + /// Outline, then the moves of 2 and 4. + fn app_with_a_view_step() -> (VectorcraftApp, impl Fn(&VectorcraftApp) -> f64) { + let (mut app, x) = app_with_three_moves(); + let doc = app.session.active().unwrap().doc.clone(); + let r = app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 0, "command": "view.outline"})).unwrap(); + assert_eq!(r, json!({"set": 0, "action": 0, "step": 1})); + assert!(!app.outline_view(), "inserting runs nothing"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert_eq!(app.ui.action_sets[0].actions[0].steps[1], ("view.outline".to_string(), json!({}))); + (app, x) + } + + /// At Accelerated a View menu step plays in its place: the steps before it are one undo step, + /// the View step adds none, and the steps after it are one more. + #[test] + fn an_inserted_view_item_plays_in_its_place_at_accelerated() { + let (mut app, x) = app_with_a_view_step(); + let before = undo_steps(&app); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!(r["results"].as_array().map(Vec::len), Some(4), "{r}"); + assert_eq!(r["results"][1], json!(true), "Outline turned on: {r}"); + assert!(app.outline_view()); + assert_eq!(x(&app), 7.0); + assert_eq!(undo_steps(&app), before + 2, "the moves on each side of Outline are one undo step each"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 1.0, "Undo takes back the moves after Outline"); + assert!(app.outline_view(), "and leaves the view as it is"); + } + + /// A step that fails after a View menu step ends the playback: the steps before it stay done and + /// the batch it is in changes nothing. + #[test] + fn a_failing_step_after_a_view_item_keeps_what_played_before_it() { + let (mut app, x) = app_with_a_view_step(); + app.ui.action_sets[0].actions[0].steps.insert(3, ("object.align".into(), json!({}))); + assert!(play(&mut app, 0, 0).is_err()); + assert!(app.outline_view(), "Outline played"); + assert_eq!(x(&app), 1.0, "the move before Outline stays, and the move of 2 in the failing batch is taken back"); + } + + /// At Step by Step and Pause For a View menu step is one step of the playback and adds no undo + /// step. + #[test] + fn an_inserted_view_item_plays_step_by_step_and_with_pauses() { + for speed in ["step", "pause"] { + let (mut app, x) = app_with_a_view_step(); + app.run(PLAYBACK_OPTIONS, json!({"speed": speed})).unwrap(); + let before = undo_steps(&app); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!(r["pending"], json!(3), "{speed}: {r}"); + assert!(!app.outline_view(), "{speed}: Outline waits its turn"); + let mut now = 0.0; + for _ in 0..40 { + if app.ui.action_playing.is_none() { + break; + } + now += 2.0; + frame(&mut app, now); + } + assert!(app.ui.action_playing.is_none(), "{speed}: the playback ended"); + assert!(app.outline_view(), "{speed}"); + assert_eq!(x(&app), 7.0, "{speed}"); + assert_eq!(undo_steps(&app), before + 3, "{speed}: each move its own undo step, Outline none"); + } + } + + /// A View menu step before a stop plays before the playback waits there, and the steps after the + /// stop play on Continue. + #[test] + fn an_inserted_view_item_plays_up_to_a_stop() { + let (mut app, x) = app_with_a_view_step(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 1})).unwrap(); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!(r["atStop"], json!(true), "{r}"); + assert!(app.outline_view()); + assert_eq!(x(&app), 1.0); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(x(&app), 7.0); + assert!(app.outline_view(), "Outline did not play again"); + } + + /// Playing an action with a View menu step while another records keeps the View step in the + /// recording, between the batches of the steps around it. + #[test] + fn a_view_step_played_while_recording_is_recorded() { + let (mut app, _) = app_with_a_view_step(); + app.run(START_RECORDING, json!({})).unwrap(); + play(&mut app, 0, 0).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = &app.ui.action_sets[0].actions.last().unwrap().steps; + let ids: Vec<&str> = recorded.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["command.batch", "view.outline", "command.batch"]); + assert_eq!(recorded[1].1, json!({})); + } + + /// Batch… refuses an action with an included View menu step, which acts on a document window, + /// and plays one whose View menu step is excluded. + #[test] + fn batch_refuses_an_action_with_a_view_step() { + let (mut app, _) = app_with_a_view_step(); + assert_eq!(crate::dialogs::action_batch::open(&mut app, 0, 0).unwrap_err(), "Batch cannot play an action with a View menu step"); + assert!(app.ui.dialog.is_none()); + app.ui.action_sets[0].actions[0].excluded_steps.insert(1); + crate::dialogs::action_batch::open(&mut app, 0, 0).unwrap(); + } + + /// An action set with a View menu step saves as version 6 and loads back with the step; one whose + /// View menu step has params that are not an object is not an action set file. A View menu step + /// in a recorded batch counts too, since playback runs it as its own step. + #[test] + fn a_saved_view_step_survives_as_version_6() { + let (app, _) = app_with_a_view_step(); + let saved = files::write_set(&app.ui.action_sets[0]); + let file: Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(file["version"], json!(6), "older builds must refuse a View menu step"); + let loaded = files::read_set(&saved, "Test").unwrap(); + assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps); + for params in [json!(5), json!([1]), json!(null)] { + let step = json!({"command": "view.zoomIn", "params": params}); + for step in [step.clone(), json!({"command": "command.batch", "params": {"commands": [step]}})] { + let bad = json!({"format": "vcactions", "version": 6, "name": "Bad", "actions": [{"name": "A", "steps": [step]}]}); + assert_eq!(files::read_set(&bad.to_string(), "Test").unwrap_err(), "not an action set file", "{step}"); + } + } + let nested = json!({"commands": [{"command": "view.outline", "params": {}}]}); + let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]); + assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(6), "a step in a recorded batch counts"); + } + + /// At Accelerated, the steps up to a stop play as saved steps, as they do without a stop: a key + /// their command no longer reads, recorded by an older version, is taken. + #[test] + fn steps_before_a_stop_play_with_a_key_no_longer_read() { + let (mut app, _) = app_with_three_moves(); + let old = json!({"x": 10, "y": 50, "width": 20, "height": 20, "cornerStyle": "round"}); + assert!(app.run("shape.rectangle", old.clone()).is_err()); + let stop = (STOP_STEP.to_string(), json!({"message": "", "allowContinue": false})); + app.ui.action_sets[0].actions[0].steps = vec![("shape.rectangle".into(), old), stop]; + let shapes = app.session.active().unwrap().doc.layers[0].children().unwrap().len(); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!(r["atStop"], json!(true), "{r}"); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), shapes + 1); + assert!(!app.session.replaying()); + } + #[test] fn insert_menu_item_refuses_what_no_menu_item_runs_and_changes_nothing() { let (mut app, _) = app_with_three_moves(); @@ -4515,11 +4736,15 @@ mod tests { (place(json!({"command": "file.save"})), none), (place(json!({"command": "object.move"})), none), (place(json!({"command": "object.move", "params": {"dx": 1, "dy": 0}})), none), - (place(json!({"command": "view.zoomIn"})), none), (place(json!({"command": INSERT_MENU_ITEM})), none), (place(json!({"command": "edit.paste"})), none), - // Align, whose menu items add the Align To choice when clicked. - (place(json!({"command": "object.align", "params": {"horizontal": "left"}})), none), + // The app's own commands outside the View menu, a saved-view slot and the View menu's New + // View…, which asks for a name first. + (place(json!({"command": "window.panel", "params": {"panel": "layers", "toggle": true}})), none), + (place(json!({"command": "view.goto1"})), none), + (place(json!({"command": "view.saved.new"})), none), + // Align To is the step's own: only the menu item's params are taken. + (place(json!({"command": "object.align", "params": {"horizontal": "left", "to": "selection"}})), none), // Image Trace, whose menu items trace off the UI thread with progress and Cancel. (place(json!({"command": "imageTrace.make", "params": {"preset": "Default"}})), none), (place(json!({"command": "imageTrace.makeAndExpand", "params": {"preset": "Default"}})), none), @@ -4547,19 +4772,74 @@ mod tests { } /// A listed menu item runs from the menu what its step runs in playback: the click opens no - /// dialog, and it journals the item's own command with the item's own params. + /// dialog, and it journals the item's own command with the params its step holds. An Object › + /// Align item's step holds the Align To choice that the click sends, and the app adds the active + /// artboard to both. #[test] fn every_listed_menu_item_runs_from_the_menu_what_its_step_holds() { for item in crate::menus::insertable_items(false) { let (mut app, _) = app_with_three_moves(); + app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": item.command, "params": item.params})).unwrap(); + let held = app.ui.action_sets[0].actions[0].steps[3].1.clone(); let start = app.session.journal.len(); crate::menus::invoke(&mut app, item.command, item.params.clone()); assert!(app.ui.dialog.is_none(), "{item:?} opens a dialog"); let ran = app.session.journal_for_action(start); - assert!(ran.iter().all(|(c, p)| c != item.command || *p == item.params), "{item:?} ran {ran:?}"); + let without_artboard = |p: &Value| { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("artboard"); + } + p + }; + assert!(ran.iter().all(|(c, p)| c != item.command || without_artboard(p) == held), "{item:?} ran {ran:?}, its step holds {held}"); } } + /// Object › Align items can be inserted, and the step keeps the Align To that the click sends at + /// insertion: a later Align To choice does not change it. + #[test] + fn an_inserted_align_item_keeps_the_align_to_of_its_insertion() { + let (mut app, _, ids) = app_with_two_rectangles(); + app.run("select.set", json!({ "ids": ids })).unwrap(); + app.ui.align_to = super::super::align::AlignTo::Artboard; + app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.align", "params": {"horizontal": "left"}})).unwrap(); + app.ui.align_to = super::super::align::AlignTo::Selection; + app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.align", "params": {"horizontal": "left"}})).unwrap(); + let steps = &app.ui.action_sets[0].actions[0].steps; + assert_eq!(steps[3], ("object.align".to_string(), json!({"horizontal": "left", "to": "artboard"}))); + assert_eq!(steps[4], ("object.align".to_string(), json!({"horizontal": "left", "to": "selection"}))); + } + + /// With anchor points selected, an Object › Align item sends its params unchanged, and the points + /// align to their own bounds: its step holds the same params, without the Align To choice. + #[test] + fn an_inserted_align_item_with_anchors_selected_keeps_the_menu_params() { + let (mut app, _, [first, _]) = app_with_two_rectangles(); + app.ui.align_to = super::super::align::AlignTo::Artboard; + app.run("select.anchors", json!({"id": first, "anchors": [[0, 0], [0, 1]]})).unwrap(); + assert!(!app.session.active().unwrap().selection.anchors.is_empty()); + app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.align", "params": {"horizontal": "left"}})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps[3], ("object.align".to_string(), json!({"horizontal": "left"}))); + } + + /// A step that aligns to the key object fails in playback when the selection has no key then: + /// it does not align to the selection or the artboard instead, and the steps after it do not run. + #[test] + fn an_align_step_to_the_key_object_fails_without_a_key() { + let (mut app, _, [first, second]) = app_with_two_rectangles(); + app.run("select.set", json!({"ids": [first, second]})).unwrap(); + app.run("select.key", json!({"id": second})).unwrap(); + app.ui.action_sets[0].actions[0].steps.clear(); + app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.align", "params": {"horizontal": "left"}})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps[0].1["to"], json!("key")); + app.ui.action_sets[0].actions[0].steps.push(("object.move".into(), json!({"dx": 5, "dy": 0}))); + app.run("select.set", json!({"ids": [first, second]})).unwrap(); + let before = (left_of(&app, first), left_of(&app, second)); + assert!(play(&mut app, 0, 0).unwrap_err().contains("no key object")); + assert_eq!((left_of(&app, first), left_of(&app, second)), before, "nothing moved"); + } + #[test] fn insert_menu_item_goes_into_the_recording() { let (mut app, _) = app_with_three_moves(); @@ -5003,6 +5283,212 @@ mod tests { assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); } + /// A script that moves the selection twice. + const TWO_MOVES: &str = "vc.run('object.move', {dx: 1, dy: 0}); vc.run('object.move', {dx: 1, dy: 0});"; + + /// Runs `source` to its end: in one call (`frames` false, a run with nobody to ask), or a frame + /// at a time, as the app does. + fn run_script(app: &mut VectorcraftApp, source: &str, frames: bool) { + app.session.script_frames = frames; + app.run("script.run", json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if !app.session.script_running() { + return; + } + crate::script_runs::poll(app); + } + panic!("the script did not end"); + } + + /// Begin Recording with `filler` steps already recorded. + fn recording_with(app: &mut VectorcraftApp, filler: usize) { + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 0..filler { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + } + + /// A script whose commands take a recording past 10,000 steps keeps its edit and the whole + /// journal, the status bar names the limit as the run ends, the next recorded command does not + /// run, and Stop Playing/Recording keeps the 10,000 steps that fit. One that fills the action + /// exactly names no limit. + #[test] + fn script_run_checks_recording_step_limit_on_completion() { + let steps = "the action already has 10,000 steps"; + for frames in [false, true] { + let (mut app, x) = app_with_three_moves(); + recording_with(&mut app, files::MAX_ACTION_STEPS - 1); + app.ui.status.clear(); + run_script(&mut app, TWO_MOVES, frames); + assert_eq!(x(&app), 2.0, "frames {frames}: the script's edit stays"); + let start = recording_start(&app).unwrap(); + assert_eq!(app.session.journal.len() - start, files::MAX_ACTION_STEPS + 1, "frames {frames}: the journal keeps every step"); + assert_eq!(app.ui.status, steps, "frames {frames}: the status bar names the limit now"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + assert_eq!(app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap_err(), steps, "no later command runs"); + assert_eq!(x(&app), 2.0); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps.len(), files::MAX_ACTION_STEPS); + assert_eq!(recorded.steps.last().map(|(c, _)| c.as_str()), Some("object.move"), "the first move fits"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[1].steps.len(), files::MAX_ACTION_STEPS, "and loads again"); + + let (mut app, _) = app_with_three_moves(); + recording_with(&mut app, files::MAX_ACTION_STEPS - 2); + app.ui.status.clear(); + run_script(&mut app, TWO_MOVES, frames); + let ran = if frames { "Ran t.js" } else { "" }; + assert_eq!(app.ui.status, ran, "frames {frames}: 10,000 steps fit"); + } + } + + /// A script whose commands give the recording's set a file larger than 8 MiB keeps its edit, + /// the status bar names the limit as the run ends, and Stop Playing/Recording keeps the steps + /// that fit in a set that Load Actions… reads. One that fills the file exactly names no limit. + #[test] + fn script_run_checks_recording_byte_limit_on_completion() { + let size = "the action set would be larger than 8 MiB"; + for frames in [false, true] { + for spare in [0, 1] { + let (mut app, x) = app_with_three_moves(); + // The two steps as an action keeps them. + let before = app.session.journal.len(); + run_script(&mut app, TWO_MOVES, false); + let both: usize = app.session.journal_for_action(before).iter().map(|(c, p)| files::step_bytes(c, p)).sum(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 0.0); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + // Room for both moves, or for one move and all but a byte of the other. + app.ui.action_sets[0].actions[0].steps = vec![pad(room - both + 1 - spare)]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + app.ui.status.clear(); + run_script(&mut app, TWO_MOVES, frames); + assert_eq!(x(&app), 2.0, "frames {frames}: the script's edit stays"); + let what = format!("frames {frames}, spare {spare}"); + if spare == 1 { + assert_eq!(app.ui.status, if frames { "Ran t.js" } else { "" }, "{what}: the file is exactly 8 MiB"); + continue; + } + assert_eq!(app.ui.status, size, "{what}: the status bar names the limit now"); + assert_eq!(app.session.journal.since(recording_start(&app).unwrap()).len(), 2, "the journal keeps both moves"); + assert_eq!(app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap_err(), size, "no later command runs"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 2, "{what}: the first move fits"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert!(saved.len() <= files::MAX_SET_FILE); + assert!(files::read_set(&saved, "Test").is_ok()); + } + } + } + + /// Presses, drags and releases the active tool from `from` to `to`, as the canvas hands its + /// results on ([`crate::canvas::apply_requests`]). + fn tool_drag(app: &mut VectorcraftApp, from: (f64, f64), to: (f64, f64)) { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let view = app.view_info(); + for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + let r = app.session.pointer(&PointerEvent::new(kind, x, y), view); + crate::canvas::apply_requests(app, r); + } + } + + /// A drag with the Selection tool (a committed preview) or the Rectangle tool (a command the + /// tool runs) that takes a recording past 10,000 steps keeps its edit and the journal, the + /// status bar names the limit as the tool ends, and Stop Playing/Recording keeps the 10,000 + /// steps that fit. One that fills the action exactly names no limit. + #[test] + fn tool_commit_checks_recording_step_limit() { + let steps = "the action already has 10,000 steps"; + for tool in ["selection", "rectangle"] { + for filler in [files::MAX_ACTION_STEPS, files::MAX_ACTION_STEPS - 1] { + let (mut app, _) = app_with_three_moves(); + app.select_tool(tool); + recording_with(&mut app, filler); + app.ui.status.clear(); + let (journal, art) = (app.session.journal.len(), app.session.active().unwrap().doc.art_bounds()); + tool_drag(&mut app, (20.0, 20.0), (60.0, 50.0)); + assert_eq!(app.session.journal.len(), journal + 1, "{tool}: the tool journals one step"); + assert_ne!(app.session.active().unwrap().doc.art_bounds(), art, "{tool}: the edit stays"); + if filler < files::MAX_ACTION_STEPS { + assert_eq!(app.ui.status, "", "{tool}: 10,000 steps fit"); + continue; + } + assert_eq!(app.ui.status, steps, "{tool}: the status bar names the limit now"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS); + } + } + } + + /// A tool's edit whose step gives the recording's set a file larger than 8 MiB keeps its edit, + /// the status bar names the limit as the tool ends, and Stop Playing/Recording leaves the step + /// out of a set that Load Actions… reads. + #[test] + fn tool_commit_checks_recording_byte_limit() { + let size = "the action set would be larger than 8 MiB"; + for tool in ["selection", "rectangle"] { + let (mut app, _) = app_with_three_moves(); + app.select_tool(tool); + app.run(START_RECORDING, json!({})).unwrap(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = vec![pad(room)]; + app.ui.recording_into = Some(app.ui.action_sets[0].actions[0].id); + app.ui.status.clear(); + let art = app.session.active().unwrap().doc.art_bounds(); + tool_drag(&mut app, (20.0, 20.0), (60.0, 50.0)); + assert_ne!(app.session.active().unwrap().doc.art_bounds(), art, "{tool}: the edit stays"); + assert_eq!(app.ui.status, size, "{tool}: the status bar names the limit now"); + assert_eq!(app.session.journal.since(recording_start(&app).unwrap()).len(), 1, "{tool}: the journal keeps the step"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "{tool}: the step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + } + + /// A step that reaches the journal outside a command, as a panel's or a dialog's commit does, is + /// checked on the app's next frame. A journal rewrite that makes a checked step larger is + /// checked again, and a frame with no new step does not name the limit again. + #[test] + fn a_journal_rewrite_or_a_direct_commit_is_checked_on_the_next_frame() { + let size = "the action set would be larger than 8 MiB"; + let (mut app, _) = app_with_three_moves(); + let small = ("object.move".to_string(), json!({"dx": 1, "dy": 0})); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = vec![pad(room - files::step_bytes(&small.0, &small.1))]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp| { + app.begin_host_iteration(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.tick(ui.ctx())); + out.textures_delta.clear(); + }; + app.session.journal.push(small); + app.ui.status.clear(); + frame(&mut app); + assert_eq!(app.ui.status, "", "a step that fills the file exactly"); + let start = recording_start(&app).unwrap(); + app.session.journal.replace(start, pad(1)); + frame(&mut app); + assert_eq!(app.ui.status, size, "the rewritten step no longer fits"); + app.ui.status.clear(); + frame(&mut app); + assert_eq!(app.ui.status, "", "named once"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "the step is left out"); + } + /// When the action New Action made for the recording in progress is deleted, Stop /// Playing/Recording keeps none of its steps, so no limit stops a recorded command, even with /// the set's file at 8 MiB. @@ -5789,7 +6275,7 @@ mod tests { json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]}); assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}"); } - let later = json!({"format": "vcactions", "version": 6, "name": "New", "actions": []}); + let later = json!({"format": "vcactions", "version": 7, "name": "New", "actions": []}); assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft"); for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] { let stop = json!({"command": "action.stop", "params": params}); diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 6aa8b95df4..a8ae79e713 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,6 +1,6 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! -//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 5, "name", +//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 6, "name", //! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a //! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop` //! with `{"message", "allowContinue"}`. A Select Object step is the command `select.byNote` with @@ -10,9 +10,10 @@ //! or with a value that is not one, has none. `excluded: true` keeps a step but skips it during //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay //! excluded steps, sets with stops use version 3 so older builds refuse them rather than fail at a -//! stop, sets with Select Object steps use version 4 and sets with Select Path steps use version 5 so -//! older builds refuse them rather than fail at that step. Other sets use version 1, and earlier -//! version 1 to 4 files still work. +//! stop, sets with Select Object steps use version 4, sets with Select Path steps use version 5 and +//! sets with View menu steps ([`crate::menus::plays_in_app`]), whose params are an object, use +//! version 6 so older builds refuse them rather than fail at that step. Other sets use version 1, and +//! earlier version 1 to 5 files still work. //! Load Actions… also reads the action sets another app saves as text (`.aia`, through //! `actionSet.read`). @@ -152,6 +153,22 @@ fn select_paths_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { steps_in(super::SELECT_PATH_STEP, command, params) } +/// The params of the View menu steps ([`crate::menus::plays_in_app`]) in a step, as [`stops_in`] +/// finds stops. +fn ui_steps_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { + if crate::menus::plays_in_app(command) { + return vec![params]; + } + if command != "command.batch" { + return vec![]; + } + let steps = params.get("commands").and_then(Value::as_array).into_iter().flatten(); + steps + .filter(|step| step.get("command").and_then(Value::as_str).is_some_and(crate::menus::plays_in_app)) + .map(|step| step.get("params").unwrap_or(&Value::Null)) + .collect() +} + /// `set` as the text of an action set file. pub fn write_set(set: &ActionSet) -> String { let actions = set @@ -173,7 +190,9 @@ pub fn write_set(set: &ActionSet) -> String { let has_select_object = steps().any(|(command, params)| !select_objects_in(command, params).is_empty()); let has_select_path = steps().any(|(command, params)| !select_paths_in(command, params).is_empty()); let has_stop = steps().any(|(command, params)| !stops_in(command, params).is_empty()); + let has_ui_step = steps().any(|(command, params)| !ui_steps_in(command, params).is_empty()); let version = match (has_select_path, has_select_object, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { + _ if has_ui_step => 6, (true, ..) => 5, (false, true, ..) => 4, (false, false, true, _) => 3, @@ -193,7 +212,7 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.format != ACTIONS_EXT { return Err("not an action set file".into()); } - if !matches!(f.version, 1..=5) { + if !matches!(f.version, 1..=6) { return Err("the action set file needs a later version of VectorCraft".into()); } if f.actions.len() > MAX_SET_ACTIONS { @@ -214,6 +233,10 @@ pub fn read_set(text: &str, name: &str) -> Result { if select_objects.any(|p| super::select_object_params(p).is_err()) { return Err("not an action set file".into()); } + let mut ui_steps = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| ui_steps_in(&s.command, &s.params)); + if ui_steps.any(|p| !p.is_object()) { + return Err("not an action set file".into()); + } let actions = f .actions .into_iter() diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 417468f642..02c7969be2 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -60,6 +60,13 @@ pub fn with_align_to(app: &VectorcraftApp, base: Value) -> Value { align_params(base, align_to(app), objects_aligned(app)) } +/// The params that an Object → Align menu item with params `p` sends: [`with_align_to`] unless `p` +/// names `to` or anchor points are selected (Direct Selection), when `p` goes unchanged and the +/// points align to their own bounds. Insert Menu Item stores the same params as the item's step. +pub fn menu_align_params(app: &VectorcraftApp, p: Value) -> Value { + if p.as_object().is_some_and(|o| !o.contains_key("to")) && !has_anchors(app) { with_align_to(app, p) } else { p } +} + /// The selected objects that Align aligns as objects: none while anchor points are selected /// (Direct Selection), since the points align and a single path's points must not go to the /// artboard as a single object does (#1636). diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 774002a72d..0f7223f7e8 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -100,7 +100,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool, } if transform { super::transform::link(app, ui); - ui.separator(); + crate::chrome::group_separator(ui); } if options { art_option_boxes(app, ui, ui.available_width() < ART_OPTIONS_WIDTH); diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index eae59fd7dc..b53faf87c0 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -95,6 +95,28 @@ fn fit_to_artwork_bounds_fits_each_selected_artboard_to_its_own_art() { assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]); } +/// A batch the app runs (as the Actions panel plays an action) with a Fit to Artwork Bounds step +/// fits artboards 1 and 3, selected with the Artboard tool, and leaves artboard 2, the active one, +/// as it was. +#[test] +fn a_batchs_fit_to_artwork_bounds_step_fits_each_selected_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap(); + for (x, h) in [(10, 30), (130, 20), (250, 40)] { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap(); + } + app.session.execute("select.all", &json!({})).unwrap(); + app.session.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let was = boards(&app); + app.select_tool("artboard"); + select(&mut app, 1); + app.session.set_tool_option("selected", &json!([0, 2])); + app.run("command.batch", json!({"commands": [{"command": "artboard.fitToArt", "params": {}}]})).unwrap(); + let rect = vectorcraft_geom::Rect::new; + assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]); +} + /// With the Artboard tool, Shift-click on a row selects the artboards from the last row clicked /// without Shift to it and makes it the active one, Ctrl/Cmd-click adds its artboard to the /// tool's selection or takes it out (#1139), a plain click selects it alone, and every selected diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2ad678bf62..e744640a72 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -42,14 +42,21 @@ pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft /// The first selected text descendant, shared by Character and Paragraph controls. pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> { + match &first_selected_text_node(app)?.kind { + NodeKind::Text(t) => Some(t.as_ref()), + _ => None, + } +} + +/// The node of [`first_selected_text`]: the first text object among the selected objects and +/// their descendants, in selection order, then paint order within each selected object. +pub(crate) fn first_selected_text_node(app: &VectorcraftApp) -> Option<&vectorcraft_doc::Node> { let st = app.session.active()?; for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { let mut text = None; node.walk(&mut |n| { - if text.is_none() - && let NodeKind::Text(t) = &n.kind - { - text = Some(t.as_ref()); + if text.is_none() && matches!(n.kind, NodeKind::Text(_)) { + text = Some(n); } }); if text.is_some() { @@ -940,18 +947,92 @@ const FONT_HEIGHT_REFERENCES: [(vectorcraft_doc::FontHeightReference, &str); 4] (vectorcraft_doc::FontHeightReference::IcfBox, "icfBox"), ]; +/// The size of the icon before each name in the Set Font Height Reference dropdown. +const FONT_HEIGHT_ICON: f32 = 16.0; + /// The Set Font Height Reference dropdown, `width` wide: the height of the font that the Font Size -/// measures, blank where the characters the panel acts on differ. +/// measures, blank where the characters the panel acts on differ. Each reference shows an icon of +/// the height it measures before its name ([`paint_font_height_icon`], #6304). fn font_height_reference(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { // The labels of [`FONT_HEIGHT_REFERENCES`], in its order. let labels = [tl!("Em Box"), tl!("Cap Height"), tl!("x-Height"), tl!("ICF Box")]; let current = shared(app, |s| s.font_height_reference); - let shown = FONT_HEIGHT_REFERENCES.iter().zip(labels).find(|(r, _)| Some(r.0) == current).map_or("", |(_, l)| l); - if let Some((_, key)) = widgets::dropdown_names(ui, "ch-font-height", shown, &labels, width).and_then(|i| FONT_HEIGHT_REFERENCES.get(i)) { + let shown = FONT_HEIGHT_REFERENCES.iter().position(|(r, _)| Some(*r) == current); + let accent = Tokens::get(ui.ctx()).accent; + let icon = |ui: &Ui, rect: egui::Rect, i: usize, ink: egui::Color32, highlighted: bool| { + // On the highlighted row the selection fill is accent-tinted, so the measure takes the + // name's colour there to keep its contrast. + let mark = if highlighted { ink } else { accent }; + if let Some((r, _)) = FONT_HEIGHT_REFERENCES.get(i) { + paint_font_height_icon(ui.painter(), rect, *r, ink, mark); + } + }; + let picked = widgets::dropdown_icon_names(ui, "ch-font-height", shown, &labels, width, FONT_HEIGHT_ICON, icon); + if let Some((_, key)) = picked.and_then(|i| FONT_HEIGHT_REFERENCES.get(i)) { format(app, json!({"fontHeightReference": key})); } } +/// Paints the icon of font height reference `r` into the square `rect` (#6304): a letter drawn in +/// `ink` on a baseline, and in `mark` the height the reference measures. Em Box frames the whole +/// square around an "A"; Cap Height puts a bar from the baseline to the top of a capital "A" +/// beside it; x-Height a bar from the baseline to the top of a lowercase "x"; ICF Box frames a +/// square inset in the faint em square around an ideograph-like cross, as the ideographic +/// character face sits inside the em box. The drawings are VectorCraft's own. +fn paint_font_height_icon( + painter: &egui::Painter, + rect: egui::Rect, + r: vectorcraft_doc::FontHeightReference, + ink: egui::Color32, + mark: egui::Color32, +) { + use vectorcraft_doc::FontHeightReference as R; + // A point at (x, y) in the unit square mapped onto `rect`. + let p = |x: f32, y: f32| rect.lerp_inside(vec2(x, y)); + let glyph = egui::Stroke::new(1.3, ink); + let measure = egui::Stroke::new(1.2, mark); + let line = |a: (f32, f32), b: (f32, f32), stroke: egui::Stroke| painter.line_segment([p(a.0, a.1), p(b.0, b.1)], stroke); + let frame = |min: f32, max: f32, stroke: egui::Stroke| { + painter.rect_stroke(egui::Rect::from_min_max(p(min, min), p(max, max)), 0.0, stroke, egui::StrokeKind::Middle); + }; + // A capital A from `left` to `right`, its apex at `top` and its feet on `base`. + let capital = |left: f32, right: f32, top: f32, base: f32| { + let mid = (left + right) / 2.0; + painter.add(egui::Shape::line(vec![p(left, base), p(mid, top), p(right, base)], glyph)); + let bar = top + (base - top) * 0.62; + let inset = (right - left) * 0.5 * 0.62; + line((mid - inset, bar), (mid + inset, bar), glyph); + }; + // A vertical measure from `top` to `base` at `x`, with ticks at its ends. + let height_bar = |x: f32, top: f32, base: f32| { + line((x, top), (x, base), measure); + line((x - 0.1, top), (x + 0.1, top), measure); + line((x - 0.1, base), (x + 0.1, base), measure); + }; + let (base, cap_top, x_top) = (0.86, 0.16, 0.46); + match r { + R::EmBox => { + frame(0.06, 0.94, measure); + capital(0.26, 0.74, 0.26, 0.76); + } + R::CapHeight => { + capital(0.06, 0.6, cap_top, base); + height_bar(0.82, cap_top, base); + } + R::XHeight => { + line((0.1, x_top), (0.54, base), glyph); + line((0.54, x_top), (0.1, base), glyph); + height_bar(0.82, x_top, base); + } + R::IcfBox => { + frame(0.06, 0.94, egui::Stroke::new(1.0, ink.gamma_multiply(0.4))); + frame(0.2, 0.8, measure); + line((0.33, 0.5), (0.67, 0.5), glyph); + line((0.5, 0.31), (0.5, 0.69), glyph); + } + } +} + /// The font family menu and the font style dropdown of style `s` (Character and Properties panels, /// Control bar), with their `ids` and `widths`. The mouse wheel over the closed family field steps /// the text through the families. @@ -1073,7 +1154,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0)); size_field(app, ui, "cb-font-size", 96.0); ui.add_space(4.0); - ui.separator(); + crate::chrome::group_separator(ui); } /// The panel state key of the panel menu's Show Snap to Glyph Options. diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index a90286e2fb..dd4b12adf5 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -822,6 +822,135 @@ fn show_font_height_options_shows_the_font_height_reference() { assert!(item(&texts(&ctx, &mut app, show), "Cap Height").is_none()); } +/// The Set Font Height Reference dropdown shows an icon before each reference's name, in the +/// closed field and in the open list, and each reference's icon is its own (#6304). +#[test] +fn font_height_references_show_their_icons() { + use super::super::tests_appearance::click; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 12})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, FONT_HEIGHT_OPTIONS, true); + // Every text drawn with its rect, and the rects of every other shape drawn. + let frame = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, texts: &mut Vec<(String, egui::Rect)>, marks: &mut Vec) { + match s { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, texts, marks)), + egui::Shape::Noop => {} + s => marks.push(s.visual_bounding_rect()), + } + } + let (mut texts, mut marks) = (vec![], vec![]); + for c in &out.shapes { + collect(&c.shape, &mut texts, &mut marks); + } + (texts, marks) + }; + // The shapes in the icon's place before the text `rect`, relative to that place and rounded. + let icon = |marks: &[egui::Rect], rect: egui::Rect| { + let place = egui::Rect::from_min_max( + egui::pos2(rect.min.x - FONT_HEIGHT_ICON - 8.0, rect.center().y - 10.0), + egui::pos2(rect.min.x, rect.center().y + 10.0), + ); + let mut found: Vec<[i32; 4]> = marks + .iter() + .filter(|m| m.is_finite() && place.contains_rect(**m)) + .map(|m| [m.min.x - place.min.x, m.min.y - place.min.y, m.max.x - place.min.x, m.max.y - place.min.y].map(|v| v.round() as i32)) + .collect(); + found.sort_unstable(); + found + }; + frame(&ctx, &mut app); + let (texts, marks) = frame(&ctx, &mut app); + let field = texts.iter().find(|(t, _)| t == "Em Box").map(|(_, r)| *r).expect("the closed field shows Em Box"); + assert!(icon(&marks, field).len() >= 2, "no icon before Em Box in the closed field: {:?}", icon(&marks, field)); + click(&ctx, &mut app, field.center(), show); + frame(&ctx, &mut app); + let (texts, marks) = frame(&ctx, &mut app); + let mut icons = vec![]; + for name in ["Em Box", "Cap Height", "x-Height", "ICF Box"] { + // The list's row is the last text of that name drawn (the closed field is drawn first). + let row = texts.iter().rev().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("the open list shows {name}: {texts:?}")); + let found = icon(&marks, row); + assert!(found.len() >= 2, "no icon before {name} in the open list: {found:?}"); + icons.push(found); + } + for (i, a) in icons.iter().enumerate() { + assert!(icons.iter().skip(i + 1).all(|b| a != b), "two references share an icon: {icons:?}"); + } +} + +/// In the open Set Font Height Reference list, the current reference's row is drawn on the +/// accent-tinted selection fill, so its icon draws the measure in the name's colour rather than +/// the accent, which would lose its contrast there; the other rows keep the accent (#6304). +#[test] +fn the_highlighted_font_height_icon_keeps_its_contrast() { + use super::super::tests_appearance::click; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 12})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, FONT_HEIGHT_OPTIONS, true); + let accent = Tokens::get(&ctx).accent; + // Every text drawn with its rect, and every stroke drawn with its rect and colour. + let frame = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, texts: &mut Vec<(String, egui::Rect)>, strokes: &mut Vec<(egui::Rect, egui::Color32)>) { + match s { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, texts, strokes)), + egui::Shape::LineSegment { stroke, .. } => strokes.push((s.visual_bounding_rect(), stroke.color)), + egui::Shape::Path(p) => { + if let egui::epaint::ColorMode::Solid(c) = p.stroke.color { + strokes.push((s.visual_bounding_rect(), c)); + } + } + egui::Shape::Rect(r) => strokes.push((s.visual_bounding_rect(), r.stroke.color)), + _ => {} + } + } + let (mut texts, mut strokes) = (vec![], vec![]); + for c in &out.shapes { + collect(&c.shape, &mut texts, &mut strokes); + } + (texts, strokes) + }; + // The stroke colours in the icon's place before the text `rect`. + let colours = |strokes: &[(egui::Rect, egui::Color32)], rect: egui::Rect| -> Vec { + let place = egui::Rect::from_min_max( + egui::pos2(rect.min.x - FONT_HEIGHT_ICON - 8.0, rect.center().y - 10.0), + egui::pos2(rect.min.x, rect.center().y + 10.0), + ); + strokes.iter().filter(|(r, _)| r.is_finite() && place.contains_rect(*r)).map(|(_, c)| *c).collect() + }; + frame(&ctx, &mut app); + let (texts, _) = frame(&ctx, &mut app); + let field = texts.iter().find(|(t, _)| t == "Em Box").map(|(_, r)| *r).expect("the closed field shows Em Box"); + click(&ctx, &mut app, field.center(), show); + frame(&ctx, &mut app); + let (texts, strokes) = frame(&ctx, &mut app); + let row = |name: &str| texts.iter().rev().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("the open list shows {name}")); + // The list fades in, so colours are compared without their alpha. + let rgb = |c: egui::Color32| { + let [r, g, b, _] = c.to_srgba_unmultiplied(); + [r, g, b] + }; + let is_accent = |c: &egui::Color32| rgb(*c).iter().zip(rgb(accent)).all(|(a, b)| a.abs_diff(b) <= 4); + let current = colours(&strokes, row("Em Box")); + assert!(!current.is_empty() && !current.iter().any(is_accent), "the highlighted Em Box icon draws in the accent: {current:?}"); + let other = colours(&strokes, row("Cap Height")); + assert!(other.iter().any(is_accent), "the other rows keep the accent measure: {other:?}"); +} + /// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the /// selected characters. #[test] diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index ce5a5955b8..7777380180 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -361,7 +361,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { if widgets::flat_button(ui, tl!("Expand"), 64.0).clicked() { app.run("imageTrace.expand", json!({})).ok(); } - ui.separator(); + crate::chrome::group_separator(ui); true } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0ac14d243d..1b0c664d2f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -11,15 +11,20 @@ //! - The selection column selects a row's art (`layer.selectAll`, Shift adds or removes); a row //! whose art is selected shows a square in its layer's colour, and dragging that square onto //! another row moves the selected art there (Alt copies it). -//! - The eye and lock columns toggle a row (`layer.setProps`); dragging down a column sets every -//! row it passes to the same state, in one undo step. Ctrl/Cmd-click an eye switches the layer -//! between Preview and Outline. Alt-click an eye hides the other layers (`layer.hideOthers`), or -//! shows every layer when they are hidden (`layer.showAll`); Alt-click a lock does the same with -//! `layer.lockOthers` and `layer.unlockAll`. Ctrl/Cmd+Alt-click an eye switches the other layers -//! to Outline and the row's layer to Preview (`layer.outlineOthers`), or switches every layer to -//! Preview when the others are in Outline (`layer.previewAll`). +//! - The eye and lock columns toggle a row (`layer.setProps`), a ruler guide's row included; +//! dragging down a column sets every row it passes to the same state, in one undo step. +//! Ctrl/Cmd-click a layer's eye switches it between Preview and Outline. Alt-click an eye hides +//! the other layers (`layer.hideOthers`), or shows every layer when they are hidden +//! (`layer.showAll`); Alt-click a lock does the same with `layer.lockOthers` and +//! `layer.unlockAll`. Ctrl/Cmd+Alt-click an eye switches the other layers to Outline and the +//! row's layer to Preview (`layer.outlineOthers`), or switches every layer to Preview when the +//! others are in Outline (`layer.previewAll`). +//! - A ruler guide's row selects the guide (`guide.select`) and leaves no row highlighted, so +//! Delete then deletes the guide; dragged onto another layer's row the guide goes there +//! (`guide.setLayer`), and onto the trash it is deleted (`guide.remove`). //! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or -//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Layers +//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer, and rows +//! held near the top or bottom edge of the list scroll it, faster nearer the edge. Layers //! dropped in the empty space under the last row go below the bottom layer. Dropped on the trash //! they are deleted, on New Layer duplicated. //! - Deleting rows from the panel (the trash, rows dropped on it, Delete Selection, Delete and @@ -134,7 +139,7 @@ enum LayersDrag { /// The selected-art square: the selected objects go to the row it is dropped on. Art, /// Ruler guide rows (indexes into the document's guides): they go to the layer of the row - /// they are dropped on. + /// they are dropped on, or are deleted when dropped on the trash. Guides(Vec), } @@ -304,6 +309,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for r in list.get(range).unwrap_or_default() { row(ui, &view, r, &mut out); } + // Rows held near the list's top or bottom edge scroll it, so a row out of view can + // take the drop. + if egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) + && ui.input(|i| i.pointer.primary_down()) + { + super::scroll_at_edges(ui); + } }, ) }); diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs index a9ee00462b..381d0a65b1 100644 --- a/crates/ui-egui/src/panels/layers/controls.rs +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -63,7 +63,8 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc { actions.push(("appearance.clear".into(), json!({"ids": [id.0]}))); } else if trash.clicked() { - if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) { + // With no row highlighted, the selected art and ruler guides go. + if view.rows.is_empty() && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) { actions.push(("edit.clear".into(), json!({}))); } else { actions.push(("ui.layerDelete".into(), json!({}))); @@ -82,15 +83,22 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc } } // Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are - // outlined while rows (or, on the trash, a target circle) hang over them. + // outlined while rows (or, on the trash, a target circle) hang over them. Guide rows dropped on + // the trash delete their guides (`guide.remove`); New Layer does not take them. for (button, cmd) in [(&trash, "ui.layerDelete"), (&new_layer, "layer.duplicate")] { - if let Some(d) = button.dnd_release_payload::() - && let LayersDrag::Rows(ids) = &*d - { - actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); + let deletes = cmd == "ui.layerDelete"; + if let Some(d) = button.dnd_release_payload::() { + match &*d { + LayersDrag::Rows(ids) => actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))), + LayersDrag::Guides(guides) if deletes => actions.push(("guide.remove".into(), json!({"indexes": guides}))), + _ => {} + } } - let over = button.dnd_hover_payload::().is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) - || (cmd == "ui.layerDelete" && button.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_)))); + let over = button.dnd_hover_payload::().is_some_and(|d| match *d { + LayersDrag::Rows(_) => true, + LayersDrag::Guides(_) => deletes, + LayersDrag::Art => false, + }) || (deletes && button.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_)))); if over { widgets::drop_target_outline(&child, button.rect); } diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index c24863ffbc..428149d776 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -62,7 +62,8 @@ pub(super) struct Row { pub(super) colour: Color32, /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. pub(super) dim: bool, - /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. + /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row, + /// dimmed when the guide or its layer is hidden. pub(super) guide: Option, /// The spine of blend `node`: its row, below the blend's key objects (#4820). pub(super) spine: bool, @@ -263,10 +264,15 @@ pub(super) fn push_rows( let searching = !query.is_empty(); let narrowing = searching || filter.is_on(); // A layer's ruler guides are rows in it, above its art, the highest `guide.list` index first - // (not while searching, nor while the filter leaves them out). - let guides: Vec = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { - let mut found: Vec = - n.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).filter_map(|c| all_guides.binary_search(&c.id).ok()).collect(); + // (not while searching, nor while the filter leaves them out), each with whether it is visible. + let guides: Vec<(usize, bool)> = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { + let mut found: Vec<(usize, bool)> = n + .children() + .into_iter() + .flatten() + .filter(|c| c.is_ruler_guide()) + .filter_map(|c| all_guides.binary_search(&c.id).ok().map(|i| (i, c.visible))) + .collect(); found.sort_unstable(); found } else { @@ -277,7 +283,7 @@ pub(super) fn push_rows( let at = out.len(); out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None, spine: false }); if open { - for i in guides.into_iter().rev() { + for (i, visible) in guides.into_iter().rev() { out.push(Row { node: n.clone(), depth: depth + 1, @@ -285,7 +291,7 @@ pub(super) fn push_rows( opens: false, open: false, colour, - dim: !shown, + dim: !shown || !visible, guide: Some(i), spine: false, }); diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 4e5b7eb218..9f80d93791 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -116,9 +116,14 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { } } -/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection -/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it -/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. +/// The row of ruler guide `i` on the layer of `item`: the guide's own eye and lock, a guide mark +/// and its name, the selection square when it is selected. The eye and lock hide and lock this +/// guide alone (`layer.setProps` on its node, one undo step), and take part in a drag down either +/// column, as other rows do; they work while its layer is hidden or locked, and the guide shows or +/// can be edited only when its layer allows it too. A click elsewhere selects the guide and leaves +/// no row highlighted; Shift or Cmd adds it to (or takes it from) the selected guides. Dragged onto +/// another layer's row the guide goes to that layer, and onto the trash it is deleted. A hidden or +/// locked guide, or one on a hidden or locked layer, can't be selected or dragged. fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); @@ -129,6 +134,9 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + if let Some(node) = g.id.and_then(|id| view.doc.node(id)) { + eye_and_lock(ui, view, node, r, out); + } let mut x = r.left() + 2.0 * COLUMN + 2.0; colour_bar(ui, &item.node, r, x, item.colour); x += 6.0 + item.depth as f32 * INDENT + 16.0; @@ -149,11 +157,17 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { } if editable && resp.clicked() { let m = ui.input(|i| i.modifiers); + // The highlighted rows are let go, so Delete then deletes the selected guide (Edit › Clear) + // rather than those rows. + if !view.rows.is_empty() { + out.actions.push(("layer.highlight".into(), json!({"ids": [], "mode": "set"}))); + out.rows_after_click = Some(vec![]); + } out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); } // Dragged: the selected guides when this is one of them, else this one, to another layer's row. - // Only the primary button drags a guide row. - if editable && resp.drag_started_by(egui::PointerButton::Primary) { + // Only the primary button drags a guide row, and not while a drag down a column passes it. + if editable && resp.drag_started_by(egui::PointerButton::Primary) && view.column.is_none() { let guides = if selected { view.guides.clone() } else { vec![i] }; egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); } @@ -426,8 +440,8 @@ fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) { (false, true) => "layer.lockOthers", (false, false) => "layer.unlockAll", }; - // Show All and Unlock All take no rows. - let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) }; + // Show All and Unlock All take no rows; a ruler guide's row names its layer. + let params = if cmd.ends_with("Others") { json!({ "ids": [layer_row(n, own).0] }) } else { json!({}) }; (cmd.into(), params) } @@ -437,13 +451,19 @@ fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) { fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) { let own = doc.layer_of(n.id); if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { - ("layer.outlineOthers".into(), json!({ "ids": [n.id.0] })) + ("layer.outlineOthers".into(), json!({ "ids": [layer_row(n, own).0] })) } else { // Preview All takes no rows. ("layer.previewAll".into(), json!({})) } } +/// The row the layer commands of an Alt-click on row `n`'s eye or lock name: `n` itself, or for a +/// ruler guide's row the layer `own` that holds the guide (the layer commands refuse a guide). +fn layer_row(n: &Node, own: Option) -> NodeId { + if n.is_ruler_guide() { own.unwrap_or(n.id) } else { n.id } +} + /// The eye and lock columns of row `n` at `r`. fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) { let t = &view.t; diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 2143bd1ee3..55fa27ce58 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -835,6 +835,158 @@ fn guide_rows_select_and_move_their_guides() { assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].layer, Some(second)); } +/// A guide row's eye and lock hide and lock that guide alone, each click one undo step: a hidden +/// guide neither shows nor stays selected and its row dims, a locked one shows but can't be +/// selected. They work while the guide's layer is hidden, and a drag down the eye column passes +/// the guide row like any other. +#[test] +fn guide_rows_have_their_own_eye_and_lock() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap()); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 1, its guide (no target circle), the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 2); + let y = (circles[0].y + circles[1].y) / 2.0; + let (name, eye, lock) = (egui::pos2(150.0, y), egui::pos2(12.0, y), egui::pos2(37.0, y)); + let st = app.session.active().unwrap(); + let (layer, guide) = (st.doc.layers[0].id, st.doc.ruler_guides()[0].id.unwrap()); + let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + let shown_and_editable = |app: &VectorcraftApp| { + let d = &app.session.active().unwrap().doc; + let g = d.ruler_guide(guide).unwrap(); + (d.guide_shown(&g), d.guide_editable(&g)) + }; + let dimmed = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()).iter().find(|r| r.guide.is_some()).unwrap().dim + }; + click_with(&mut app, &ctx, name, egui::Modifiers::NONE); + assert_eq!(selected(&app), [guide]); + // The eye hides the guide alone, in one step, and deselects it. + let before = undo(&app); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert!(!node(&app, guide).visible && node(&app, layer).visible && node(&app, rect).visible); + assert_eq!((undo(&app), shown_and_editable(&app), dimmed(&app)), (before + 1, (false, false), true)); + assert!(selected(&app).is_empty(), "a hidden guide can't stay selected"); + click_with(&mut app, &ctx, name, egui::Modifiers::NONE); + assert!(selected(&app).is_empty(), "its row doesn't select a hidden guide"); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert_eq!((node(&app, guide).visible, shown_and_editable(&app), dimmed(&app)), (true, (true, true), false)); + // The lock locks it alone: it still shows, and its row no longer selects it. + click_with(&mut app, &ctx, lock, egui::Modifiers::NONE); + assert!(node(&app, guide).locked && !node(&app, layer).locked); + assert_eq!((undo(&app), shown_and_editable(&app), dimmed(&app)), (before + 3, (true, false), false)); + click_with(&mut app, &ctx, name, egui::Modifiers::NONE); + assert!(selected(&app).is_empty(), "its row doesn't select a locked guide"); + run(&mut app, "edit.undo", json!({})); + assert!(!node(&app, guide).locked); + run(&mut app, "edit.redo", json!({})); + assert!(node(&app, guide).locked); + click_with(&mut app, &ctx, lock, egui::Modifiers::NONE); + assert_eq!(shown_and_editable(&app), (true, true)); + // With its layer hidden, the guide's own eye still changes the guide, which shows again with + // the layer only when its own eye is on. + run(&mut app, "layer.setProps", json!({"ids": [layer.0], "visible": false})); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert!(!node(&app, guide).visible); + run(&mut app, "layer.setProps", json!({"ids": [layer.0], "visible": true})); + assert_eq!(shown_and_editable(&app), (false, false)); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert_eq!(shown_and_editable(&app), (true, true)); + // A drag down the eye column from the guide row to the rectangle hides both, in one step. + let before = undo(&app); + let below = egui::pos2(12.0, circles[1].y); + let steps = [ + vec![egui::Event::PointerMoved(eye)], + vec![button(eye, true)], + vec![egui::Event::PointerMoved(eye + vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(below)], + vec![button(below, false)], + vec![], + ]; + for e in steps { + frame(&mut app, &ctx, e, false); + } + assert!(!node(&app, guide).visible && !node(&app, rect).visible && node(&app, layer).visible); + assert_eq!(undo(&app), before + 1, "one undo step"); + assert!(!app.session.in_interaction()); + run(&mut app, "edit.undo", json!({})); + assert!(node(&app, guide).visible && node(&app, rect).visible); + // Alt-click on the guide's eye or lock acts on the other layers, as on any row in its layer. + run(&mut app, "layer.new", json!({})); + let other = app.session.active().unwrap().doc.layers.iter().map(|l| l.id).find(|l| *l != layer).unwrap(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3, "the new layer, Layer 1 and the rectangle"); + let y = (circles[1].y + circles[2].y) / 2.0; + click_with(&mut app, &ctx, egui::pos2(12.0, y), egui::Modifiers::ALT); + assert!(!node(&app, other).visible && node(&app, layer).visible && node(&app, guide).visible); + click_with(&mut app, &ctx, egui::pos2(37.0, y), egui::Modifiers::ALT); + assert!(node(&app, other).locked && !node(&app, layer).locked && !node(&app, guide).locked); +} + +/// A guide row dropped on the trash deletes its guide, in one undo step; with no row highlighted +/// the trash deletes the guide its row selected; and a click on a guide row lets go of the +/// highlighted rows, so Delete goes to Edit › Clear, which deletes the guide and not the layer. +#[test] +fn guide_rows_are_deleted_with_the_trash_and_delete() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap()); + for pos in [50, 80] { + run(&mut app, "guide.add", json!({"vertical": true, "pos": pos})); + } + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 1, the 80 pt guide, the 50 pt guide, the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 2); + let h = (circles[1].y - circles[0].y) / 3.0; + let (row_80, row_50) = (egui::pos2(150.0, circles[0].y + h), egui::pos2(150.0, circles[0].y + 2.0 * h)); + let positions = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let kept = |app: &VectorcraftApp| { + let d = &app.session.active().unwrap().doc; + d.layers.len() == 1 && d.node(rect).is_some() + }; + let trash = trash_pos(&mut app, &ctx); + let before = undo(&app); + drag(&mut app, &ctx, row_80, trash, false); + assert_eq!((positions(&app), undo(&app)), (vec![50.0], before + 1)); + assert!(kept(&app)); + run(&mut app, "edit.undo", json!({})); + assert_eq!(positions(&app).len(), 2); + // The trash, with the guide selected through its row and no row highlighted. + click_with(&mut app, &ctx, row_50, egui::Modifiers::NONE); + assert!(app.session.active().unwrap().highlighted_rows().is_empty()); + click_with(&mut app, &ctx, trash, egui::Modifiers::NONE); + assert_eq!(positions(&app), [80.0]); + assert!(kept(&app) && app.ui.dialog.is_none()); + run(&mut app, "edit.undo", json!({})); + // Layer 1 highlighted, then the guide's row clicked: the panel lets Delete go. + let layer = app.session.active().unwrap().doc.layers[0].id; + click_with(&mut app, &ctx, egui::pos2(150.0, circles[0].y), egui::Modifiers::NONE); + assert_eq!(app.session.active().unwrap().highlighted_rows(), [layer]); + click_with(&mut app, &ctx, row_80, egui::Modifiers::NONE); + assert!(app.session.active().unwrap().highlighted_rows().is_empty()); + let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![del], false); + assert!(kept(&app) && app.ui.dialog.is_none() && !crate::panels::rows_take_delete(&ctx)); + assert_eq!(positions(&app).len(), 2); + app.run("edit.clear", json!({})).unwrap(); + assert_eq!(positions(&app), [50.0]); + assert!(kept(&app)); +} + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows /// holding selected art). fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { @@ -1232,6 +1384,202 @@ fn dragging_rows_moves_them_into_other_layers_and_alt_copies() { let _ = (layer, c); } +/// One headless frame of the panel on a short screen (300 × 260) with `events` (Alt held when +/// `alt`): for every row name painted inside the list's view, its center, and the view. +fn short_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, alt: bool) -> (HashMap, egui::Rect) { + timed_short_frame(app, ctx, events, alt, 1.0 / 60.0) +} + +/// [`short_frame`] for a frame that comes `dt` seconds after the one before it. +fn timed_short_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + alt: bool, + dt: f32, +) -> (HashMap, egui::Rect) { + let mut events = events; + events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 260.0)); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), predicted_dt: dt, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + // The rows' eye column dividers are drawn clipped to the list's view. + let border = Tokens::get(ctx).input_border; + let view = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::LineSegment { stroke, .. } if stroke.color == border && c.clip_rect.height() < screen.height() => Some(c.clip_rect), + _ => None, + }) + .unwrap_or(egui::Rect::NOTHING); + let mut names = HashMap::new(); + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape + && t.galley.text().starts_with("Layer ") + && view.contains_rect(c.clip_rect) + { + let center = t.pos + t.galley.size() / 2.0; + if c.clip_rect.contains(center) { + names.insert(t.galley.text().to_string(), center); + } + } + } + (names, view) +} + +/// A document of 30 empty layers named `Layer 1` (the bottom one) to `Layer 30` (the top one): +/// more rows than [`short_frame`]'s list shows. +fn thirty_layers() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + for i in 2..=30 { + app.session.execute("layer.new", &json!({"top": true, "name": format!("Layer {i}")})).unwrap(); + } + app +} + +/// The layers' names, bottom first. +fn layer_names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.layers.iter().map(|l| l.display_name()).collect() +} + +/// Hold the button still at `at` (a row is held) until `name`'s row is fully in view; → its center. +fn hold_until(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, name: &str, alt: bool) -> Option { + short_frame(app, ctx, vec![egui::Event::PointerMoved(at)], alt); + for _ in 0..500 { + let (names, view) = short_frame(app, ctx, vec![], alt); + if let Some(&c) = names.get(name) + && c.y - ROW_MEDIUM / 2.0 >= view.top() + && c.y + ROW_MEDIUM / 2.0 <= view.bottom() + { + return Some(c); + } + } + None +} + +#[test] +fn rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view() { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let (names, view) = short_frame(&mut app, &ctx, vec![], false); + assert!(names.contains_key("Layer 30") && !names.contains_key("Layer 1"), "the list overflows: {names:?}"); + let before = (undo(&app), layer_names(&app)); + // Layer 30 held still just above the list's bottom edge: the list scrolls down to Layer 1. + let grab = names["Layer 30"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], false); + short_frame(&mut app, &ctx, vec![button(grab, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab + vec2(0.0, 6.0))], false); + let edge = egui::pos2(grab.x, view.bottom() - 3.0); + let target = hold_until(&mut app, &ctx, edge, "Layer 1", false).expect("Layer 1 scrolled into view while the row was held"); + assert_eq!((undo(&app), layer_names(&app)), before, "scrolling alone changes nothing"); + // Dropped on the upper edge of Layer 1's row: above it. + let drop = target - vec2(0.0, ROW_MEDIUM * 0.4); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(drop)], false); + short_frame(&mut app, &ctx, vec![button(drop, false)], false); + let names = layer_names(&app); + assert_eq!(&names[..2], ["Layer 1", "Layer 30"], "{names:?}"); + assert_eq!(undo(&app), before.0 + 1); + // Released, the list stays where it is with the pointer in the top band. + let (rows, _) = short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(drop.x, view.top() + 3.0))], false); + for _ in 0..10 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "no scrolling after the release"); + } + // Layer 2, Alt-dragged to the top edge: the list scrolls back up, and dropped on the lower edge + // of Layer 29's row a copy goes below it. + let grab = rows["Layer 2"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], true); + short_frame(&mut app, &ctx, vec![button(grab, true)], true); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab - vec2(0.0, 6.0))], true); + let (_, view) = short_frame(&mut app, &ctx, vec![], true); + let target = hold_until(&mut app, &ctx, egui::pos2(grab.x, view.top() + 3.0), "Layer 29", true).expect("Layer 29 scrolled into view"); + let drop = target + vec2(0.0, ROW_MEDIUM * 0.4); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(drop)], true); + short_frame(&mut app, &ctx, vec![button(drop, false)], true); + let names = layer_names(&app); + assert_eq!(names.len(), 31); + assert_eq!((names[28].as_str(), names[30].as_str()), ("Layer 28", "Layer 29"), "{names:?}"); + assert!(names[29].starts_with("Layer 2"), "the copy, below Layer 29: {names:?}"); + assert_eq!(names[2], "Layer 2", "the original stays"); +} + +#[test] +fn a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate() { + // Layer 30 held just above the list's bottom edge for half a second, drawn in `frames` frames, + // then moved beside the list, where the scrolling stops: → where the rows in view came to rest. + let held = |frames: u16| { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let (names, view) = short_frame(&mut app, &ctx, vec![], false); + let grab = names["Layer 30"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], false); + short_frame(&mut app, &ctx, vec![button(grab, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab + vec2(0.0, 6.0))], false); + let dt = 0.5 / f32::from(frames); + timed_short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(grab.x, view.bottom() - 3.0))], false, dt); + for _ in 1..frames { + timed_short_frame(&mut app, &ctx, vec![], false, dt); + } + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(view.right() + 2.0, view.bottom() - 3.0))], false); + let mut rows = HashMap::new(); + for _ in 0..120 { + rows = short_frame(&mut app, &ctx, vec![], false).0; + } + // (Layer 30's dimmed copy follows the pointer.) + rows.remove("Layer 30"); + rows + }; + let (at_30, at_120) = (held(15), held(60)); + assert!(!at_30.contains_key("Layer 29") && !at_30.is_empty(), "the list scrolled: {at_30:?}"); + assert_eq!(at_30.len(), at_120.len(), "{at_30:?} {at_120:?}"); + for (name, a) in &at_30 { + let b = at_120.get(name).unwrap_or_else(|| panic!("{name} is in view at 30 frames a second only: {at_120:?}")); + assert!((a.y - b.y).abs() < 2.0, "{name}: {a:?} at 30 frames a second, {b:?} at 120"); + } +} + +#[test] +fn the_list_scrolls_only_for_rows_held_over_it() { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let (rows, view) = short_frame(&mut app, &ctx, vec![], false); + // The pointer in the bottom band with no button down: nothing scrolls. + let edge = egui::pos2(150.0, view.bottom() - 3.0); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(edge)], false); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "hovering scrolls nothing"); + } + // A row held in the bottom band beside the list (right of its view): nothing scrolls, and + // the release there moves nothing. + let before = layer_names(&app); + let grab = rows["Layer 30"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], false); + short_frame(&mut app, &ctx, vec![button(grab, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab + vec2(0.0, 6.0))], false); + let beside = egui::pos2(view.right() + 2.0, view.bottom() - 3.0); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(beside)], false); + // (Layer 30's dimmed copy follows the pointer, so another row is compared.) + assert!(rows.contains_key("Layer 27")); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0.get("Layer 27"), rows.get("Layer 27"), "held beside the list"); + } + short_frame(&mut app, &ctx, vec![button(beside, false)], false); + short_frame(&mut app, &ctx, vec![], false); + assert_eq!(layer_names(&app), before); + // The eye column dragged down into the band: a column drag, which scrolls nothing. + let eye = egui::pos2(12.0, grab.y); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(eye)], false); + short_frame(&mut app, &ctx, vec![button(eye, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(eye + vec2(0.0, 6.0))], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(12.0, view.bottom() - 3.0))], false); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "a column drag"); + } + short_frame(&mut app, &ctx, vec![button(egui::pos2(12.0, view.bottom() - 3.0), false)], false); +} + /// The centres of the bottom bar's buttons, right to left from under the right end of its top /// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object. fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index cce131c7a3..ee7967c8a3 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -3,11 +3,16 @@ //! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each //! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a //! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool -//! is ([`locate`]). Back goes up one level. `learn.list` lists the tutorials, `learn.show` opens one, -//! `learn.locateTool` shows where a tool is and `window.learn` shows or hides the panel. +//! is ([`locate`]). Back goes up one level. Under the steps, View more opens the tutorial's practice +//! sample as a new untitled document; Finish tutorial then marks it finished, and Next Up shows the +//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.skills` shows the list +//! of skills, `learn.show` opens one tutorial, `learn.locateTool` shows where a tool is, +//! `learn.openSample`, `learn.finish` and `learn.next` run the three practice controls, and +//! `window.learn` shows or hides the panel. pub mod catalog; pub mod locate; +pub mod samples; use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -35,6 +40,14 @@ pub struct View { pub tutorial: Option<&'static str>, } +/// The tutorials whose samples View more opened, and the tutorials finished, in the order they +/// were finished. Each id is listed once. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Practice { + pub opened: Vec<&'static str>, + pub finished: Vec<&'static str>, +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { @@ -75,8 +88,10 @@ fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) { }); let resp = ui.interact(card.response.rect, ui.scope_id().with("quick-skill-guide"), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Quick skill guide"))); - if resp.clicked() { - app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; + if resp.clicked() + && let Err(e) = app.run("learn.skills", json!({})) + { + app.status(e); } } @@ -89,6 +104,7 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.learn.tutorial = None; } steps(app, ui, tutorial); + practice(app, ui, tutorial); return; } if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { @@ -163,6 +179,50 @@ fn tool_link(ui: &mut Ui, t: &Tokens, name: &str) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tl!("Show where this tool is")) } +/// The controls under a tutorial's steps: View more; Finish tutorial once its sample is open; and +/// once it is finished, Next Up with the next tutorial's title, or for the skill's last tutorial a +/// line saying so and Back to skills. +fn practice(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::Tutorial) { + let t = Tokens::get(ui.ctx()); + let id = tutorial.id; + let params = json!({ "tutorial": id }); + let mut run = None; + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if ui.button(tl!("View more")).clicked() { + run = Some(("learn.openSample", params.clone())); + } + let done = &app.ui.learn_practice; + if done.opened.contains(&id) && !done.finished.contains(&id) && ui.button(tl!("Finish tutorial")).clicked() { + run = Some(("learn.finish", params.clone())); + } + }); + if app.ui.learn_practice.finished.contains(&id) { + ui.add_space(6.0); + match catalog::next(id) { + Some(next) => { + ui.horizontal_wrapped(|ui| { + if ui.button(tl!("Next Up")).clicked() { + run = Some(("learn.next", params.clone())); + } + ui.add(egui::Label::new(egui::RichText::new(next.title).color(t.text)).wrap()); + }); + } + None => { + ui.add(egui::Label::new(egui::RichText::new(tl!("You finished the last tutorial in this skill.")).color(t.text_dim)).wrap()); + if ui.button(tl!("Back to skills")).clicked() { + run = Some(("learn.skills", json!({}))); + } + } + } + } + if let Some((cmd, params)) = run + && let Err(e) = app.run(cmd, params) + { + app.status(e); + } +} + /// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the /// count of tutorials done of all after it. No progress is kept yet, so the ring is empty. fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { @@ -191,13 +251,18 @@ fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show` and `learn.locateTool`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.skills`, `learn.show`, +/// `learn.locateTool`, `learn.openSample`, `learn.finish` and `learn.next`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { Some(match id { "window.learn" => toggle(app, p), "learn.list" => Ok(list()), + "learn.skills" => skills_list(app), "learn.show" => open(app, p), "learn.locateTool" => locate::locate_tool(app, p), + "learn.openSample" => open_sample(app, p), + "learn.finish" => finish(app, p), + "learn.next" => next(app, p), _ => return None, }) } @@ -231,21 +296,84 @@ fn list() -> Value { json!({ "skills": skills.collect::>() }) } +/// `learn.skills`: Back to skills, and the Quick skill guide card. Show the panel on the Skills tab +/// with the list of skills, with no skill and no tutorial open → what `learn.list` answers. +fn skills_list(app: &mut VectorcraftApp) -> Result { + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; + Ok(list()) +} + /// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the /// tutorial. A missing or unknown id is an error and changes nothing. fn open(app: &mut VectorcraftApp, p: &Value) -> Result { - let key = "tutorial"; - let id = match p.get(key) { - None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), - Some(Value::String(id)) => id.as_str(), - Some(_) => return Err(format!("`{key}` must be a string")), - }; - let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; + let (skill, tutorial) = tutorial_param(p)?; crate::panel_docking::reveal_panel(app, ID)?; app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; let steps: Vec = tutorial.steps.iter().map(catalog::Step::plain).collect(); Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) } +/// The tutorial `p`'s `tutorial` names, with its skill. A missing, non-string or unknown id is an +/// error. +fn tutorial_param(p: &Value) -> Result<(&'static catalog::Skill, &'static catalog::Tutorial), String> { + let key = "tutorial"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)")) +} + +/// `learn.openSample {tutorial}`: View more. Open that tutorial's practice sample as a new untitled +/// document, which becomes the active one, and move the Learn view to the tutorial → `{tutorial, +/// index, title}` of the new document. Each call opens another copy; open documents stay as they +/// are. An id that is not valid, or a sample that does not read, is an error and changes nothing. +fn open_sample(app: &mut VectorcraftApp, p: &Value) -> Result { + let (skill, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + let svg = samples::sample(id).ok_or_else(|| format!("tutorial `{id}` has no practice sample"))?; + let name = format!("{id}.svg"); + let opened = vectorcraft_engine::cmd::fileio::open_template(&mut app.session, &name, svg.as_bytes(), None).map_err(|e| e.to_string())?; + app.sync_views(); + if !app.ui.learn_practice.opened.contains(&id) { + app.ui.learn_practice.opened.push(id); + } + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(id) }; + Ok(json!({"tutorial": id, "index": opened["index"], "title": opened["title"]})) +} + +/// `learn.finish {tutorial}`: Finish tutorial. Mark a tutorial whose sample was opened finished → +/// `{tutorial, finished: true, next}`, where `next` is the id of the next tutorial in its skill, or +/// null after the skill's last one. Finishing it again keeps one entry. The Learn view stays as it is. +fn finish(app: &mut VectorcraftApp, p: &Value) -> Result { + let (_, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + let practice = &mut app.ui.learn_practice; + if !practice.opened.contains(&id) { + return Err(format!("open the practice sample of `{id}` first (see learn.openSample)")); + } + if !practice.finished.contains(&id) { + practice.finished.push(id); + } + Ok(json!({"tutorial": id, "finished": true, "next": catalog::next(id).map(|t| t.id)})) +} + +/// `learn.next {tutorial}`: Next Up. Show the panel on the tutorial after a finished one in the +/// same skill → `{skill, tutorial, title}` of that tutorial. It opens no sample. An unfinished +/// tutorial, or a skill's last tutorial, is an error and changes nothing. +fn next(app: &mut VectorcraftApp, p: &Value) -> Result { + let (skill, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + if !app.ui.learn_practice.finished.contains(&id) { + return Err(format!("finish `{id}` first (see learn.finish)")); + } + let after = catalog::next(id).ok_or_else(|| format!("`{id}` is the last tutorial in its skill"))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(after.id) }; + Ok(json!({"skill": skill.id, "tutorial": after.id, "title": after.title})) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs index 98f0d6f355..57a8f30e31 100644 --- a/crates/ui-egui/src/panels/learn/catalog.rs +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -201,3 +201,10 @@ pub fn skill(id: &str) -> Option<&'static Skill> { pub fn tutorial(id: &str) -> Option<(&'static Skill, &'static Tutorial)> { SKILLS.iter().find_map(|s| s.tutorials.iter().find(|t| t.id == id).map(|t| (s, t))) } + +/// The tutorial after the one whose id is `id` in the same skill; none for a skill's last tutorial. +pub fn next(id: &str) -> Option<&'static Tutorial> { + let (skill, _) = tutorial(id)?; + let at = skill.tutorials.iter().position(|t| t.id == id)?; + skill.tutorials.get(at + 1) +} diff --git a/crates/ui-egui/src/panels/learn/samples.rs b/crates/ui-egui/src/panels/learn/samples.rs new file mode 100644 index 0000000000..d5e2758f59 --- /dev/null +++ b/crates/ui-egui/src/panels/learn/samples.rs @@ -0,0 +1,103 @@ +//! The practice samples that a tutorial's View more opens: one small original SVG drawing for each +//! tutorial, with only the art its steps work on. Each one opens as a new untitled document. + +/// The largest sample, in bytes: the catalog test keeps every sample under it. +pub const MAX_SAMPLE_BYTES: usize = 16 * 1024; + +/// Every tutorial's sample, by tutorial id, in the catalog's order. +pub const SAMPLES: &[(&str, &str)] = &[ + ( + "draw-and-color", + r##" + + +"##, + ), + ( + "combine-shapes", + r##" + + + +"##, + ), + ( + "add-headline", + r##" + + +"##, + ), + ( + "pen-paths", + r##" + + + + + +"##, + ), + ( + "smooth-curves", + r##" + + +"##, + ), + ( + "pencil-sketch", + r##" + + +"##, + ), + ( + "fill-and-stroke", + r##" + + +"##, + ), + ( + "gradient", + r##" + + +"##, + ), + ( + "copy-colors", + r##" + + + +"##, + ), + ( + "area-type", + r##" + + +"##, + ), + ( + "type-on-path", + r##" + + +"##, + ), + ( + "outline-type", + r##" + Outline + +"##, + ), +]; + +/// The sample of the tutorial whose id is `id`. +pub fn sample(id: &str) -> Option<&'static str> { + SAMPLES.iter().find(|(tutorial, _)| *tutorial == id).map(|(_, svg)| *svg) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index 7014785eda..4bd00648d1 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -4,7 +4,7 @@ use egui::accesskit::Node; use serde_json::json; use vectorcraft_engine::Session; -use super::{ID, Tab, View, catalog, show}; +use super::{ID, Practice, Tab, View, catalog, samples, show}; use crate::VectorcraftApp; use crate::tests_labels::painted_text; @@ -228,6 +228,255 @@ fn learn_show_opens_a_tutorial_and_learn_list_lists_them() { assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3})); } +/// Every tutorial has one sample of its own, in the catalog's order, each one small and read +/// without warnings into art of its own. +#[test] +fn every_tutorial_has_its_own_readable_sample() { + let ids: Vec<&str> = catalog::SKILLS.iter().flat_map(|s| s.tutorials.iter().map(|t| t.id)).collect(); + let sampled: Vec<&str> = samples::SAMPLES.iter().map(|(id, _)| *id).collect(); + assert_eq!(sampled, ids, "one sample for each tutorial, in the catalog's order"); + let mut texts = HashSet::new(); + for (id, svg) in samples::SAMPLES { + assert!(svg.len() <= samples::MAX_SAMPLE_BYTES, "{id}: {} bytes", svg.len()); + assert!(texts.insert(*svg), "{id} reuses another tutorial's sample"); + let loaded = vectorcraft_engine::cmd::fileio::load(&format!("{id}.svg"), svg.as_bytes()).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(loaded.warnings.is_empty(), "{id}: {:?}", loaded.warnings); + let mut art = 0; + loaded.doc.walk(|n| art += usize::from(!n.is_layer())); + assert!(art > 0, "{id} has no art"); + } +} + +/// Next Up stays in the tutorial's skill: a skill's last tutorial, the catalog's last one +/// included, has no next tutorial. +#[test] +fn the_next_tutorial_is_the_next_one_in_the_same_skill() { + for skill in catalog::SKILLS { + for pair in skill.tutorials.windows(2) { + assert_eq!(catalog::next(pair[0].id).map(|t| t.id), Some(pair[1].id)); + } + let last = skill.tutorials.last().unwrap(); + assert!(catalog::next(last.id).is_none(), "{} is the last of {}", last.id, skill.id); + } + assert!(catalog::next("nope").is_none()); +} + +/// View more opens the tutorial's sample as a new untitled document beside a modified one, which +/// stays as it was; another tutorial opens another sample, and View more again opens another copy. +#[test] +fn view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document() { + let mut app = app(); + let ctx = context(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let work = app.session.documents()[0].doc.node_count(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + click(&mut app, &ctx, "View more", show); + let docs = app.session.documents(); + assert_eq!(docs.len(), 2, "{}", app.ui.status); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(docs[0].doc.node_count(), work, "the open document stays as it was"); + assert!(docs[1].path.is_none() && docs[1].title().starts_with("Untitled-"), "{}", docs[1].title()); + let pen = docs[1].doc.node_count(); + assert_eq!(app.ui.learn_practice, Practice { opened: vec!["pen-paths"], finished: vec![] }); + + let r = app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r["tutorial"], "gradient"); + assert_eq!(r["index"], 2); + assert_eq!(r["title"], json!(app.session.documents()[2].title())); + assert_ne!(app.session.documents()[2].doc.node_count(), pen, "another tutorial, another sample"); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") }); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(app.session.documents().len(), 4, "View more again opens another copy"); + assert_eq!(app.ui.learn_practice.opened, ["pen-paths", "gradient"]); +} + +/// Finish tutorial shows once the sample is open; finishing shows Next Up with the next +/// tutorial's title, and Next Up shows that tutorial without opening its sample. +#[test] +fn finishing_a_tutorial_offers_next_up_in_its_skill() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + let text = painted_text(&mut app, show); + assert!(text.contains("View more") && !text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); + click(&mut app, &ctx, "View more", show); + let text = painted_text(&mut app, show); + assert!(text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); + click(&mut app, &ctx, "Finish tutorial", show); + assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + let text = painted_text(&mut app, show); + assert!(text.contains("Next Up") && text.contains("Draw smooth curves") && !text.contains("Finish tutorial"), "{text}"); + let docs = app.session.documents().len(); + click(&mut app, &ctx, "Next Up", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") }); + assert_eq!(app.session.documents().len(), docs, "Next Up opens no sample"); + let text = painted_text(&mut app, show); + assert!(text.contains("View more") && !text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); +} + +/// One press and release of `key` in what `draw` draws. +fn key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let event = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(app, ctx, vec![event(true)], draw); + frame(app, ctx, vec![event(false)], draw); +} + +/// Press Tab until the control labeled `name` has the keyboard focus. +fn tab_to(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + for _ in 0..32 { + key(app, ctx, egui::Key::Tab, draw); + let at = center(&frame(app, ctx, vec![], draw), name).unwrap_or_else(|| panic!("{name} is drawn")); + if ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| r.rect.contains(at)) { + return; + } + } + panic!("Tab did not reach {name}"); +} + +/// View more, Finish tutorial and Next Up are reached with Tab and pressed with Enter. +#[test] +fn the_practice_controls_work_from_the_keyboard() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + frame(&mut app, &ctx, vec![], show); + tab_to(&mut app, &ctx, "View more", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.session.documents().len(), 1, "{}", app.ui.status); + assert_eq!(app.ui.learn_practice.opened, ["pen-paths"]); + tab_to(&mut app, &ctx, "Finish tutorial", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + tab_to(&mut app, &ctx, "Next Up", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") }); + assert_eq!(app.session.documents().len(), 1, "Next Up opens no sample"); +} + +/// The panel 150 points wide. +fn narrow(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.allocate_ui(egui::vec2(150.0, 800.0), |ui| { + ui.set_max_width(150.0); + show(app, ui); + }); +} + +/// In a narrow panel the practice controls wrap inside it. +#[test] +fn the_practice_controls_fit_a_narrow_panel() { + let mut app = app(); + let ctx = context(); + app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap(); + frame(&mut app, &ctx, vec![], narrow); + let nodes = frame(&mut app, &ctx, vec![], narrow); + for name in ["View more", "Finish tutorial"] { + let node = nodes.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} is drawn")); + let b = node.bounds().unwrap(); + assert!(b.x1 <= 151.0, "{name} ends at {}", b.x1); + } +} + +/// A skill's last tutorial, finished, says so and offers Back to skills instead of Next Up, even +/// when another skill follows; `learn.next` there is an error. +#[test] +fn the_last_tutorial_of_a_skill_offers_back_to_skills() { + let mut app = app(); + let ctx = context(); + for id in ["add-headline", "outline-type"] { + app.run("learn.openSample", json!({"tutorial": id})).unwrap(); + assert_eq!(app.run("learn.finish", json!({"tutorial": id})).unwrap(), json!({"tutorial": id, "finished": true, "next": null})); + let text = painted_text(&mut app, show); + assert!(text.contains("You finished the last tutorial in this skill.") && !text.contains("Next Up"), "{text}"); + let before = app.ui.learn.clone(); + assert_eq!(app.run("learn.next", json!({"tutorial": id})).unwrap_err(), format!("`{id}` is the last tutorial in its skill")); + assert_eq!(app.ui.learn, before); + click(&mut app, &ctx, "Back to skills", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }); + } +} + +/// `learn.finish` needs the sample opened, and finishing again keeps one entry; `learn.next` needs +/// the tutorial finished and answers the next tutorial. +#[test] +fn learn_finish_and_learn_next_follow_the_practice() { + let mut app = app(); + let open = "open the practice sample of `gradient` first (see learn.openSample)"; + assert_eq!(app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap_err(), open); + assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); + assert_eq!(app.ui.learn_practice, Practice::default()); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); + for _ in 0..2 { + let r = app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r, json!({"tutorial": "gradient", "finished": true, "next": "copy-colors"})); + } + assert_eq!(app.ui.learn_practice.finished, ["gradient"], "finishing again keeps one entry"); + let r = app.run("learn.next", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r, json!({"skill": "color", "tutorial": "copy-colors", "title": "Copy colors from other art"})); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("copy-colors") }); +} + +/// `learn.skills` shows the panel on the Skills tab with the list of skills and answers what +/// `learn.list` answers. Back to skills and the Quick skill guide card run it: each one also shows +/// a panel that was hidden, as the command does. +#[test] +fn learn_skills_shows_the_list_of_skills() { + let skills = View { tab: Tab::Skills, skill: None, tutorial: None }; + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "learn.skills"), "learn.skills is in the UI command catalog"); + let mut app = app(); + app.run("learn.show", json!({"tutorial": "gradient"})).unwrap(); + app.run("window.learn", json!({"show": false})).unwrap(); + assert!(!shown(&app)); + let list = app.run("learn.list", json!({})).unwrap(); + assert_eq!(app.run("learn.skills", json!({})).unwrap(), list); + assert_eq!(app.ui.learn, skills); + assert!(shown(&app)); + + // Back to skills, after a skill's last tutorial. + let mut app = self::app(); + let ctx = context(); + app.run("learn.openSample", json!({"tutorial": "outline-type"})).unwrap(); + app.run("learn.finish", json!({"tutorial": "outline-type"})).unwrap(); + assert!(!shown(&app)); + click(&mut app, &ctx, "Back to skills", show); + assert_eq!(app.ui.learn, skills); + assert!(shown(&app), "Back to skills runs learn.skills"); + + // The Quick skill guide card on the For you tab. + let mut app = self::app(); + let ctx = context(); + assert!(!shown(&app)); + click(&mut app, &ctx, "Quick skill guide", show); + assert_eq!(app.ui.learn, skills); + assert!(shown(&app), "the Quick skill guide card runs learn.skills"); +} + +/// The practice commands reject a missing, mistyped or unknown tutorial id and change nothing. +#[test] +fn the_practice_commands_reject_a_missing_or_unknown_tutorial() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for cmd in ["learn.openSample", "learn.finish", "learn.next"] { + for (params, error) in [ + (json!({}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": null}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": 3}), "`tutorial` must be a string"), + (json!({"tutorial": "nope"}), "unknown tutorial `nope` (see learn.list)"), + ] { + assert_eq!(app.run(cmd, params).unwrap_err(), error, "{cmd}"); + assert_eq!(app.ui.learn, View::default()); + assert_eq!(app.ui.learn_practice, Practice::default()); + assert_eq!(app.session.documents().len(), 1); + assert!(!shown(&app)); + } + } +} + /// A missing, mistyped or unknown tutorial id is an error and leaves the panel as it was. #[test] fn learn_show_rejects_a_missing_or_unknown_tutorial() { diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 84f72e5bdc..3b55d34e11 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -51,8 +51,6 @@ const LIST_VIEW: &str = "library-list-view"; const BAR: f32 = 42.0; /// The least height the list takes. const MIN_LIST: f32 = 140.0; -/// How near the list's top or bottom edge a drag scrolls it. -const EDGE: f32 = 28.0; /// The current library: its id and a copy of it (the panel menu acts on it after reading). fn current(app: &VectorcraftApp) -> Option<(String, Library)> { @@ -329,7 +327,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); if items.grab.is_some() { - scroll_at_edges(ui); + super::scroll_at_edges(ui); } }); out.inner_rect @@ -713,22 +711,6 @@ fn no_group_strip(ui: &mut Ui) { ui.add_space(6.0); } -/// While something is dragged near the top or bottom edge of the scrolled list (`ui`: its -/// contents), scroll that way, faster nearer the edge. -fn scroll_at_edges(ui: &mut Ui) { - let Some(pos) = ui.ctx().pointer_hover_pos() else { return }; - let view = ui.clip_rect(); - if !view.x_range().contains(pos.x) { - return; - } - let speed = |depth: f32| if (0.0..EDGE).contains(&depth) { 2.0 + 10.0 * (1.0 - depth / EDGE) } else { 0.0 }; - let delta = speed(pos.y - view.top()) - speed(view.bottom() - pos.y); - if delta != 0.0 { - ui.scroll_with_delta(vec2(0.0, delta)); - ui.ctx().request_repaint(); - } -} - /// The drag in progress held over a section of the list (`sections`, seen through `clip`), away /// from the items that take one: an item goes into that section's group (the ungrouped section: /// out of its group), and something new is added there, the section outlined; a group goes diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index aced758482..2fb130ae3e 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -9,6 +9,10 @@ //! Show in Folder and Show DCS Transparency Interactions (a warning on the rows of images placed //! from DCS files that are transparent or overlap transparent objects). //! +//! Images and placed documents in opacity-mask art and in the art of symbols, graph designs and +//! patterns are listed after the layers' own, one row each; the canvas can't select them, so a +//! click picks such a row in the panel, and the buttons act on it as on any other. +//! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -56,6 +60,16 @@ struct Cache { at: f64, list: Value, info: Option<(u64, Value)>, + /// The objects outside the layer tree, by id: those of the rows, for their thumbnails, and + /// those whose opacity masks hold a row's image, for the tooltip. + nested: Vec<(u64, std::sync::Arc)>, +} + +impl Cache { + /// Object `id` outside the layer tree, as [`cached`] collected it. + fn nested_node(&self, id: u64) -> Option<&std::sync::Arc> { + self.nested.binary_search_by_key(&id, |(n, _)| *n).ok().and_then(|i| self.nested.get(i)).map(|(_, n)| n) + } } fn options(ctx: &egui::Context) -> Options { @@ -73,13 +87,34 @@ fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) { fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option { let st = app.session.active()?; let key = Some((st.uid, st.revision, o.show, o.sort, o.dcs)); - let first = selected_images(app).first().copied(); + let first = selected_links(app, ctx).first().copied(); let now = ctx.input(|i| i.time); let mut c: Cache = pstate(ctx, "links-cache"); let stale = c.key != key || now - c.at > RECHECK; if stale { c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?; (c.key, c.at) = (key, now); + // One pass over the stored art for the rows outside the layer tree and the objects whose + // opacity masks hold them, not one per frame. + let mut want: std::collections::HashSet = c.list["links"] + .as_array() + .into_iter() + .flatten() + .filter(|r| nested_row(r)) + .flat_map(|r| [r["id"].as_u64(), r["owner"]["maskOf"].as_u64()]) + .flatten() + .collect(); + c.nested.clear(); + if !want.is_empty() + && let Some(st) = app.session.active() + { + st.doc.visit_stored_arcs(|n, place| { + if !place.in_layers() && want.remove(&n.id.0) { + c.nested.push((n.id.0, std::sync::Arc::clone(n))); + } + }); + c.nested.sort_by_key(|(id, _)| *id); + } } let want = first.filter(|_| o.info); if stale || c.info.as_ref().map(|(id, _)| *id) != want { @@ -100,6 +135,50 @@ fn selected_images(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect() } +/// Is row `r` of an object outside the layer tree (in opacity-mask art or the art of a symbol, +/// graph design or pattern), which the canvas can't select? +fn nested_row(r: &Value) -> bool { + r["owner"].is_object() +} + +/// The active document's uid and the ids of its selected objects. +fn canvas_selection(app: &VectorcraftApp) -> Option<(u64, Vec)> { + app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())) +} + +/// The rows outside the layer tree picked in the panel, with the canvas selection +/// ([`canvas_selection`]) they were picked with. +type NestedPicks = Option<((u64, Vec), Vec)>; + +/// The rows outside the layer tree picked in the panel, in the active document. A change of the +/// canvas selection, or another document, drops them (the panel looks every frame). +fn nested_picks(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let picks: NestedPicks = pstate(ctx, "links-nested"); + let Some((at, ids)) = picks else { return vec![] }; + if Some(&at) == canvas_selection(app).as_ref() { + return ids; + } + set_pstate::(ctx, "links-nested", None); + vec![] +} + +/// Keep `ids` as the rows picked in the panel, with the canvas selection as it is now. +fn set_nested_picks(app: &VectorcraftApp, ctx: &egui::Context, ids: Vec) { + set_pstate(ctx, "links-nested", canvas_selection(app).map(|at| (at, ids))); +} + +/// The ids of the selected rows: the selected images and placed documents, then the rows outside +/// the layer tree picked in the panel. +fn selected_links(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut ids = selected_images(app); + for id in nested_picks(app, ctx) { + if !ids.contains(&id) { + ids.push(id); + } + } + ids +} + /// The rows of `list` whose image is selected. fn selected_rows<'a>(list: &'a Value, selected: &[u64]) -> Vec<&'a Value> { list["links"].as_array().into_iter().flatten().filter(|r| r["id"].as_u64().is_some_and(|id| selected.contains(&id))).collect() @@ -174,9 +253,14 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { }); } -/// Select image `id` and scroll it into view. +/// Select image `id` and scroll it into view; one outside the layer tree only says that it has no +/// place on the canvas. pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) { let Ok(r) = app.run("links.goTo", json!({ "id": id })) else { return }; + if r["onCanvas"] == false { + app.status("The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + return; + } let b: Vec = r["bounds"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); if let (&[x0, y0, x1, y1], Some(v)) = (&b[..], app.view_mut()) { v.center = vectorcraft_geom::Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); @@ -275,7 +359,31 @@ fn badge(ui: &Ui, r: egui::Rect, status: &str) { } } -fn status_tip(r: &Value) -> String { +/// The tooltip of row `r`. A row outside the layer tree also names where it is stored +/// (`links.list`'s `owner`): the symbol, graph design or pattern whose art holds it, and the object +/// whose opacity mask holds it, by `mask` (that object's name) or else by its id. +pub(super) fn status_tip(r: &Value, mask: Option) -> String { + let tip = file_tip(r); + let owner = &r["owner"]; + let name = owner["name"].as_str().unwrap_or_default(); + let art = match owner["kind"].as_str() { + Some("symbol") => Some(tl!("Symbols")), + Some("graphDesign") => Some(tl!("Graph Design")), + Some("pattern") => Some(tl!("Pattern")), + _ => None, + }; + let tip = match art { + Some(kind) => format!("{tip}\n{kind}: {name}"), + None => tip, + }; + match owner["maskOf"].as_u64() { + Some(id) => format!("{tip}\n{}: {}", tl!("Opacity Mask"), mask.unwrap_or_else(|| id.to_string())), + None => tip, + } +} + +/// The file, status and notes part of [`status_tip`]. +fn file_tip(r: &Value) -> String { let path = r["found"].as_str().or(r["path"].as_str()).unwrap_or_default(); let tip = match r["status"].as_str().unwrap_or_default() { "missing" => crate::i18n::fmt(tl!("Missing: {path}"), &[("path", path)]), @@ -299,25 +407,46 @@ fn status_tip(r: &Value) -> String { /// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked /// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A /// Shift-click while that row is not shown, or after the last click was in another document, acts -/// as a click without Shift. +/// as a click without Shift. Rows outside the layer tree are picked in the panel itself, as the +/// canvas can't select them; a click without Ctrl/Cmd replaces both kinds of selection. fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) { let Some(uid) = app.session.active().map(|st| st.uid) else { return }; let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor"); let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id)); + let nested = |r: &Value| nested_row(r).then(|| r["id"].as_u64()).flatten(); + let mut picks = if m.command { nested_picks(app, ctx) } else { vec![] }; if m.shift && let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid) && let (Some(i), Some(j)) = (at(a), at(id)) { - let range: Vec = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect(); - app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok(); + let range = rows.get(i.min(j)..=i.max(j)).unwrap_or_default(); + let canvas: Vec = range.iter().filter(|r| !nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + picks.extend(range.iter().filter_map(nested).filter(|id| !picks.contains(id)).collect::>()); + app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": canvas })).ok(); + set_nested_picks(app, ctx, picks); return; } set_pstate(ctx, "links-anchor", Some((uid, id))); + let row = rows.iter().find(|r| r["id"].as_u64() == Some(id)); + if row.is_some_and(nested_row) { + match picks.iter().position(|p| *p == id) { + Some(i) if m.command => { + picks.remove(i); + } + _ => picks.push(id), + } + if !m.command { + app.run("select.set", json!({ "ids": [] })).ok(); + } + set_nested_picks(app, ctx, picks); + return; + } if m.command { app.run("select.toggle", json!({ "id": id })).ok(); } else { app.run("select.set", json!({ "ids": [id] })).ok(); } + set_nested_picks(app, ctx, picks); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -331,7 +460,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let thumbs = super::layers::ThumbSource::of(app, st, &doc); - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); let mut clicked: Option<(u64, egui::Modifiers, bool)> = None; widgets::list_box(ui, |ui| { @@ -363,7 +492,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if o.thumb > 0.0 { let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); - if let Some(n) = super::layers::node_arc(&doc, NodeId(id)) { + let node = + if nested_row(r) { cache.nested_node(id).map(std::sync::Arc::clone) } else { super::layers::node_arc(&doc, NodeId(id)) }; + if let Some(n) = node { super::layers::real_thumb(ui, &thumbs, &n, tr); } ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); @@ -395,7 +526,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.5), t.text, ); - let resp = resp.on_hover_text(status_tip(r)); + // The mask's object by the layer tree's index or from the cache: no walk of the + // stored art each frame. + let mask = r["owner"]["maskOf"] + .as_u64() + .and_then(|m| doc.node(NodeId(m)).or_else(|| cache.nested_node(m).map(|n| &**n))) + .map(|n| super::layers::shown_name(app, n, crate::i18n::current())); + let resp = resp.on_hover_text(status_tip(r, mask)); if resp.double_clicked() { clicked = Some((id, egui::Modifiers::NONE, true)); } else if resp.clicked() { @@ -424,7 +561,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::icon_button_enabled(ui, "pencil", tl!("Edit Original"), false, can_edit, 24.0).clicked() { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } if widgets::icon_button_enabled(ui, "rotate-cw", tl!("Update Link"), false, !modified.is_empty(), 24.0).clicked() { let r = app.run("links.update", json!({ "ids": modified })); @@ -465,6 +602,15 @@ fn break_link(app: &mut VectorcraftApp, ids: Vec) { }); } +/// The params of Edit Original and Show in Folder for the selected rows `sel`: the first linked +/// row whose file is there (rows outside the layer tree included), else none (the selection's). +fn file_row(sel: &[&Value]) -> Value { + match ids(sel, |r| r["linked"] == true && !status_is(r, "missing")).first() { + Some(id) => json!({ "id": id }), + None => json!({}), + } +} + /// Pick-a-file actions need a file dialog (the desktop app). fn can_pick(app: &VectorcraftApp) -> bool { app.services.pick_open.is_some() @@ -551,7 +697,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let o = options(ui.ctx()); // The rows the panel shows (the menu opens from it). let list = pstate::(ui.ctx(), "links-cache").list; - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let sel = selected_rows(&list, &selected); let linked = ids(&sel, |r| r["linked"] == true); let embedded: Vec<&Value> = sel.iter().copied().filter(|r| status_is(r, "embedded")).collect(); @@ -576,7 +722,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { report(app, r, |v| updated_status(v["updated"].as_array().map_or(0, Vec::len))); } if menu_item(ui, tl!("Edit Original"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } ui.separator(); if menu_item(ui, tl!("Embed Image(s)"), !linked.is_empty(), false) { @@ -607,7 +753,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Show in Folder"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.reveal", json!({})); + crate::menus::invoke(app, "links.reveal", file_row(&sel)); } ui.separator(); // Select Linked, Embedded, Missing and Modified: every such image in the document, not only diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 07b53cc6d8..6195de4b24 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -389,12 +389,15 @@ fn trace_section(app: &mut VectorcraftApp, ui: &mut Ui, preset: &str, view: vect }); } -/// The Objects on Path groups selected with nothing else: their ids, and the Pivot and Rotate they -/// share (`None` where they differ). +/// The Objects on Path groups selected with nothing else: their ids, the Pivot and Rotate they +/// share (`None` where they differ), and whether Detach can take an object off the path: one group, +/// neither hidden nor locked, with an object chosen with its Select/Move widget that is neither +/// hidden nor locked. struct ObjectsOnPathSelection { ids: Vec, pivot: Option, rotate: Option, + detachable: bool, } /// The selection as Objects on Path groups ([`ObjectsOnPathSelection`]), when every selected @@ -405,7 +408,11 @@ fn selected_objects_on_path(app: &VectorcraftApp) -> Option out = Some(ObjectsOnPathSelection { ids: vec![id.0], pivot: Some(rec.pivot), rotate: Some(rec.rotate) }), + None => { + let chosen = st.selection.on_path_member.filter(|m| st.selection.objects.len() == 1 && rec.members.iter().any(|x| x.id == *m)); + let detachable = chosen.is_some_and(|m| st.doc.is_editable(*id) && st.doc.is_editable(m)); + out = Some(ObjectsOnPathSelection { ids: vec![id.0], pivot: Some(rec.pivot), rotate: Some(rec.rotate), detachable }); + } Some(s) => { s.ids.push(id.0); if s.pivot != Some(rec.pivot) { @@ -425,7 +432,8 @@ fn selected_objects_on_path(app: &VectorcraftApp) -> Option). A change goes to every /// selected group in one undo step (`object.objectsOnPath.options`). Where the groups differ, no -/// Pivot square is chosen and Rotate is blank. +/// Pivot square is chosen and Rotate is blank. Detach takes the object chosen with its Select/Move +/// widget off the path (`object.objectsOnPath.detach`), and is dimmed until one is chosen. fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsOnPathSelection) { section_header(ui, tl!("Objects on Path Options")); let fw = super::field_width(ui); @@ -452,6 +460,12 @@ fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsO } ui.end_row(); }); + let w = ui.available_width(); + if ui.add_enabled_ui(oop.detachable, |ui| widgets::flat_button(ui, tl!("Detach"), w)).inner.clicked() + && let Err(e) = app.run("object.objectsOnPath.detach", json!({})) + { + app.status(e); + } } /// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5ed020c326..9300619765 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1879,6 +1879,39 @@ fn objects_on_path_options_set_pivot_and_rotate_in_one_undo_step() { assert_eq!(undo_depth(&app), depth + 1, "one undo step for both groups"); } +/// The objects of the Objects on Path group `id`, in their order along the path. +fn on_path(app: &VectorcraftApp, id: &Value) -> Vec { + let d = &app.session.active().unwrap().doc; + let rec = d.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().extras().objects_on_path.as_deref().unwrap(); + rec.members.iter().map(|m| m.id.0).collect() +} + +/// The help page's Detach in the Objects on Path Options section takes the object chosen with its +/// Select/Move widget off the path in one undo step and selects it. It is dimmed, and a click does +/// nothing, until one group is selected with an object chosen. +#[test] +fn detach_takes_the_chosen_object_off_the_path() { + let (mut app, ctx) = reference_setup(&[]); + let group = objects_on_path(&mut app, 150.0, 0.0); + let [first, second] = on_path(&app, &group)[..] else { panic!("two objects") }; + assert!(shows(&panel_frame(&mut app, &ctx, vec![], objects_on_path_options), "Detach")); + let depth = undo_depth(&app); + click_named(&mut app, &ctx, objects_on_path_options, "Detach"); + assert_eq!((on_path(&app, &group), undo_depth(&app)), (vec![first, second], depth), "no object chosen: dimmed"); + let other = objects_on_path(&mut app, 250.0, 0.0); + app.run("object.objectsOnPath.selectMember", json!({"member": first})).unwrap(); + app.run("select.add", json!({"ids": [other]})).unwrap(); + click_named(&mut app, &ctx, objects_on_path_options, "Detach"); + assert_eq!(on_path(&app, &group), [first, second], "two groups: dimmed"); + app.run("object.objectsOnPath.selectMember", json!({"member": first})).unwrap(); + let depth = undo_depth(&app); + click_named(&mut app, &ctx, objects_on_path_options, "Detach"); + assert_eq!(on_path(&app, &group), [second]); + assert_eq!(undo_depth(&app), depth + 1, "one undo step"); + let sel = &app.session.active().unwrap().selection; + assert_eq!((sel.objects.iter().map(|i| i.0).collect::>(), sel.on_path_member), (vec![first], None)); +} + /// #3759: with area type selected, the Properties panel's Paragraph section shows Align Top, Align /// Center, Align Bottom and Justify Vertically; a click sets the alignment on every selected frame /// in one undo step. Point type shows none; frames with rows or columns and threaded frames turn diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 77dd07e15f..bc1f9352c3 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -10,7 +10,7 @@ use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject}; -use super::{first_selected, pstate, set_pstate}; +use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; @@ -330,12 +330,14 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec, f64)> { /// As [`current`], with all the attributes of the paragraph whose stops the panel shows, its left /// indent as laid out ([`ParaStyle::laid_out_left_indent`]: its list level's indent added), where -/// the ruler shows it. +/// the ruler shows it. Without the Type tool editing text, the panel shows the first paragraph of +/// the first selected text object, also when that text is in a selected group or follows a +/// selected object that is not text. fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { let editing = super::character::text_editing(app); let n = match editing { Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(), - None => first_selected(app)?, + None => super::character::first_selected_text_node(app)?.clone(), }; let NodeKind::Text(t) = &n.kind else { return None }; let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index d6bcef15f1..6472b1017b 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -27,6 +27,83 @@ fn panel_draws_empty_with_text_and_with_stops() { assert_eq!(back[1]["align"], json!("decimal")); } +/// A rectangle and two area texts, the first with a stop at 40 pt and the second with one at +/// 90 pt, nothing selected. → the app and the ids of the rectangle and the two texts. +fn mixed_selection_app() -> (VectorcraftApp, [u64; 3]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let rect = id(app.run("shape.rectangle", json!({"x": 10, "y": 200, "width": 40, "height": 40})).unwrap()); + let a = id(app.run("text.create", json!({"x": 10, "y": 20, "text": "a\tb", "area": {"width": 300, "height": 60}})).unwrap()); + let b = id(app.run("text.create", json!({"x": 10, "y": 100, "text": "c\td", "area": {"width": 250, "height": 60}})).unwrap()); + for (text, position) in [(a, 40), (b, 90)] { + app.run("select.set", json!({"ids": [text]})).unwrap(); + app.run("text.tabs.set", json!({"stops": [{"position": position}]})).unwrap(); + } + app.run("select.set", json!({"ids": []})).unwrap(); + (app, [rect, a, b]) +} + +/// With a rectangle selected first and a text after it, the panel shows the text's stops on its +/// ruler, not the "No text selected" state. +#[test] +fn the_ruler_shows_text_selected_after_an_object_that_is_not_text() { + let (mut app, [rect, a, _]) = mixed_selection_app(); + app.run("select.set", json!({"ids": [rect, a]})).unwrap(); + let (node, stops, span) = current(&app).unwrap(); + assert_eq!(node.id.0, a); + assert_eq!(stops.iter().map(|s| s.position).collect::>(), [40.0]); + assert_eq!(span, 300.0); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0")); +} + +/// With a group selected first whose text follows a rectangle in it, the panel shows that text's +/// stops, ahead of a text selected after the group. +#[test] +fn the_ruler_shows_text_inside_a_selected_group() { + let (mut app, [rect, a, b]) = mixed_selection_app(); + app.run("select.set", json!({"ids": [rect, b]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, a]})).unwrap(); + let (node, stops, span) = current(&app).unwrap(); + assert_eq!(node.id.0, b); + assert_eq!(stops.iter().map(|s| s.position).collect::>(), [90.0]); + assert_eq!(span, 250.0); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0")); +} + +/// A stop added on the ruler of a mixed selection sets the first text's stops plus the new one on +/// every selected text, and leaves the rectangle as it was. +#[test] +fn a_stop_added_on_a_mixed_selection_sets_every_selected_text() { + let (mut app, [rect, a, b]) = mixed_selection_app(); + let shape = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().clone(); + let before = shape(&app); + app.run("select.set", json!({"ids": [rect, a, b]})).unwrap(); + let ctx = egui::Context::default(); + drag_on_ruler(&ctx, &mut app, 60.0, 60.0, false); + for text in [a, b] { + assert_eq!(text_of(&app, text).para_at(0).tabs.iter().map(|s| s.position).collect::>(), [40.0, 60.0], "text {text}"); + } + assert_eq!(shape(&app), before); +} + +/// A selection without text keeps the "No text selected" state and draws no ruler. +#[test] +fn a_selection_without_text_shows_no_ruler() { + let (mut app, [rect, ..]) = mixed_selection_app(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 200, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [rect, id]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + assert!(current(&app).is_none()); + let ctx = egui::Context::default(); + assert!(ruler_labels(&ctx, &mut app).is_empty()); + assert!(crate::tests_labels::painted_text(&mut app, show).contains("No text selected")); +} + /// In vertical type the Left and Right alignment buttons are named Top-Justified Tab and /// Bottom-Justified Tab, in their tool tips and for screen readers, and the position field is /// labeled Y. Horizontal type keeps Left-Justified Tab, Right-Justified Tab and X. diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 215cf7148a..8055c1288c 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -132,6 +132,103 @@ fn shift_click_selects_a_range_of_links_and_ctrl_click_toggles_one() { assert_eq!(selected(&app), id(&[3])); } +/// A linked image kept only in a symbol's art has a row of its own: a click picks it in the panel +/// (the canvas selection is cleared), Link Info shows it, Ctrl/Cmd-click adds an image in the +/// layers, a change of the canvas selection drops the pick, and Go To clears the selection and +/// says it has no place on the canvas. +#[test] +fn a_link_inside_a_symbol_is_listed_and_picked_in_the_panel() { + let dir = Folder::new("nested"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let inside = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": inside, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let outside = dir.file("layer.png", &png(20, 20)); + let layer = app.run("file.place", json!({"path": outside, "at": [300, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "inside.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let shown = |app: &mut VectorcraftApp, time: f64| -> Vec { + let raw = egui::RawInput { time: Some(time), ..Default::default() }; + super::tests_appearance::frame_raw(&ctx, app, raw, super::links::show).into_iter().map(|(t, _)| t).collect() + }; + assert!(shown(&mut app, 0.5).iter().any(|t| t == "20 × 20 px"), "Link Info of the selected image in the layers"); + click_row(&mut app, &ctx, "inside.png", egui::Modifiers::NONE, 1.0); + assert!(selected(&app).is_empty(), "the canvas can't select it"); + assert!(shown(&mut app, 1.5).iter().any(|t| t == "30 × 20 px"), "Link Info of the symbol's image"); + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 2.0); + assert_eq!(selected(&app), [layer]); + // Both rows are selected until the canvas selection changes: then the panel's pick goes too. + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 3.0); + assert!(selected(&app).is_empty()); + assert!(shown(&mut app, 3.5).iter().any(|t| t == "30 × 20 px"), "the symbol's image is still picked"); + app.run("select.set", json!({"ids": [layer]})).unwrap(); + assert!(shown(&mut app, 4.0).iter().any(|t| t == "20 × 20 px")); + app.run("select.set", json!({"ids": []})).unwrap(); + let text = shown(&mut app, 4.5); + assert!(!text.iter().any(|t| t == "30 × 20 px" || t == "20 × 20 px"), "nothing is picked: {text:?}"); + // Go To clears the selection and says the link has no place on the canvas. + app.run("select.set", json!({"ids": [layer]})).unwrap(); + super::links::go_to(&mut app, row["id"].as_u64().unwrap()); + assert_eq!(app.ui.status, "The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + assert!(selected(&app).is_empty(), "the image in the layers is no longer selected"); +} + +/// The tooltip of a row outside the layer tree says where the image is stored, so two rows of the +/// same file in different symbols or masks can be told apart. +#[test] +fn a_nested_rows_tooltip_names_what_holds_it() { + let tip = |owner: serde_json::Value, mask: Option<&str>| { + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png", "owner": owner}); + super::links::status_tip(&row, mask.map(str::to_string)) + }; + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark"}), None), "/x/a.png\nSymbols: Mark"); + assert_eq!(tip(json!({"kind": "pattern", "name": "Dots"}), None), "/x/a.png\nPattern: Dots"); + assert_eq!(tip(json!({"kind": "graphDesign", "name": "Bar"}), None), "/x/a.png\nGraph Design: Bar"); + assert_eq!(tip(json!({"kind": "layers", "maskOf": 7}), Some("")), "/x/a.png\nOpacity Mask: "); + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark", "maskOf": 7}), None), "/x/a.png\nSymbols: Mark\nOpacity Mask: 7"); + // A row in the layers says only its file. + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png"}); + assert_eq!(super::links::status_tip(&row, None), "/x/a.png"); +} + +/// The tooltip of an image in the opacity mask of an object inside a symbol names that object, +/// which the panel finds among the objects it collected with the list. +#[test] +fn a_mask_rows_tooltip_names_its_object_inside_a_symbol() { + let dir = Folder::new("mask-owner"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let mask = dir.file("mask.png", &png(20, 20)); + let mask = app.run("file.place", json!({"path": mask, "at": [40, 40]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("transparency.makeOpacityMask", json!({"ids": [rect, mask]})).unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [rect]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "mask.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let owner = vectorcraft_doc::NodeId(row["owner"]["maskOf"].as_u64().unwrap_or_else(|| panic!("{list}"))); + let doc = app.session.active().unwrap().doc.clone(); + assert!(doc.node(owner).is_none(), "the masked object is outside the layer tree"); + let name = super::layers::shown_name(&app, doc.stored_node(owner).unwrap().0, crate::i18n::current()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec, time: f64| { + super::tests_appearance::frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show) + }; + let at = super::tests_appearance::text_rect(&frame(&mut app, vec![], 1.0), "mask.png").center(); + frame(&mut app, vec![egui::Event::PointerMoved(at)], 2.0); + frame(&mut app, vec![], 4.0); + let text: Vec = frame(&mut app, vec![], 4.1).into_iter().map(|(t, _)| t).collect(); + let want = format!("Opacity Mask: {name}"); + assert!(text.iter().any(|t| t.contains(&want)), "{want} in {text:?}"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 08f0819b3a..72ce8004b5 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -438,12 +438,35 @@ fn the_materials_tab_lists_the_graphics_and_maps_one_in_one_undo_step() { let nodes = controls(&mut app, &ctx); named(&nodes, "Remove Graphic"); named(&nodes, "Scale"); - // Inflate has no planar faces to map onto. + // Inflate takes no graphics. set_type(&mut app, "inflate").unwrap(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); out.textures_delta.clear(); let text = crate::tests_labels::shapes_text(&out); - assert!(text.contains("Graphics map onto planar faces"), "{text}"); + assert!(text.contains("Graphics map onto the Plane, Extrude and Revolve object types"), "{text}"); +} + +#[test] +fn on_revolve_the_materials_tab_maps_onto_the_side_without_a_face_choice() { + let (mut app, ctx) = materials_app(); + ctx.enable_accesskit(); + super::run_action(&mut app, &ctx, crate::dialogs::three_d_materials::Action::Map("Graphic".into())); + let drawn = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + crate::tests_labels::shapes_text(&out) + }; + assert!(drawn(&mut app).contains("Face")); + // Revolve keeps the graphic on its side and lists it without Face; a pick maps another. + set_type(&mut app, "revolve").unwrap(); + assert_eq!(graphics(&app).as_array().map(Vec::len), Some(1)); + let history = undo_len(&app); + super::run_action(&mut app, &ctx, crate::dialogs::three_d_materials::Action::Map("Graphic".into())); + assert_eq!(graphics(&app).as_array().map(Vec::len), Some(2)); + assert_eq!(undo_len(&app), history + 1); + named(&controls(&mut app, &ctx), "Scale"); + let text = drawn(&mut app); + assert!(text.contains("Mapped graphics") && !text.contains("Face"), "{text}"); } #[test] diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 1169422789..94ae695ca4 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -604,7 +604,7 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool crate::menus::invoke(app, cmd, json!({})); } } - ui.separator(); + crate::chrome::group_separator(ui); true } diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs index 02c2229741..ddba7618d1 100644 --- a/crates/ui-egui/src/script_runs.rs +++ b/crates/ui-egui/src/script_runs.rs @@ -34,7 +34,12 @@ pub fn poll(app: &mut VectorcraftApp) { } script_run::settle_close(app); match result { - Ok(_) => app.status(format!("Ran {name}")), + // The commands it ran are journaled now: a recording they no longer fit in names its limit. + Ok(_) => { + if !crate::panels::actions::after_journaled_edit(app) { + app.status(format!("Ran {name}")); + } + } Err(e) => failed(app, e.to_string()), } app.sync_views(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f84b8f53b0..be804f0347 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -424,6 +424,10 @@ pub struct UiState { /// is on screen. Not saved. #[serde(skip)] pub learn_locate: Option, + /// The tutorials whose practice samples were opened and the tutorials finished, kept while the + /// app runs. + #[serde(skip)] + pub learn_practice: crate::panels::learn::Practice, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -632,6 +636,10 @@ pub struct UiState { /// ([`vectorcraft_engine::Journal::rewrites`]). #[serde(skip)] pub recording_tail: Option<(u64, usize, usize, usize)>, + /// The journal as the recording in progress last checked it against the limits of an action + /// set file: (its rewrites, its length) ([`crate::panels::actions::after_journaled_edit`]). + #[serde(skip)] + pub recording_checked: Option<(u64, usize)>, /// How actions play (Actions panel menu › Playback Options…), kept with the UI preferences. #[serde(default, deserialize_with = "crate::panels::actions::deserialize_playback")] pub action_playback: crate::panels::actions::PlaybackOptions, @@ -864,7 +872,8 @@ impl FloatingFlyout { } /// A toolbar made with Window › Toolbars › New Toolbar (`window.toolbar.new`): it floats on its -/// own, filled with tools from the All Tools drawer, and is kept with the workspace it was made in. +/// own or docks along the window's left or right edge, filled with tools from the All Tools drawer, +/// and is kept with the workspace it was made in. /// The groups torn off it are kept with it (`floating`), so the Tools panel's strips /// (`UiState::floating_flyouts`) hold none of them. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -877,8 +886,16 @@ pub struct CustomToolbar { /// It shows (Window › Toolbars › its name toggles it). #[serde(default = "yes")] pub shown: bool, - /// Top-left corner in screen points. + /// Top-left corner in screen points while it floats; while it is docked, where it floated last. pub pos: [f32; 2], + /// Its place in the stack of toolbars docked along one of the window's edges (0 at the top), + /// while it is docked there; none while it floats at `pos`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dock: Option, + /// It docks along the window's right edge, not the left: the edge it is docked at, or while it + /// floats the edge it docked at last. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dock_right: bool, /// The tool each slot of several tools shows, chosen last (keyed by the slot's first tool). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub slot_tool: std::collections::BTreeMap, @@ -1089,6 +1106,7 @@ impl Default for UiState { find_font_in: String::new(), learn: Default::default(), learn_locate: None, + learn_practice: Default::default(), control_bar: false, control_bar_bottom: false, control_bar_floating: false, @@ -1152,6 +1170,7 @@ impl Default for UiState { recording_into: None, recording_base: None, recording_tail: None, + recording_checked: None, action_playback: Default::default(), action_playing: None, shortcut_overrides: Default::default(), diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index ac382547de..28ffc812a6 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -85,6 +85,12 @@ impl ClipboardRead { /// thread and the handle lives and dies there, so the handle needn't be `Send`. pub type ClipboardProbeFactory = Box Box + Send>; +/// How long quitting waits for the probe's thread ([`ClipboardProbeFactory`]) to drop its handle +/// before the app drops its own: 27 s, VectorCraft's own bound. A host's handle should finish a +/// look and its own teardown within it, or the app's handle may not be the last one dropped; a +/// handle that never lets go still lets the app quit after this wait. +pub const CLIPBOARD_PROBE_STOP_WAIT: std::time::Duration = std::time::Duration::from_secs(27); + /// The Copy or Cut whose formats go to the system clipboard ([`VectorcraftApp::publish_clipboard`]). #[derive(Default)] pub(crate) struct Publishing { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 07d88595d7..b37ed0d10c 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -8,6 +8,7 @@ mod cut; mod distortkeys; mod docsetup; mod document_drag; +pub(super) mod folder; mod font_menu; mod fonts; mod guide_escape; diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index b443d84586..b73bb08dfa 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -248,6 +248,96 @@ fn the_menu_follows_the_selection() { assert!(items.windows(2).all(|w| !matches!(w, [Item::Sep, Item::Sep]))); } +/// A closed path of 60 jagged anchors around (150, 150), filled so that a right-click inside it +/// hits it, with nothing selected. +fn jagged(app: &mut VectorcraftApp) -> NodeId { + let anchors: Vec<_> = (0..60) + .map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 60.0; + let r = 60.0 + f64::from(i % 2) * 4.0; + json!({"x": 150.0 + r * a.cos(), "y": 150.0 + r * a.sin()}) + }) + .collect(); + let id = NodeId(app.run("path.create", json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + app.run("paint.setFill", json!({"color": "#ff8010"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + id +} + +/// The anchor points of every path in the document. +fn anchor_total(app: &VectorcraftApp) -> usize { + let mut n = 0; + app.session.active().unwrap().doc.walk(|node| { + if let NodeKind::Path { path, .. } = &node.kind { + n += path.anchor_count(); + } + }); + n +} + +/// A right-click on a path lists Object › Path › Smooth… after Join and Average…. Smooth… opens +/// the Smooth slider at Auto-Smooth's level and adds no undo step while it is open; OK keeps the +/// smoothed path as one undo step. +#[test] +fn right_click_on_a_path_opens_the_smooth_slider() { + let mut app = app(); + let id = jagged(&mut app); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = screen(&app, 150.0, 150.0); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert_eq!(app.session.active().unwrap().selection.objects, [id], "the path right-clicked is selected"); + let y = |label| at(&texts, label).y; + assert!(y("Join") < y("Average…") && y("Average…") < y("Smooth…"), "{texts:?}"); + let (undo, before) = (app.session.active().unwrap().history.undo.len(), anchor_total(&app)); + let after = click(&mut app, &ctx, at(&texts, "Smooth…"), PointerButton::Primary); + assert!(!has(&after, "Arrange"), "choosing Smooth… closes the menu"); + let open = app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.f64("level", -1.0))); + assert_eq!(open, Some((crate::dialogs::smooth_slider::KIND.to_string(), vectorcraft_engine::SMOOTH_AUTO_LEVEL))); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step while the slider is open"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1, "OK keeps the result as one undo step"); + assert!(anchor_total(&app) < before, "the path was smoothed: {} anchor points from {before}", anchor_total(&app)); +} + +/// Smooth… is listed while the selection holds a path: a path, a group of paths, a compound path, +/// a path with type, and anchors selected with Direct Selection (whose Remove Anchor Points +/// stays). It is left out with nothing, only type or a group of type selected. +#[test] +fn smooth_follows_the_selections_paths() { + let mut app = app(); + let (a, b) = (rect(&mut app, 0.0, 0.0), rect(&mut app, 150.0, 0.0)); + let t = NodeId(app.run("text.create", json!({"x": 10, "y": 250, "text": "Type"})).unwrap()["id"].as_u64().unwrap()); + let smooth = |app: &VectorcraftApp| labels(&menus::context_items(app)).contains(&"Smooth…"); + app.run("select.none", json!({})).unwrap(); + assert!(!smooth(&app), "nothing selected"); + app.run("select.set", json!({"ids": [t.0]})).unwrap(); + assert!(!smooth(&app), "only type selected"); + let u = NodeId(app.run("text.create", json!({"x": 10, "y": 300, "text": "More"})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [t.0, u.0]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert!(!smooth(&app), "a group of type only"); + app.run("object.ungroup", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + assert!(smooth(&app), "a path"); + app.run("select.set", json!({"ids": [a.0, t.0]})).unwrap(); + assert!(smooth(&app), "a path and type"); + app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert!(smooth(&app), "a group of paths"); + app.run("object.ungroup", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap(); + app.run("object.compoundPath.make", json!({})).unwrap(); + assert!(smooth(&app), "a compound path"); + app.run("object.compoundPath.release", json!({})).unwrap(); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": a.0, "anchors": [[0, 1]]})).unwrap(); + assert!(smooth(&app), "an anchor point direct-selected"); + assert!(labels(&menus::context_items(&app)).contains(&"Remove Anchor Points"), "the anchor entries stay"); +} + /// Selected type has Bullets and Numbering › Apply Bullets in the context menu and the Type menu, /// which makes its paragraphs a bulleted list; other art has no such item. #[test] diff --git a/crates/ui-egui/src/tests/folder.rs b/crates/ui-egui/src/tests/folder.rs new file mode 100644 index 0000000000..e8b4dfae8d --- /dev/null +++ b/crates/ui-egui/src/tests/folder.rs @@ -0,0 +1,76 @@ +//! A test's temporary folder, removed with everything in it when the test ends. + +use std::path::PathBuf; + +/// A test's folder, removed with everything in it when dropped, also when the test panics, as +/// the engine's find-files tests remove their trees. A removal that fails (a file still held open) +/// is ignored. +pub(crate) struct Folder(PathBuf); + +impl Folder { + /// A new empty folder `vectorcraft--` in the system temporary folder. One + /// left there by an earlier process with the same id is removed first. + pub(crate) fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("vectorcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + /// The folder at `dir`, made by the caller, removed when dropped. + pub(crate) fn adopt(dir: PathBuf) -> Self { + Self(dir) + } + + /// `bytes` written to a file `name` in the folder → its path. + pub(crate) fn file(&self, name: &str, bytes: &[u8]) -> String { + let path = self.0.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() + } +} + +impl std::ops::Deref for Folder { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for Folder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn a_folder_is_removed_when_its_test_ends() { + let folder = Folder::new("ui-folder-ends"); + let path = folder.to_path_buf(); + std::fs::create_dir_all(path.join("nested")).unwrap(); + folder.file("nested/a.txt", b"a"); + assert!(path.join("nested").join("a.txt").is_file()); + drop(folder); + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn a_folder_is_removed_when_its_test_panics() { + let path = std::env::temp_dir().join(format!("vectorcraft-ui-folder-panics-{}", std::process::id())); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let folder = Folder::new("ui-folder-panics"); + folder.file("a.txt", b"a"); + panic!("the test fails"); + }); + assert_eq!(r.err().as_deref(), Some("the test fails")); + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn an_adopted_test_kit_folder_is_removed_when_its_test_ends() { + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("ui-folder-adopted")); + let path = folder.to_path_buf(); + folder.file("a.txt", b"a"); + drop(folder); + assert!(!path.exists(), "{} is left", path.display()); +} diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index 47d6258a1d..7ab5378e14 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -1058,6 +1058,54 @@ fn open_menu_with(id: &str, left: f32, width: f32, screen: egui::Vec2, look: Men drawn } +/// The field is drawn while other threads ask the context for repaints, as the threads that +/// render the samples do when they finish. A read of the context made while the menu already +/// holds one waits behind such a request, and a debug build of egui panics after ten seconds. +#[test] +fn the_field_draws_while_other_threads_request_repaints() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + /// Stops the threads when the test ends, failed or not. + struct Stop(Arc); + impl Drop for Stop { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } + } + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let stop = Stop(Arc::default()); + let (started, running) = std::sync::mpsc::channel(); + let threads: Vec<_> = (0..4) + .map(|_| { + let (ctx, stop, started) = (ctx.clone(), stop.0.clone(), started.clone()); + std::thread::spawn(move || { + let _ = started.send(()); + while !stop.load(Ordering::Relaxed) { + ctx.request_repaint(); + } + }) + }) + .collect(); + for _ in &threads { + running.recv().expect("the thread started"); + } + // The wheel on: the field also looks up its queued wheel input. + let look = MenuLook { wheel: true, ..Default::default() }; + for _ in 0..2000 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + font_menu(ui, "test-repaints", "Inter", 200.0, None, look); + }); + out.textures_delta.clear(); + } + drop(stop); + for thread in threads { + thread.join().expect("the thread ended"); + } +} + /// A narrow field, such as the Control bar's, opens a menu wide enough for the filters on their /// rows and for the families' names, each drawn whole beside its sample. #[test] diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index 60f80f4fa4..9c67861eeb 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -11,6 +11,7 @@ use vectorcraft_engine::Session; use crate::VectorcraftApp; use crate::canvas::Xf; use crate::place::{DropAt, DropTarget}; +use crate::tests_folder::Folder; /// A `w`×`h` red PNG declaring `ppi`. fn png(w: u32, h: u32, ppi: f64) -> Vec { @@ -19,15 +20,6 @@ fn png(w: u32, h: u32, ppi: f64) -> Vec { vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out, (ppi, ppi)) } -/// `bytes` written to a fresh temporary file named `name` → its path. -fn temp_file(name: &str, bytes: &[u8]) -> String { - let dir = std::env::temp_dir().join(format!("vectorcraft-ui-place-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(name); - std::fs::write(&path, bytes).unwrap(); - path.to_string_lossy().to_string() -} - /// An app reading files from disk, with a 400×300 document. fn app() -> VectorcraftApp { let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; @@ -166,7 +158,8 @@ fn a_dropped_swatch_library_opens_in_the_library_panel() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let gpl = temp_file("dropped.gpl", b"GIMP Palette\nName: Dropped\n#\n255 0 0\tRed\n 0 128 255\tSky\n"); + let dir = Folder::new("ui-place-swatches"); + let gpl = dir.file("dropped.gpl", b"GIMP Palette\nName: Dropped\n#\n255 0 0\tRed\n 0 128 255\tSky\n"); frame(&mut app, &ctx, vec![], &[&gpl], false); let id = format!("loaded/{gpl}"); assert_eq!(crate::panels::library_panel::last_open(&app.ui).map(|o| o.id.as_str()), Some(id.as_str()), "{}", app.ui.status); @@ -184,7 +177,8 @@ fn a_dropped_script_shows_its_check() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let path = temp_file("dropped.jsx", b"#target acme\napp.executeMenuCommand('acmecopyall');\n"); + let dir = Folder::new("ui-place-script"); + let path = dir.file("dropped.jsx", b"#target acme\napp.executeMenuCommand('acmecopyall');\n"); frame(&mut app, &ctx, vec![], &[&path], false); let d = app.ui.dialog.as_ref().expect("the check is shown"); assert_eq!(d.kind, crate::dialogs::script_check::KIND, "{}", app.ui.status); @@ -203,7 +197,8 @@ fn a_dropped_libraries_panel_file_is_added_as_a_new_library() { let ctx = egui::Context::default(); laid_out(&mut app, &ctx); let file = json!({"name": "Dropped", "colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}]}); - let path = temp_file("dropped.vclibrary", file.to_string().as_bytes()); + let dir = Folder::new("ui-place-library"); + let path = dir.file("dropped.vclibrary", file.to_string().as_bytes()); frame(&mut app, &ctx, vec![], &[&path], false); assert_eq!(app.ui.status, "Imported library Dropped"); let lib = app.session.execute("library.get", &json!({})).unwrap(); @@ -220,7 +215,8 @@ fn a_dropped_shortcut_set_opens_import_shortcut_set() { let ctx = egui::Context::default(); let rect = laid_out(&mut app, &ctx); let set = "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n"; - let path = temp_file("dropped.kys", set.as_bytes()); + let dir = Folder::new("ui-place-shortcuts"); + let path = dir.file("dropped.kys", set.as_bytes()); frame(&mut app, &ctx, vec![], &[&path], false); let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status); @@ -237,7 +233,8 @@ fn a_dropped_png_is_placed_in_the_middle_of_the_view() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let path = temp_file("drop.png", &png(20, 10, 72.0)); + let dir = Folder::new("ui-place-png"); + let path = dir.file("drop.png", &png(20, 10, 72.0)); // Where egui last saw the pointer is stale during a desktop drag: it doesn't count. frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0))], &[&path], false); let n = selected_image(&app); @@ -258,8 +255,9 @@ fn a_dropped_document_opens_as_a_tab_of_its_own() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let svg = temp_file("dropped.svg", SVG.as_bytes()); - let pic = temp_file("with-it.png", &png(8, 8, 72.0)); + let dir = Folder::new("ui-place-tab"); + let svg = dir.file("dropped.svg", SVG.as_bytes()); + let pic = dir.file("with-it.png", &png(8, 8, 72.0)); frame(&mut app, &ctx, vec![], &[&svg, &pic], false); assert_eq!(app.session.documents().len(), 2, "opened, not placed"); assert_eq!(app.session.active_index(), Some(1), "the document opened is shown"); @@ -270,7 +268,8 @@ fn a_dropped_document_opens_as_a_tab_of_its_own() { #[test] fn a_native_svg_drop_uses_file_open_even_with_a_path_only_handle() { - let path = temp_file("path-only.svg", SVG.as_bytes()); + let dir = Folder::new("ui-place-path-only"); + let path = dir.file("path-only.svg", SVG.as_bytes()); let mut dropped = app(); let ctx = egui::Context::default(); laid_out(&mut dropped, &ctx); @@ -317,8 +316,9 @@ fn multiple_svg_drops_open_individually() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let a = temp_file("first.svg", SVG.as_bytes()); - let b = temp_file("second.svg", SVG.as_bytes()); + let dir = Folder::new("ui-place-several-svg"); + let a = dir.file("first.svg", SVG.as_bytes()); + let b = dir.file("second.svg", SVG.as_bytes()); frame(&mut app, &ctx, vec![], &[&a, &b], false); assert_eq!(app.session.documents().len(), 3); assert_eq!(app.session.active().unwrap().path.as_deref(), Some(b.as_str())); @@ -330,15 +330,16 @@ fn unsupported_and_unreadable_drops_leave_the_document_open() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let invalid = temp_file("not-art.xyz", b"not an image or document"); + let dir = Folder::new("ui-place-unreadable"); + let invalid = dir.file("not-art.xyz", b"not an image or document"); frame(&mut app, &ctx, vec![], &[&invalid], false); assert!(app.ui.status.contains("Couldn't place not-art.xyz"), "{}", app.ui.status); - let invalid_svg = temp_file("broken.svg", b" = ["held-a.png", "held-b.png", "held-c.png"].iter().map(|name| temp_file(name, &png(10, 10, 72.0))).collect(); + let dir = Folder::new("ui-place-held-escape"); + let paths: Vec = ["held-a.png", "held-b.png", "held-c.png"].iter().map(|name| dir.file(name, &png(10, 10, 72.0))).collect(); app.run("file.place", json!({"paths": paths})).unwrap(); crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(app.session.tool_options()["count"], 3); @@ -774,7 +785,8 @@ fn holding_a_chord_bound_to_a_command_repeats_it_with_the_place_cursor_loaded() let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); app.run("shortcuts.set", json!({"id": "view.zoomIn", "shortcut": "Shift+Escape", "force": true})).unwrap(); - let paths: Vec = ["chord-a.png", "chord-b.png"].iter().map(|name| temp_file(name, &png(10, 10, 72.0))).collect(); + let dir = Folder::new("ui-place-held-chord"); + let paths: Vec = ["chord-a.png", "chord-b.png"].iter().map(|name| dir.file(name, &png(10, 10, 72.0))).collect(); app.run("file.place", json!({"paths": paths})).unwrap(); crate::dialogs::confirm(&mut app).unwrap(); let modifiers = egui::Modifiers::SHIFT; @@ -793,7 +805,8 @@ fn holding_a_chord_bound_to_a_command_repeats_it_with_the_place_cursor_loaded() fn crop_image_shows_a_box_that_apply_crops_to() { use vectorcraft_tools::ToolKey; let mut app = app(); - place_picked(&mut app, &temp_file("crop.png", &png(100, 50, 72.0))); + let dir = Folder::new("ui-place-crop"); + place_picked(&mut app, &dir.file("crop.png", &png(100, 50, 72.0))); app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); crate::dialogs::confirm(&mut app).unwrap(); let whole = selected_image(&app).geometric_bounds().unwrap(); @@ -899,7 +912,8 @@ fn a_file_placed_while_idle_follows_its_changes() { // Half a second later, before the next look is due, a file is placed. The place itself is a // frame (as any command); then nothing happens for 3 s. time += 0.5; - let path = temp_file("idle-watch.png", &png(40, 30, 72.0)); + let dir = Folder::new("ui-place-idle-watch"); + let path = dir.file("idle-watch.png", &png(40, 30, 72.0)); app.run("file.place", json!({"path": path})).unwrap(); let id = app.session.active().unwrap().selection.objects[0]; idle(&mut app, &ctx, &mut time, 3.0, &|_| false); @@ -925,7 +939,8 @@ fn a_look_whose_worker_stopped_does_not_stop_later_looks() { // Every frame renders on this thread: a frame finished by the render worker asks for a repaint. app.canvas.worker_started = true; app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap(); - let path = temp_file("stopped-watch.png", &png(40, 30, 72.0)); + let dir = Folder::new("ui-place-stopped-watch"); + let path = dir.file("stopped-watch.png", &png(40, 30, 72.0)); app.run("file.place", json!({"path": path})).unwrap(); let id = app.session.active().unwrap().selection.objects[0]; let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/tests/sysclip_probe.rs b/crates/ui-egui/src/tests/sysclip_probe.rs index 05cab31bf7..1566b2bc8a 100644 --- a/crates/ui-egui/src/tests/sysclip_probe.rs +++ b/crates/ui-egui/src/tests/sysclip_probe.rs @@ -12,7 +12,7 @@ use vectorcraft_engine::Session; use vectorcraft_engine::cmd::clipboard::Flavour; use crate::tests_sysclip::{copy_rect, run}; -use crate::{Services, SystemClipboard, VectorcraftApp, menus}; +use crate::{Services, SystemClipboard, VectorcraftApp, clipboard_probe, menus}; /// What the clipboard handles saw: the thread each `has()` ran on, how many handles are alive, and /// the thread each handle was dropped on, in order. @@ -206,6 +206,87 @@ fn quitting_drops_the_probes_clipboard_before_the_apps() { assert_eq!(drops, vec![Some("vectorcraft-clip".to_owned()), ui], "handles dropped on: {drops:?}"); } +/// The probe's clipboard: dropping it tells `dropping` it started, then waits until `release` sends +/// (a handle that lets go only once an unanswered read ends), and tells `dropped` once it is gone. +struct Releasing { + rec: Arc, + dropping: std::sync::mpsc::Sender<()>, + release: std::sync::mpsc::Receiver<()>, + dropped: std::sync::mpsc::Sender<()>, +} + +impl Drop for Releasing { + fn drop(&mut self) { + let _ = self.dropping.send(()); + let _ = self.release.recv(); + self.rec.drops.lock().unwrap().push(std::thread::current().name().map(str::to_owned)); + let _ = self.dropped.send(()); + } +} + +impl SystemClipboard for Releasing { + fn write(&mut self, _flavours: &[Flavour]) -> Result<(), String> { + Ok(()) + } + fn holds_ours(&mut self) -> bool { + false + } + fn read(&mut self, _mimes: &[&'static str]) -> Option { + None + } + fn has(&mut self, _mimes: &[&'static str]) -> bool { + false + } +} + +#[test] +fn quitting_waits_while_the_probes_clipboard_is_still_letting_go() { + let rec = Arc::new(Recorder::default()); + let (dropping, dropping_seen) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel(); + let (dropped, dropped_seen) = std::sync::mpsc::channel(); + let make = { + let rec = Arc::clone(&rec); + move || -> Box { Box::new(Releasing { rec, dropping, release: released, dropped }) } + }; + let services = Services { + system_clipboard: Some(Slow::boxed(&rec, Duration::ZERO, Some(false))), + clipboard_probe: Some(Box::new(make)), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + frame(&mut app); + // A controllable clock: the probe's handle lets go 26 s after the quit (a text read unanswered + // just before it, and the worker's teardown). The wait sees the handle start to drop, then + // either reaches that time and waits for the worker, or ends at its deadline first. + let lets_go = Duration::from_secs(26); + let release_at_deadline = release.clone(); + app.clipboard_probe.as_mut().expect("the probe started").stop = Box::new(move |verdicts, quit, end| { + dropping_seen.recv().expect("the probe's handle was never dropped"); + if end >= quit + lets_go { + let _ = release_at_deadline.send(()); + while verdicts.recv().is_ok() {} + } + }); + drop(app); + // A wait that ended first left the handle dropping: let it finish to read the order. + let _ = release.send(()); + dropped_seen.recv().expect("the probe's handle was never dropped"); + let drops = rec.drops.lock().unwrap().clone(); + let ui = std::thread::current().name().map(str::to_owned); + assert_eq!(drops, vec![Some("vectorcraft-clip".to_owned()), ui], "handles dropped on: {drops:?}"); +} + +#[test] +fn quitting_waits_out_an_unanswered_x11_read_and_its_teardown() { + let quit = Instant::now(); + // A look whose text read went unanswered just before the quit: the Linux host's worker holds its + // handle until 25 s after the read was sent, and an idle worker's teardown takes 2 s at most. + assert!(clipboard_probe::stop_by(quit) >= quit + Duration::from_secs(25) + Duration::from_secs(2)); + // A handle that never lets go still lets the app quit. + assert_eq!(clipboard_probe::stop_by(quit), quit + Duration::from_secs(27)); +} + #[test] fn without_its_thread_the_check_runs_in_line_again() { let rec = Arc::new(Recorder::default()); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 4cece18050..d5199e0220 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -121,12 +121,10 @@ fn an_image_with_vector_art_offers_make_clipping_mask() { image::RgbaImage::from_pixel(20, 20, image::Rgba([0, 128, 255, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); - let dir = std::env::temp_dir().join(format!("vectorcraft-ui-taskbar-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("blue.png"); - std::fs::write(&path, &png).unwrap(); + let dir = crate::tests_folder::Folder::new("ui-taskbar"); + let path = dir.file("blue.png", &png); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); - app.run("file.place", json!({"path": path.to_string_lossy(), "link": false})).unwrap(); + app.run("file.place", json!({"path": path, "link": false})).unwrap(); let img = app.session.active().unwrap().selection.objects[0].0; assert!(matches!(kind(&app, img), NodeKind::Image(_))); let shape = id(app.run("shape.ellipse", json!({"x": 150, "y": 100, "width": 60, "height": 60})).unwrap()); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b1788e1e8a..29d89ebf6d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -145,6 +145,8 @@ pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.toolbar_pos.is_none() && (app.ui.tools_beside_canvas || app.ui.toolbar_right) { show(app, ui); } + // The lane where toolbars made with New Toolbar dock on the left, right of Tools. + crate::custom_toolbars::lane_beside_canvas(app, ui); } /// Is the Tools panel docked on the right, between the canvas and the panels? @@ -528,7 +530,7 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("tool.setOption", json!({ "key": key, "value": value })).ok(); } } - ui.separator(); + crate::chrome::group_separator(ui); } /// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's @@ -564,7 +566,7 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) { crate::canvas::apply_requests(app, r); } } - ui.separator(); + crate::chrome::group_separator(ui); } /// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as @@ -1155,7 +1157,7 @@ fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::flat_button(ui, "Select All Pins", 104.0).clicked() { app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok(); } - ui.separator(); + crate::chrome::group_separator(ui); } #[cfg(test)] diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 0cc6433429..b5fb04d384 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -46,6 +46,13 @@ pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) } +/// This frame's Escape put a moved bar back ([`escape_cancelled_move`]): a toolbar made with New +/// Toolbar ([`crate::custom_toolbars::cancel_move_on_escape`]) as well as the Tools panel. +pub(crate) fn mark_escape_cancelled(ctx: &egui::Context) { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(escape_cancel_id(), frame)); +} + /// The strip along the canvas's left edge where the Tools panel docks while Keep Tools Beside the /// Canvas is on, as last laid out. pub(crate) fn tools_zone_id() -> Id { @@ -194,7 +201,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } /// The `x`, `y` params: a top-left corner in screen points, if given. -fn position(p: &Value) -> Result, String> { +pub(crate) fn position(p: &Value) -> Result, String> { let coord = |k: &str| match p.get(k) { None | Some(Value::Null) => Some(None), Some(v) => v.as_f64().filter(|f| f.abs() < 1e6).map(|f| Some(f as f32)), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 826e7e02f6..52849bc2bc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -47,8 +47,8 @@ pub use drag::{ use dropdowns::combo_button_id; pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open}; pub use dropdowns::{ - dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, - panel_scroll, popover, set_menu_floor, strip_scroll, + dropdown, dropdown_icon_names, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, + menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, }; #[cfg(test)] pub(crate) use dropdowns::{menu_floor, menu_room_either}; diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 44903d6613..a2741ff1cb 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -1,3 +1,5 @@ +use egui::IntoAtoms as _; + use super::drag::PanelDrag; use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; use super::text::take_armed_focus; @@ -33,6 +35,42 @@ pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option< }) } +/// [`dropdown_names`] whose options each show an icon before their name, in the open list and in +/// the closed field, so that a name that says little on its own is shown with a picture of what it +/// means (#6304). `icon(ui, rect, index, ink, highlighted)` paints option `index`'s icon into +/// `rect`, an `icon_size` square, with `ink`, the colour the row's name is drawn in; +/// `highlighted` is true on the open list's row of the current option, drawn on the selection +/// fill, where an accent-coloured mark would lose its contrast. `current` is the option shown; +/// `None` shows a blank field (characters that differ). +pub fn dropdown_icon_names( + ui: &mut Ui, + id: impl std::hash::Hash, + current: Option, + options: &[&str], + width: f32, + icon_size: f32, + icon: impl Fn(&Ui, Rect, usize, egui::Color32, bool), +) -> Option { + let icon = &icon; + let atom = move |i: usize, highlighted: bool| { + egui::Atom::paint(Vec2::splat(icon_size), move |ui, a| icon(ui, a.rect, i, a.fallback_text_color, highlighted)) + }; + let shown = match current.and_then(|i| options.get(i).map(|name| (i, *name))) { + Some((i, name)) => (atom(i, false), egui::RichText::new(name).size(12.0)).into_atoms(), + None => egui::RichText::new("").size(12.0).into_atoms(), + }; + combo_atoms_named(ui, id, None, shown, width, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + let on = current == Some(i); + if ui.add(egui::Button::selectable(on, (atom(i, on), *o))).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + /// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). pub fn dropdown_with( ui: &mut Ui, @@ -88,6 +126,18 @@ pub(crate) fn combo_named( current: &str, width: f32, list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + combo_atoms_named(ui, id, name, egui::RichText::new(current).size(12.0), width, list) +} + +/// [`combo_named`] whose closed field shows the atoms `current` (an icon and a name, say). +fn combo_atoms_named<'a, R>( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: impl egui::IntoAtoms<'a>, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); let label = take_previous_label(ui); @@ -99,7 +149,7 @@ pub(crate) fn combo_named( let button_id = combo_button_id(ui, &id); let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || craft_ui::ComboBox::is_open(ui.ctx(), button_id); let response = craft_ui::ComboBox::from_id_salt(HashOnly(&id)) - .selected_text(egui::RichText::new(current).size(12.0)) + .selected_text(current) .width(width - 4.0) .height(ui.spacing().combo_height) .show_ui(ui, list); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5694d9582d..1a0d5c7d7d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -417,7 +417,10 @@ button (Add Tool…) opens a menu of every tool under the drawer's headings: a c {tool, toolbar}`; the tools already in the toolbar are dimmed under their headings. The drawer has no Show section there. `window.floatTools {tool, toolbar}` floats one of its groups, and a group torn off it shows and hides with it. `window.toolbar.list` returns the toolbars (`ui.custom_toolbars` in `ui.inspect`: name, slots, shown flag, -two-column flag, top-left corner, the groups torn off it). They are kept with the workspace they were made in: +two-column flag, top-left corner, the groups torn off it, and `dock` and `dock_right` while docked or once docked). +`window.toolbar.dock {name, side?, order?}` docks a toolbar in the lane along the window's left or right edge, and +`window.toolbar.float {name, x?, y?}` floats it again; dropping its title bar on an edge or dragging it out of its +lane does the same (see the docking section in `docs/mcp.md`). They are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them. diff --git a/docs/development.md b/docs/development.md index 962f7207f5..cbb20e3b8a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,7 +126,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: - **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and the UI thread waits 2 s at most: an owner that never sends its data costs one read 2 s, and later reads return at once until it answers. When a read gets no answer in its 2 s, such as a paste's check whether the clipboard still holds what VectorCraft copied, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, and pastes the text unless it is empty or what VectorCraft copied. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. -- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. +- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app hides its main window before these waits (`hide_before_teardown` in `apps/vectorcraft/src/app.rs`), so an X11 window, an XWayland one included, closes at once; winit cannot hide a native Wayland window, which stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. diff --git a/docs/dimensions.md b/docs/dimensions.md index 6f11a29c96..9cba8a66dc 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -16,7 +16,12 @@ The **Dimension Tool** is in the Modify section of the toolbar. Its strip offers before the release; a longer drag drags out the dimension of the segment under the pointer. A curved segment is measured from end to end. With Smart Guides on, both points also snap into line with other objects' anchors and centres, and the second onto the construction guides through the first; the guides - show while hovering, before each click. + show while hovering, before each click. VectorCraft's own rules decide where a point lands: an + anchor within 8 pixels takes it first; a point on a guide stays on the guide where the pointer + is, except that it moves onto a segment within the Smart Guides snapping tolerance, or onto the + place where a construction guide crosses another object's alignment line within twice that + tolerance; a point that no anchor, guide, grid or pixel snapping places lands on the closest + point of a segment within 8 pixels. A linear dimension is aligned, horizontal or vertical, as the [linear dimensions help](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/plot-linear-dimensions-of-objects.html) describes: after the second click, clicking again places an aligned dimension, and moving the diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 0eb68c7e25..96b07d508f 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle ([three-d.md](three-d.md#graphics)); no base materials, graphics on curved surfaces, soft shadows or ray tracing | +| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0ee82f2cbf..f752a8973b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -324,6 +324,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | +| 2026-11-13 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: a top-object envelope's mesh can save a side's curve in pieces. In users' files (used locally, never committed), 10 top objects save a grid of three patches across whose top and bottom edges are each one cubic split into thirds, and their drawn art follows the whole curve's parameter. VectorCraft gave each piece the share of its control polygon, so their type came out up to about half a point off the drawn art along the side, and one envelope of type kept its drawn art instead of opening live. A side whose pieces are all one curve split at anchors (each within 0.02 pt of that curve's piece) now follows that curve's parameter (#7097). Synthetic test: `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import (#6983): type from the text document is set in an installed face of the PostScript name its font record gives, one of exactly that name first. That face's family is no longer swapped for another family spelled alike but for case and punctuation (`FontDb::find_family`, which picked `SAMPLE INK` for `SampleInk`, the PostScript name of `Sample/Ink`); among the faces `FontDb::postscript_faces` lists for the name, the import takes one whose name matches in case and punctuation before one that matches only without them; and when the family and style alone resolve to another installed copy, the type names the face's version. A font record's key 5, the version string (name ID 5), picks that version among the faces of its PostScript name, also when the record spells the name with other punctuation (`Sample-Slab` for faces named `SampleSlab`); each record is resolved once. Type a PostScript program sets and legacy `.ai` type take the face of their font's name the same way. The `.ai` save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), so two such copies are two fonts of the set, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes): such type round-trips without the type attributes note, and so does a version that isn't installed, which draws in the face the font resolves to anyway. In 224 users' files (used locally, never committed; all PDF-compatible, so the PostScript and legacy paths are not exercised), 16 files set some type in another installed face; 12 render differently against their own page, 10 closer (in one from 2.55% to 0.01% of the page differing) and 2 further by 0.03% of the page (an envelope of type that now opens live, and a copy of the same widths in the version the record names); envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens in another way. The notes of how far a file's layers differ from its page shrink in 2 files and appear in 2 where an envelope of type now opens live, so the file is compared with its page. Two installed copies of one PostScript name, family and style that differ in widths, in files whose records keep no version, still take the copy the family and style resolve to: only the page's widths tell them apart. Tests: `tests_native::type_from_the_text_document_takes_the_face_of_its_exact_postscript_name`, `tests_native::type_from_the_text_document_takes_the_version_its_font_record_keeps`, `tests_fontnames::shown_type_takes_the_face_of_its_exact_postscript_name`, `ai::tests::legacy_type_takes_the_face_of_its_exact_postscript_name`, `ai_editing::tests::type_in_a_font_beside_a_family_spelled_alike_keeps_its_font`, `ai_editing::tests::type_in_another_version_of_a_static_font_keeps_it`, `ai_editing::tests::type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it`, `ai_editing::tests::a_version_longer_than_the_reader_takes_is_reported`, `vectorcraft-text` `tests::versions::every_face_of_a_postscript_name_is_found`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type from the text document keeps its manual kerning (#6899). Key 8 of a story's text holds it as runs of characters (key 1), each with a value (key 0 of key 0, in thousandths of an em) or empty where the automatic kerning applies; a character with a value takes it as its Kerning, the space between it and the next character ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), of the em as its Horizontal Scale draws it, as tracking. It wasn't read, so type kerned by hand came in spaced by the font alone and drifted from the page from each kerned pair on, and envelopes of such type drew apart from their drawn art and kept it. In users' files, used locally and never committed, 48 stories in 17 of 224 files have it, and the drawn art of envelopes of such type sets each kerned gap wider or narrower by exactly that value (a heading's first pair at -140, the letters of a line at 20 to 220). Measured against each file's own page, 14 of those files draw closer (in 4 the pixels that differ go from 2.7–4.7% to under 0.6%) and 2 a little further, both where an envelope of type now opens live in place of its drawn art; envelopes of type that open live go from 154 to 159 of 203, none fewer in any file, and two notes that the type differs from the page go. A value that can't be read keeps the automatic kerning, a run whose length can't be read ends the runs there, and past 65,536 runs the characters keep the automatic kerning. `.ai` save still leaves kerning out of the editing data, so saving such a file as `.ai` again now gives the note that attributes of type are left out for type with manual kerning. Tests `ate::tests::a_story_keeps_its_manual_kerning`, `tests_native::type_keeps_its_manual_kerning`; scores unchanged | | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, after the ways it was tried before (its glyphs alone and grown by half its strokes' weight, #6535, on its own warp and on its saved grid), before a grid moved onto its drawn art, and only where that box is another: its glyph outlines with their fills and strokes baked as the envelope bakes them (geometry effects on them applied: a stroke a Transform effect moves counts where it is moved to), each piece measured by its visual bounds, which are exact for miter joins (a tip counts where the miter limit keeps it, not the miter allowance at every corner that the bounds with strokes take) and round joins and an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes; a contour of zero length, which paints nothing, doesn't count. Effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The box is measured only when that try comes and the import has a check left for it, and once for an envelope. Groups round type and other objects in the contents are measured as before. In users' files (used locally, never committed) the drawn art of some stroked type in an envelope maps from that box: where the type has sharp corners at its edges (slanted stems, pointed feet) and miter joins, or a stroke moved by a Transform effect (a drop shadow), its glyphs grown by half the weight gave too small a box and the envelope kept its drawn art; and where a glyph holds a contour of zero length at its edge, the glyphs' box counted it. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 161 in 50, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 6 files, by at most 0.94% of their pixels, each where an envelope of type now opens live in place of its drawn art (the page's own art): 1 closer to the file's own page and 5 further; the notes change in the same 6 files: the note on type in an envelope goes where none keeps its drawn art, and a file whose envelopes of type all kept their drawn art, which opened without the page comparison, now opens with it and carries its notes (the share of art or type that differs from the page, type the page has without a text object, the PDF part's note on DeviceN colours). A synthetic file of 256 such envelopes of 2,000 characters each opens in the same time as before (13.3 s in a debug build): the box is measured only when its try comes. Tests `ai::envelope::tests::mitred_type_is_also_tried_mapped_from_what_its_stroke_paints_miter_tips_included`, `type_with_a_mitred_character_stroke_is_also_tried_mapped_from_what_it_paints`, `a_stroke_moved_by_a_transform_effect_counts_where_it_paints`, `round_joined_type_is_not_tried_twice_for_the_same_box`, `a_contour_of_zero_length_paints_nothing`, `a_side_within_the_slack_of_the_geometric_box_is_that_side`, `tests_native::a_warp_of_mitred_type_maps_from_what_its_stroke_paints_where_its_drawn_art_does` (#6824, part of #3179) | diff --git a/docs/gaps.md b/docs/gaps.md index d34956d4e0..39eb984944 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -541,7 +541,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. The original polynomial distributions approximate the public description. With the Selection or Blend tool, on-canvas widgets on a blend with a spine set the color Shift and, when the steps follow - that spine, the Ramp; a blend made without a spine shows neither widget. The Blend panel shows the + that spine, the Ramp; a blend made without a spine shows the color Shift widget alone. The Blend panel shows the values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s @@ -684,8 +684,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged. -- **Partial:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they - were made in, and Window › Toolbars › Manage Toolbars renames, copies and deletes them; docking them is missing. +- **Done:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they + were made in, and Window › Toolbars › Manage Toolbars renames, copies and deletes them; they dock in lanes along + the window's left and right edges (`window.toolbar.dock`, `window.toolbar.float`). Inside the estimate below. - **Done (#1753):** a saved workspace keeps the width of the dock column. - **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window. diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 364dc2fcd8..40f6fe43b9 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -184,6 +184,47 @@ Tests on code-drawn images: `tests::a_blurred_edge_is_no_ring_of_a_tone_between_ blurred into a 10 px ramp, 16 Colors), `tests::grayscale_settles_a_blurred_edge_between_its_own_two_grays`, `tests::grayscale_settles_a_tinted_blurred_edge_by_its_lightness`, `tests::the_photo_presets_keep_the_tones_of_a_blurred_edge` and `quantize::cut_edge::tests`. +## Staircases in smooth areas +Where a band border in a gradient or a shaded area runs at a slant, its pixels form a staircase whose +steps are two or more pixels long. One piece of outline can run along a sharp edge in the image and then +along such a border with no junction between them. That piece as a whole is not a contour (see above), +and the outline kept a corner at every step. `subpixel::outline` now reads a run of three or more edges of +1 to 8 px (`STAIR_MAX`), each turning one way where it starts and the other where it ends, with no +junction on it, as a staircase when the run's own cracks pass `subpixel::low_contrast`. A run is cut at +any step whose own cracks show half of the step between the two colors or more. The 8 px limit and the +one-half cut are VectorCraft's own values. The run's edges are then steps, as one-pixel steps already +were: the outline has no corner where two of them meet, and each gives one point, its middle, moved by the +mean of its cracks' shifts. Where a run meets an edge that is not a step, the corner between them stays, +so a straight edge longer than 8 px next to a staircase stays straight. A straight edge of 8 px or less +between two turns of opposite sign is read as one more step of the run. Inside a run, two one-pixel steps +between longer ones are read as part of the slope and do not make a notch. A staircase of longer steps, +and one whose cracks read as an edge, keep their corners, and Black and White traces do not use the rule. +The runs are the same from either side of the border. +Measured with the harness below, before and after the change: rows of the "flat colour art" inputs do not +change. On gradient and photo inputs, anchors change in 106 rows, 101 of them fewer, by −64% to +6.4%: for +example 390 to 142 (Shades of Gray, linear gradients), 1,860 to 1,537 (High Fidelity Photo, linear +gradients), 6,766 to 6,542 (High Fidelity Photo, gradient and photo) and 47 to 50 (High Fidelity Photo with +Gradients, blurred). On gradient and photo, 6 Colors, 6 Colors with Shapes, with Auto Grouping, without +Gradients and enhanced each leave a gap of 0.9 px (was 1.2) and an overlap of 1.0 px (was 0.8). Edge ΔE +of Shades of Gray on linear gradients goes from 8.36 to 8.40 (8.39 enhanced), and that of 6 Colors with +Gradients on a blurred input from 2.24 to 2.22; other ΔE and edge ΔE values change by 0.01 or less. +Lines % changes by −2 to +3, grouped % by −1 to +4, upright % by +1 in 3 rows (Shades of Gray on a blurred +input), and specks do not change. On synthetic photos of 800 and 4,000 px traced outside this harness, +the corners of rectilinear staircases per 100 px of outline fall from 0.57 to 0.46 and from 1.09 to 0.94 +with High Fidelity Photo. On the 800 px photo, the 95th percentile of the pixel difference with High +Fidelity Photo rises from 6.33 to 6.67, and 16 Colors leaves 2 staircases of steps of 2 px or more where +it left none. The tables below are the previous measurement, not a rerun. +Tests on code-drawn outlines in `subpixel::tests`: +`a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps` (a piece with a +staircase of 2 × 3 px steps in a smooth area, a straight run and the same staircase on a sharp edge: the +first gives its steps' middles and none of its corners, the straight run keeps the corners at its ends, the +second staircase keeps its corners, both regions get the same points, and 10 px steps keep their corners +in a smooth area), `a_staircase_that_runs_on_from_a_smooth_area_into_an_edge_keeps_the_edges_corners`, +`a_staircase_whose_cracks_do_not_read_as_a_contour_keeps_its_corners` (two flat colors), +`a_staircase_step_moves_by_the_mean_shift_of_its_cracks`, +`a_staircase_of_mixed_one_pixel_and_longer_steps_is_one_slope_without_a_notch`, +`a_run_of_two_steps_keeps_its_corners`, `a_junction_on_a_step_ends_the_staircase_there` and +`a_step_whose_cracks_read_as_an_edge_ends_the_staircase_there`. ## A simpler contour walk `contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the right. It used to build a table of outgoing directions for every pixel corner in a raster pass and diff --git a/docs/localization-hi.md b/docs/localization-hi.md index c130a7b088..d8dbcaf145 100644 --- a/docs/localization-hi.md +++ b/docs/localization-hi.md @@ -25,6 +25,13 @@ Hindi integer counts use the first plural form for 0 and 1 and the second for 2+ Both `@plural` and `@msgplural` rows have two forms. The latter translate status messages when displayed; the engine, control channel and MCP continue to return English messages. +In diagnostics, "a number of points" is a numeric value in the point unit: write संख्या and +पॉइंट, never कई अंक ("many digits"). Keep every bound and alternative the English lists, and +every clause such as "all" or "what follows it". Type as a text object is टाइप (एरिया टाइप, +पॉइंट टाइप), not प्रकार, which stays for a kind. `hindi_rows_use_the_glossary_term_for_what_their_english_names` +in `crates/ui-egui/src/i18n/tests.rs` checks the save, swatch, crop, resolution and path terms +and the point unit in every row. + ## Terminology | English | Hindi | @@ -42,6 +49,7 @@ displayed; the engine, control channel and MCP continue to return English messag | Fill / Stroke | फ़िल / स्ट्रोक | | Gradient / Mesh / Blend | ग्रेडिएंट / मेश / ब्लेंड | | Pattern / Swatch | पैटर्न / स्वॉच | +| Type (text object) / Crop / Resolution | टाइप / क्रॉप / रिज़ॉल्यूशन | | Appearance / Graphic style | अपीयरेंस / ग्राफ़िक स्टाइल | | Symbol / Envelope | सिंबल / एन्वेलप | | Clipping mask / Opacity mask | क्लिपिंग मास्क / ओपेसिटी मास्क | @@ -61,6 +69,10 @@ The draft has 7,877 unique rows, including 2,537 `@msg`, 45 `@msgplural` and 38 Its source set combines the sibling catalogs with the current test scanner's UI/undo and error message dumps, so it includes runtime labels outside the literal scanner. Row count is not a percentage of the application's entire interface. +On 2026-11-13 the catalog has 8,732 rows, including 3,003 `@msg`, 47 `@msgplural` and 41 +`@plural` rows. A wording pass on that date corrected 193 rows outside the plug-in +messages for meaning, dropped clauses, English left in place and the save, swatch, crop, +resolution, path and type terms. It is not a native proofread. The comparison covers release `v0.21.1` (`f6aa4309`) and current `main` (`3c7d9e1f`). Their non-English catalogs have no missing Hindi keys except the names deliberately kept in English. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0df4d55d6f..db3d26f7ce 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -29,7 +29,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 7,877 rows (2026-11-06): 2,537 `@msg`, 45 `@msgplural`, 38 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | +| Hindi | `hi` | 8,732 rows (2026-11-13): 3,003 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | diff --git a/docs/mcp.md b/docs/mcp.md index 41e28969f4..6800d7d7e5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # VectorCraft MCP server -> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. +> **Last updated:** 2026-11-13 · **Change:** Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. Previously: Save location-refusal error (#6146); existing response shapes retained. `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to @@ -630,7 +630,7 @@ sampler. | `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `set` cannot change a `__` field other than the dialog's controls held under a `__` name (such as Save PDF's `__section` or the Preferences `__search`): the `__command` and `__params` of a `confirm` or `command` dialog, and every other `__` field that holds what OK acts on, are refused with "the `` dialog cannot set protected field ``", so OK runs what the dialog was opened for. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | -| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | +| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. An app that does not send the image back writes a window capture without `path` to a file in a new folder inside the system's temporary folder (on Unix only the user can open it); the server reads the file and then removes the folder, also when the capture or the read fails. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PICT (`.pct`/`.pic`/`.pict`, and a version 2 picture of any extension), HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `_.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | @@ -640,7 +640,7 @@ sampler. | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | -| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | +| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | For example, create editable Japanese area type with the engine's vertical writing mode: @@ -758,9 +758,31 @@ designs keep their own paint. A row that had no paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do -not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and -`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store -them. +not store a series' paint. + +`appearance.addFill`, `appearance.addStroke`, `appearance.setItem`, `appearance.removeItem`, +`appearance.duplicateItem`, `appearance.moveItem`, `appearance.clear`, `appearance.reduceToBasic`, +`appearance.showAllHidden`, `appearance.transfer` (as its `target`), `graphicStyle.apply`, and `transparency.set` with a +fill or stroke `item`, given a series group's id or the selected series group, edit the paths directly in the group as +if each had been given, and the group keeps no rows of its own. A series group with no path directly in it, for example +one drawn with a column design, and a series group that has a fill or stroke row of its own, as a document saved by an +earlier version can hold, are edited themselves by these commands, and `appearance.moveItem` with `from: "contents"` +moves the group's own Contents row. `appearance.clear`, `appearance.reduceToBasic`, `appearance.showAllHidden` and +`graphicStyle.breakLink` edit the group as well as its paths. `graphicStyle.apply` without `add` and +`appearance.transfer` onto the group clear the group's own effects, opacity, blend mode, isolation, knockout and style +link, and `graphicStyle.apply` with `add` unlinks the group. With `appearance.transfer`, a `source` that is one of the +target group's paths keeps its look on a move, and a `source` that is the group's only path is an error. +`graphicStyle.redefine` gives the style to the paths directly in a series group linked to it, whether or not the group +has rows of its own, links them, and clears and unlinks the group; the source and the paths linked to the style whose +look was edited keep their look. Read as a source, by `graphicStyle.new`, `graphicStyle.redefine` or +`appearance.transfer`, a series group that has no fill, stroke, effect, opacity, blend mode, isolation, knockout or +style link of its own gives the look and the style link of the topmost path directly in it, and a move with +`appearance.transfer` clears each of those paths; a series group that has one of them is read itself. + +The other commands given a series group's id edit the group itself, and the graph does not store what they set on it: +`transparency.set` and `object.setProps` with an opacity or blend mode for the object, the `effect.*` commands and +`appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the +group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that @@ -969,7 +991,11 @@ SVG writes one file per artboard of a `range`); `useArtboards: false` exports th its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and `range`. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it -is in VectorCraft Default), the export comes back with a warning that says so. +is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with +`useArtboards: false` writes that one page too, without the native document and without the editing copy for other +apps (`editingData`), and always comes back with the warning `Preserve editing was left out: an .ai file of the art's +bounds would replace the document's artboards when reopened`; with `pdfCompatible: false` it is refused, whatever +`editingData` says. A legacy version 7 `.ai` (`aiVersion: "7"`) of the art's bounds is written as before. Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no Artboards section and the page is the bounds of the visible art. @@ -1036,7 +1062,8 @@ and `flattener` options over it (as `object.flattenTransparency` takes them); im transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless `includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. -A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, +In PDF/A-2b files, `.ai` saves and files written with `longPagesInPoints: false`, a page with a side longer than +14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2). A PDF 1.5 file with such a page is written as PDF 1.6, with a warning, and keeps PDF 1.5's encryption (128-bit RC4). A PDF/A-2b file at PDF 1.4 with such a page is written as PDF 1.6 too, with a @@ -1045,9 +1072,10 @@ unless the page is too narrow for that (below) ([veraPDF rule 6.1.13-11](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-6113-11); PDF/A-2 files may declare PDF 1.0 to 1.7, [rule 6.1.2-1](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-612-1)). PDF/X files and other PDF 1.3 and 1.4 files write such a page in points, and apps -that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at +that keep to the limit cut it to 200 in. `longPagesInPoints` (on by default; "Write pages longer than 200 in at full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without -a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. The option applies +a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. Readers that +ignore `UserUnit` show such a page at its full size. The option applies only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13), a PDF/A file at PDF 1.4 included (written as PDF 1.6). The dialog disables the @@ -1434,8 +1462,10 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR start, after the profiles above are registered (#6335). Saved settings that name a profile that is no longer there, or a damaged file, leave the defaults (logged), and the next change writes over the file. `edit.assignProfile` tags the document only: colors show and convert through the profiles it assigns, and the - working spaces and the file stay as they are. The `workingCmyk` target of `view.proofSetup` simulates the working - CMYK space in such a document too. + working spaces and the file stay as they are. The profiles in use are those of the document in front, after + `file.new`, `document.open`, `document.activate`, `file.close`, `file.revert`, `edit.undo`, `edit.redo` and a + failed `command.batch` too; with none assigned, or no document open, the working spaces are. The `workingCmyk` + target of `view.proofSetup` simulates the working CMYK space in such a document too. Not remembered: the document, anything else. Nothing is read or written when `VECTORCRAFT_NO_PREFS` is set, and a server confined with `--automation-read-root` or @@ -1777,7 +1807,7 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd ### Parameter schemas Some commands have a machine-readable parameter schema: at present a small set including `view.capsLock`, `view.saved.new`, -`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference` +`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options`, `view.snapToPixel.options` and `engine.apiReference` (most of the catalogue still documents params in prose only; schemas grow command by command). The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 (https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the @@ -1830,9 +1860,10 @@ as the English gives them. An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests -more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 4 (`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Level 2 added -`engine.apiReference`; level 3 says that `rotation` in `view.saved.new` and `view.saved.list` turns clockwise. Commands that plug-ins add have no schema here. +`engine.apiReference`; level 3 says that `rotation` in `view.saved.new` and `view.saved.list` turns clockwise; level 4 +adds `view.snapToPixel.options`. Commands that plug-ins add have no schema here. `engine.apiReference {}` → `{markdown}` returns the command API reference, written from these schemas each time it runs; no file of it is kept. It needs no open document and changes nothing. The reference has a `##` section for each @@ -1947,8 +1978,10 @@ app through the global object `vc`, and writes to the Script Console and runs ot file). Its preprocessor lines are read as the script's are. `timeout` is a number of milliseconds from 0; the file is read at once and `timeout` is not waited on. The run's fuel and limits apply to the file. An empty path, a missing file and a refused path throw errors the script can catch. -- `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a - script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. `$.engineName` is +- `$.fileName` is the file of the code that reads it: the script, a file it included with `#include`, or a file + `$.evalFile` ran, also when a function that file defined is called after `$.evalFile` returned. It is empty in code + from a script given as `source`. In code that `eval` or `Function` compiled it is the file being run, which is the + file `$.evalFile` is running while it runs. `$.includePath` is the current `#includepath` list, separated by `;`. `$.engineName` is the name `#targetengine` gave the engine the script runs in (`VectorCraft` without the line) and is read-only. - `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])` open a dialog, described below. `vc.api.has("dialogs")` is true. @@ -2018,7 +2051,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: | `global` | the global object | | `hiresTimer` | the microseconds since the run last read it, or since the run started; `0` on the web | | `level`, `memCache` | `0`; setting `memCache` throws | -| `line`, `stack` | the line of the code that reads it; the running code, outermost first, one a line as `name:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name), at most 64 lines | +| `line`, `stack` | the line of the code that reads it, counted in the file that code is in (the script, a file it included or a file `$.evalFile` ran, in this run or an earlier run of its named engine); the running code, outermost first, one a line as `name@file:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name; the file as `$.fileName` gives it, or the run's name for a script given as `source`), at most 64 lines. Code that `eval` or `Function` compiled keeps the line of the source they compiled, in the file `[eval]`; code whose place a named engine no longer keeps is at line `0` of `[unknown]` | | `locale` | the interface's language as a locale such as `de` or `pt_BR` (`en` when the interface follows the system); a script can set `ll` or `ll_RR`, and `null` or `""` sets the interface's again | | `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | | `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | @@ -2092,6 +2125,9 @@ no engines, and the engines it left are dropped when their own thread next uses for 8 MiB of source maps, the oldest going first, and a run whose script does not compile keeps none. An error in code whose place is gone, or in code that `eval` or `Function` compiled, is reported without a line and column, and the code still runs. +- `$.fileName` in code an earlier run compiled names the file that code is in, by the same places: the earlier run's + script, a file it included or a file its `$.evalFile` ran, not the calling run's file. It is empty in code from an + earlier script given as `source`, even when that run had a name, and in code whose place is gone. - `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, @@ -2473,8 +2509,8 @@ them again with the key object. When the call changes nothing, it records no und Other `object.setProps` calls leave the selection unchanged. `object.unlockAll` and `object.showAll` unlock or show every locked or hidden object in the document and select -them → `{count}`. In isolation mode they take only the objects inside the isolated object. With `scope: "group"` they -take only the objects inside the groups that hold the selected objects: +them → `{count}`. Ruler guides locked or hidden on their own are counted, and are selected as guides. In isolation +mode they take only the objects inside the isolated object. With `scope: "group"` they take only the objects inside the groups that hold the selected objects: for each selected object, the group nearest above it, or the object itself when it is a group. The objects of a group inside another such group are counted once. With no selected object in a group, the call returns the error "nothing selected is in a group" and changes nothing. `scope: "document"` is the default, and any other value is @@ -2672,6 +2708,43 @@ kept with the workspace, and built-in workspaces and workspaces saved without it {"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} ``` +## Docking toolbars made with New Toolbar + +A toolbar made with New Toolbar floats when it is made. `window.toolbar.dock {name, side?, order?}` (Dock to Left and +Dock to Right in the context menu of the toolbar's title bar, remote mode, `invoke_menu`) docks it in a lane along the +window's left or right edge. On the left, the lane is right of the Tools panel when the Tools panel is docked at the +window's edge. With Keep Tools Beside the Canvas on and the Tools panel docked on the left, the lane is right of the +Tools panel beside the canvas, and dropping a title bar on the Tools panel or on the lane docks the toolbar there. `side` is `left` or `right` (`start` and `end` also work); without it, the toolbar docks at the edge it +docked at last, or at the left the first time. Toolbars docked at one edge stack from the top. `order` is the +toolbar's place in that stack, 0 at the top, from 0 to the number of other toolbars docked there; without it, the +toolbar goes at the end, or stays where it is when it is already docked at that edge. Each toolbar takes an equal +share of the lane's height and its tools scroll in it, and the lane is as wide as its widest toolbar. When the lane +is too short to give each toolbar 88 points of height, the stack wraps into more columns, left to right, and the lane +widens, so no toolbar covers another. A lane takes at most half the width the canvas and panels would have (beside +the canvas, it also leaves the canvas at least 200 points, and the canvas keeps room for one column); when that +many columns still give a toolbar less than 88 points, the toolbars in those columns show only their title bars, which +still float, dock and show the context menu. A column too short for all of its title bars shows as many as fit, and +the mouse wheel over the column scrolls through the others, one toolbar per notch. +`window.toolbar.float {name, x?, y?}` (Float Toolbar in the same menu) floats it with its top-left corner at `x`, `y` +in screen points, or where it floated last when both are omitted. Both commands answer +`{toolbar, floating, side, order, pos}`: `side` is the edge the toolbar docks at (while it floats, the edge it docked +at last), `order` its place in that edge's stack (null while it floats) and `pos` where it floats (while it is docked, +where it floated last). A missing or unknown name, a `side` other than left or right, an `order` that is not a whole +number in range, or only one of `x` and `y` is an error and changes nothing. `window.toolbar.list` reports `floating`, +`side` and `order` for each toolbar. + +With the pointer, dropping a floating toolbar's title bar on the window's left or right edge, or on that edge's lane, +docks it at the end of that edge's stack; the target lights up while the title bar is dragged over it. Dragging a +docked toolbar's title bar out of its lane floats it under the pointer. Escape, or the window losing focus, during +the drag puts the toolbar back where it was. Hiding a docked toolbar keeps its place, renaming it keeps its place, a +copy made with Manage Toolbars' + floats, and deleting one closes up the stack. Placement is kept with the workspace. +Toolbars saved before docking existed load floating. The lanes, the edge targets and the stacking are VectorCraft's +own layout rules. + +```json +{"name":"invoke_menu","arguments":{"command":"window.toolbar.dock","params":{"name":"Toolbar 1","side":"left","order":0}}} +``` + ## Learn panel Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide @@ -2683,13 +2756,34 @@ toggles it (`show` omitted or null), and answers whether it shows; Window › Le The panel counts as hidden while the dock is hidden or a full screen mode hides the panels. Showing it shows a hidden dock; in a full screen mode it makes Learn the front panel, which shows when the mode ends, and answers false. Any other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab -lists them, where `steps` is the number of steps. `learn.show {tutorial}` shows the panel on its Skills tab with that +lists them, where `steps` is the number of steps. `learn.skills {}` shows the panel on its Skills tab with the list of +skills, with no skill and no tutorial open, as the Quick skill guide card and Back to skills do, and answers what +`learn.list` answers. `learn.show {tutorial}` shows the panel on its Skills tab with that tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error -and changes nothing. Behavior reference: +and changes nothing. + +Under a tutorial's steps, View more opens the tutorial's practice sample, a small drawing of VectorCraft's own with the +art its steps work on, as a new untitled document that becomes the active one; Save asks where to save it. Once the +sample is open, Finish tutorial marks the tutorial finished. A finished tutorial shows Next Up with the title of the +next tutorial in the same skill, which Next Up shows without opening its sample. A skill's last tutorial, finished, +says so and shows Back to skills instead; Next Up never moves to another skill. Which tutorials are finished is kept +while the app runs, and the skill rings and counts do not show it yet. These are VectorCraft's own rules; the help +page also plays a short video, which VectorCraft does not have. `learn.openSample {tutorial}` runs View more → +`{tutorial, index, title}` of the new document, and moves the Learn view to that tutorial; each call opens another +copy and leaves the open documents as they are. `learn.finish {tutorial}` runs Finish tutorial → +`{tutorial, finished: true, next}`, where `next` is the next tutorial's id in the skill, or null after the skill's +last one; the tutorial's sample must have been opened, and finishing it again keeps one entry. `learn.next {tutorial}` +runs Next Up on a finished tutorial and shows the panel on the next one → `{skill, tutorial, title}`; on a tutorial +not finished, or a skill's last tutorial, it is an error. For all three, a missing, non-string or unknown `tutorial` +is an error, and an error changes nothing. Behavior reference: [Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). ```json {"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.openSample","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.finish","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.next","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.skills","params":{}}} ``` A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the @@ -2721,12 +2815,22 @@ vertical guide, the y of a horizontal one, in points) → `{index}`. With `artbo artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is deleted with it (`artboard.delete`); without, a canvas guide runs across the whole canvas. `guide.list` → -`[{index, vertical, pos, selected, artboard?}…]` (`artboard` only for artboard guides). +`[{index, id, vertical, pos, selected, artboard?, layer?, hidden, locked, shown, editable}…]` (`artboard` only for +artboard guides; `id` is the guide's node and `layer` the layer it is on; `hidden` and `locked` are its own state, +`shown` and `editable` also take its layer into account). `layer.setProps {ids: [id…], visible?, locked?}` hides or +locks guides on their own, as the eye and lock on a guide's row in the Layers panel do; a hidden guide is not drawn +and does not snap, and a locked one is drawn and snaps but can't be selected, moved or deleted. `guide.select {indexes: [index…], toggle?}` selects guides on their own (the art is deselected; `toggle` adds or removes them) → `{selected}`. `guide.move {index, pos}` puts one guide somewhere, `guide.move {dx?, dy?, copy?}` moves the selected ones (vertical guides by `dx`, horizontal ones by `dy`; `copy` leaves them and selects the -moved copies), and `guide.remove {index?}` deletes one guide or the selected ones → `{count}`; each is one undo -step. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the arrow keys) nudges them. +moved copies), and `guide.remove {index?, indexes?}` deletes one guide, the guides `indexes` (not with `index`; an +index given twice counts once; a hidden or locked guide among them is an error and nothing is deleted) or the +selected ones → `{count}`; each is one undo step. An `indexes` list of `guide.select`, `guide.remove` or +`guide.setLayer` holds at most 10,000 entries; a longer one is an error. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the +arrow keys) nudges them. Object › Lock › Selection (`object.lock`) and Object › Hide › Selection (`object.hide`) lock +or hide the selected guides on their own, with the selected art, in one undo step, except in isolation mode. +`object.unlockAll` and `object.showAll` with the default scope, outside isolation mode, also unlock or show the +guides locked or hidden on their own and select them as guides. View › Guides › Lock Guides (`view.guides.lock`) deselects them and the paths made into guides, in every open document, and refuses these commands until unlocked. While it is on, a command that selects art, such as `edit.undo`, `edit.redo`, `object.exitIsolation`, `object.unlockAll`, `object.showAll` or `edit.paste`, leaves the guides out. @@ -2916,6 +3020,13 @@ already walks Image Trace. Short agent entry points: Tilt goes from its first value with the pen lying flat to its second upright, and on Bearing or Rotation from its first value at −180° to its second at 180°. A stroke without that input draws the brush's own value (a Scatter brush's value half way between the two). +- Freehand Fidelity: the `fidelity` of `path.freehand` and `brush.freehand` is in points, 1.5 pt when left out. A + value must be from 0.000015625 pt (0.01 / 640) to 100 / 0.0313 pt (about 3194.89 pt), and a value outside that range + is rejected with an error. The Pencil and the Paintbrush give Fidelity in screen pixels, 0.01 to 100 px, and send it + divided by the zoom (0.0313 to 640) and kept within that range, so a stroke is fitted at the same tolerance on screen + at every zoom. A stroke + that continues a path (`extend`) closes it when it ends within four times the Fidelity of the path's other end, at + least 6 pt and at most 400 pt. The Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), @@ -3016,15 +3127,18 @@ already walks Image Trace. Short agent entry points: the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns `{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON: - `{"format": "vcactions", "version": 1 to 5, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step + `{"format": "vcactions", "version": 1 to 6, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with `{message, allowContinue}`. A Select Object step is the command `select.byNote` with `{note, caseSensitive, - wholeWord}`, and a Select Path step is the command `select.storedPath` with `{id, fingerprint}`. `excluded: true` preserves a step but omits it from playback; - an absent or false flag includes the step. Files with Select Path steps use version 5, files with Select Object steps - and no Select Path step use version 4, files with stops and neither use version 3, and files with excluded steps and - none of these use version 2, so older builds refuse them instead of playing the excluded steps or failing at a stop, a - Select Object step or a Select Path step; other files use version 1, and version 1 to 4 files still load. A file with another `format` or a `version` other than - 1 to 5, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are + wholeWord}`, and a Select Path step is the command `select.storedPath` with `{id, fingerprint}`. A View menu step is + the id of a View menu command that the app runs itself (`view.outline`, `view.zoomIn`, `view.rotate` and the others + `ui.action.insertMenuItem` lists) with the item's params. `excluded: true` preserves a step but omits it from playback; + an absent or false flag includes the step. Files with View menu steps use version 6, files with Select Path steps and + no View menu step use version 5, files with Select Object steps and neither use version 4, files with stops and none + of these use version 3, and files with excluded steps and none of these use version 2, so older builds refuse them + instead of playing the excluded steps or failing at a stop, a Select Object step, a Select Path step or a View menu + step; other files use version 1, and version 1 to 5 files still load. A file with another `format` or a `version` other than + 1 to 6, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a View menu step whose params are not an object, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is @@ -3063,7 +3177,9 @@ already walks Image Trace. Short agent entry points: action set would be larger than 8 MiB`): it does not run, and the recording goes on. A batch (`command.batch`) is such a command too. A command whose step grows past a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands do not run, and - `ui.action.stop` leaves its step out. A stop that playback reaches + `ui.action.stop` leaves its step out. So does a `script.run` whose commands, or a tool's or panel's edit whose + step, takes the recording past a limit: the status bar names the limit when the run or the edit ends, and + `ui.action.stop` keeps the steps that fit. A stop that playback reaches then is not recorded, and the status bar shows the reason. `ui.action.stop` leaves out any recorded steps still past either limit and shows which one in the status bar. - Actions: `ui.action.options {set, action, name?, key?, cmd?, shift?, color?}` changes the action at @@ -3089,7 +3205,12 @@ already walks Image Trace. Short agent entry points: here and in Batch…. An action with an included stop plays up to the stop at any speed and then waits, with its message box open, until `ui.action.continue` or `ui.action.stop`. At Accelerated the steps between two stops are one undoable batch each. The result is then `{results, pending, atStop?}`, with `atStop: true` when the action already - waits. Batch… refuses an action with an included stop, and a stop that is excluded does not hold playback. + waits. Batch… refuses an action with an included stop, and a stop that is excluded does not hold playback. A View + menu step (`ui.action.insertMenuItem`) plays at every speed in its place among the steps, as its menu item runs, and + adds no undo step. At Accelerated the steps before it and the steps after it are one undoable batch each, and the + result is `{results}` with every step's result in order. A step that fails ends the playback with its error: the + batch it is in changes nothing, and the steps before that batch stay done. While another action records, the View + menu step is recorded too. Batch… refuses an action with an included View menu step. - Actions: `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}` inserts a stop. With `set` and `action`, the stop goes into that action, after step `step`, else after its last step, and the command returns `{set, action, step}`. Without them it goes into the recording in progress, after the steps recorded so far, and @@ -3103,11 +3224,17 @@ already walks Image Trace. Short agent entry points: (`message`, `allowContinue`, `set`, `action`, `step`), whose OK runs this. - Actions: `ui.action.insertMenuItem {set?, action?, step?, command, params?}` inserts a menu item's command as a step without running it. `command` and `params` (default: `{}`) must be those of an item in the menus that can be a step: - an item that runs a document command without opening a dialog first, outside the File, Window and Help menus, and - not Undo, Redo, a Paste command, Object › Align (its items add the Align To choice when clicked), Object › Image + an item that runs a document command, or a View menu item that the app runs itself (Outline, Pixel Preview, Trim + View, Presentation Mode, Screen Mode, the zoom, fit and Rotate View items, and the show, hide and snap items), without + opening a dialog first, outside the File, Window and Help menus, and not Undo, Redo, a Paste command, Object › Image Trace › Make or Make and Expand (the menu traces off the UI thread, with progress and Cancel), Object › Pattern › Make or Edit Pattern (the menu also opens the Pattern Options panel), a preference - toggle, an Actions command or a recent-font, saved-selection or saved-view slot. Disabled items are listed, and playback checks the step when it runs. With `set` and `action`, the + toggle, an Actions command, a Perspective Grid preset of the preferences or a recent-font, saved-selection or saved-view slot. Disabled items are listed, and playback checks the step when it runs. An Object › Align item's step + keeps the `to` its click sends at insertion (`key` while the selection has a key object or key guide, `artboard` for one selected + object or with Align to Artboard, else `selection`), so a later Align To choice does not change it, and a step that + aligns to the key object fails in playback when the selection has no key then. With anchor points selected the click + sends no `to`, and neither does the step. These are VectorCraft's own rules. A + View menu step plays as `ui.action.play` describes. With `set` and `action`, the step goes into that action, after step `step`, else after its last step, becomes the panel's selected step, and the command returns `{set, action, step}`. Without them it goes into the recording in progress and returns `{recording: true}`. A command no such item runs, params that are not an object, an index past the last set, action @@ -3528,12 +3655,16 @@ effect of each selected object, in one undo step: `face` is `front` (default) or offset in points of the graphic's center from the center of the object's bounds in the face's plane, `scale` is a percentage of the symbol's size (1..10000; omitted, 100 or less so the graphic fits inside the object) and `rotation` is in degrees counterclockwise (-360..360). Without `index` each object's first 3D effect takes it, and `item` is refused. -Every object's 3D effect must be Extrude & Bevel with Cap on (front only at a depth of 0) or Rotate (the Plane type, -front only), and `symbol` must name a document symbol; otherwise the command is refused and nothing changes. It returns `{ids, graphic}`. +Every object's 3D effect must be Extrude & Bevel with Cap on (front only at a depth of 0), Rotate (the Plane type, +front only) or Revolve (front only: its swept side), and `symbol` must name a document symbol; otherwise the command is refused and nothing changes. It returns `{ids, graphic}`. +On Revolve, `x` and `y` are measured on the side unrolled flat from its middle, and a graphic that does not fit +starts scaled to fit the half of the side seen from the front ([three-d.md](three-d.md#graphics)). -The graphics are the `graphics` parameter of `threeD.extrude` and `threeD.rotate`, which `effect.setParams` also +The graphics are the `graphics` parameter of `threeD.extrude`, `threeD.rotate` and `threeD.revolve`, which `effect.setParams` also edits: an array of up to 64 `{symbol, face, x, y, scale, rotation}` objects, drawn on the face turned, foreshortened and shaded with it and clipped to it ([Map artworks on 3D object](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/map-artwork-on-3d-objects.html)). +Revolve takes graphics on its swept side and shades them with the object's light ([the archived reference, page 185](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185)); +how a graphic wraps around the side is VectorCraft's own rule. A graphic naming a symbol the document does not have is refused, unless the effect already holds a graphic of it. The drawing rules are VectorCraft's own; see [three-d.md](three-d.md#graphics). @@ -3769,6 +3900,32 @@ excluded take the palette as their new colors in row order, the colors repeating {"name":"run_command","arguments":{"command":"recolor.pickTheme","params":{"picks":[{"at":[120,80]},{"rect":[200,40,320,140]}],"colors":4}}} ``` +`recolor.weigh {map, weights?, ids?}` is the dialog's Prominent Colors bar. It takes rows as `recolor.apply` does and +returns them as `map`, each with `from` as a list, and `shares`: for each row, its percent of the uses (the `count` of +`recolor.colors`) of the current colors of the rows that are not excluded, and 0 for an excluded row. A use counts for +the row that `recolor.apply` recolors it with (rows with a new color first, an exact color before a hex color, then +the first row that lists it), and otherwise for the first other row that is not excluded and lists it. A current +color that no art uses counts nothing. Without `weights` the +rows come back unchanged. `weights` holds one number of 0 or more per row; an excluded row's weight is ignored, and +at least one other row needs a weight above 0. As VectorCraft's own rule, current colors then move one at a time +between the rows that are not excluded and have a new color (`to`), as long as a move brings each row's share of the uses nearer its share of the +weights. Of those moves, it takes first the color nearest in Lab to the first color of the row it goes to, compared +with the row it leaves. A row keeps its first color, and the tints of one global swatch in a row move together. A +color that the first color of another row with a new color also lists stays where it is. When a color moves, other +rows with a new color drop their own entries for it, so `recolor.apply` recolors it with the row it moved to. The +colors that can move are compared with the rows at most 50,000,000 times in all; when those colors times the rows +that are not excluded pass that, no color moves. Colors that stay in a row keep their order, and the colors that come +to it follow them. The command changes nothing; `recolor.apply` with the returned `map` recolors the art. In the Recolor Artwork dialog the +bar sits under the Edit tab's wheel: each row with a share is a part as wide as that share, in the row's new color, +the largest first. Dragging the edge between two parts moves width between them, and when the drag ends the widths +become the `weights` field, which moves the rows' current colors and previews the result. The New Color Group menu +beside the link button runs `swatch.newGroup` right away, as its own undo step: Save All Colors saves the new colors +of the rows that are not excluded, in row order, and Save Prominent Colors saves the bar's colors in the bar's order. + +```json +{"name":"run_command","arguments":{"command":"recolor.weigh","params":{"map":[{"from":["rgb 255 0 0","rgb 128 0 204"],"to":"#00ff00"},{"from":["rgb 0 0 255"],"to":"#ffff00"}],"weights":[1,1]}}} +``` + The `recolor.*` commands refuse parameters of the wrong form. A `limitTo` or `method` that is not a string gets the error for an unknown library or method. Other wrong-form parameters get an error that names them. `includeImages`, `includePatterns`, a row's `exclude`, `order`, `saturationBrightness` and the `white`, `black` and @@ -4306,6 +4463,13 @@ and `layerRows`, the rows highlighted in the panel. draws and is clicked in outline, Ctrl/Cmd-click its eye), `dimImages` (0–100, or false) and `color` (an index 0–26, `#rrggbb` or a preset name; a hex color with an alpha part, `#rrggbbaa` or `#rgba`, is an error, and so it is in `layer.new` and `layer.newSublayer`). Several rows change in one undo step (a drag down the eye or lock column). + A ruler guide's row has its own eye and lock: `ids` may name ruler guides (their `id` in `guide.list`), which take + `visible` and `locked`. They can be changed while the guide's layer is hidden or locked, and the guide shows + or can be edited only when its layer allows it as well. Alt-click on a guide row's eye or lock acts on the other + layers, as on any row in that layer. A click on a guide row selects the guide (`guide.select`) and clears the + highlighted rows (`layer.highlight {ids: [], mode: "set"}`), so Delete then runs `edit.clear` on the guide; with no + row highlighted, the trash button runs `edit.clear` on the selected guides and art, and guide rows dropped on the + trash are deleted (`guide.remove {indexes}`). - **Dragging rows**: `layer.move {ids, target, place?: above|below|inside, copy?}` moves rows beside a row or into a layer or group (on top of its contents), keeping their stacking order; `copy` (Alt-drag) moves copies. Layers go only in layers or at the top level and objects in layers and groups (an object placed beside a top-level layer goes @@ -4603,7 +4767,9 @@ and those of the object shape the outline of the clip group, the gradient fits t box, as the canvas paints the fill; the expanded art does not keep them. With `fill: false` gradient and pattern fills stay live. `object.expand.info` answers which options have something to expand in the selection (`{object, fill, stroke, gradient, appearance, symbolArt?}`; `gradient` says whether Fill has a gradient fill, which Expand -Gradient To applies to; `appearance` whether the selection has appearance attributes Expand Appearance expands first, +Gradient To applies to, that it turns into art: it is false when every such fill repeats or reflects in more than 256 +runs across the box it fills, which for type, symbol art included, is the box of each outline Object makes of a character, +ruby character, underline or strikethrough; `appearance` whether the selection has appearance attributes Expand Appearance expands first, for which Object → Expand… is dimmed in the menus; `symbolArt: {fill, stroke, gradient}`, present when the selection holds symbol instances that `object` expands, whether only their art holds it, which Fill and Stroke reach only with `object` on), and `object.mesh.create` on a gradient-filled object colours the @@ -4923,9 +5089,10 @@ round, such as a rectangle, stays as it is. Closed paths stay closed and open pa {"name":"run_command","arguments":{"command":"object.path.smooth","params":{"level":80}}} ``` -In the desktop app, Object › Path › Smooth opens the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog -`smoothSlider` with field `level`) at the Auto-Smooth level and previews the result until `ui.dialog.confirm`; -`ui.smoothAuto` moves the open slider back to the Auto-Smooth level. +In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a +path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at +the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open +slider back to the Auto-Smooth level. ## Editing a path with the Curvature tool @@ -5381,8 +5548,12 @@ the selected artboards (those the Artboard tool has selected, else the active on `artboard.fitToSelection` calls it runs without `index`. Both include MCP's `run_command` calls in remote mode. The Artboards panel menu's Fit to Artwork Bounds also passes the selected artboards. A batch passes its `artboard` on to each of their steps that names no `index`, as the step's `index`. When the document a step runs in has no artboard at -that index, the step gets that document's last artboard. An action recorded in the Actions panel leaves `index` and -`indices` out of these steps, and they resize the artboard that is active when the action plays. +that index, the step gets that document's last artboard. An `artboard.fitToArt` step that names neither `index` nor +`indices`, while the Artboard tool is chosen with several artboards selected when the step runs, gets those artboards +as its `indices` instead, and the batch's journal entry records them, so a replay of the journal fits the same +artboards. A step's own `index` or `indices` is kept. An action recorded in the Actions panel leaves `index` and +`indices` out of these steps: when the action plays, Fit to Artwork Bounds resizes the artboards the Artboard tool then +has selected (two or more), else the active artboard, and Fit to Selected Art resizes the active artboard. ```json {"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","to":"artboard","artboard":1}}} @@ -5644,6 +5815,23 @@ know or a value that isn't a boolean is refused and changes nothing. {"name":"run_command","arguments":{"command":"view.snapToGlyph.options","params":{"angular":false,"anchors":false}}} ``` +The Pixel Snapping Options (the desktop app's Control bar › Snapping menu › Pixel Snapping Options…, described on the +help page, +): +`view.snapToPixel.options {drawing?, moving?, +scaling?}` (or `prefs.get`/`prefs.set` with key `pixelSnap`, a partial object) reads and sets which edits View › Snap to +Pixel lands on whole pixels. All three are on by default, which is VectorCraft's own default. `drawing` covers the +points the drawing tools place, the rulers' origin and measured points; `moving` covers the selection, its anchors and +segments, artboards and ruler guides moved by dragging, and the Rotate, Reflect and Shear tools' drags; `scaling` +covers the edges a bounding-box, Free Transform, artboard or slice handle drags, and the Scale tool's drag. An edit +whose option is off snaps as it does with Snap to Pixel off. With Snap to Pixel off, the options do nothing. `{}` reads +them; a key it doesn't know or a value that isn't a boolean is refused and changes nothing. Align New Objects to Pixel +Grid and the objects' pixel-aligned property (see Align to Pixel Grid) do not depend on these options. + +```json +{"name":"run_command","arguments":{"command":"view.snapToPixel.options","params":{"scaling":false}}} +``` + With Smart Guides on, a bounding-box handle dragged with the Selection or Free Transform tool (`mods.shift` proportional, `mods.alt` from the centre) lands on another object's anchor or centre (labelled "anchor" or "center"), else its corners and side line up with the edges and centres of the other objects and the artboards (a @@ -5871,8 +6059,8 @@ Preferences. `prefs.reset` resets the preferences at once, without the question. `prefs.export {path?}` writes the preferences as a JSON file, `{"format": "vectorcraft-preferences", "version": 1, "prefs": {…}}`, and replies `{path, count}`; without `path` it replies `{data, count}` with the file's text. The file -holds every preference `prefs.list` lists and the `eyedropper`, `perspectiveWidget`, `pathfinder` and `glyphSnap` -groups. It leaves out the folder preferences (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) and +holds every preference `prefs.list` lists and the `eyedropper`, `perspectiveWidget`, `pathfinder`, `glyphSnap` and +`pixelSnap` groups. It leaves out the folder preferences (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) and saved presets. An export over 1 MB, the most `prefs.import` reads, is an error and writes nothing. `prefs.import {path | data}` applies such a file and replies `{imported}`, the number of preferences it set. It checks every value first, as `prefs.set` does: a file over 1 MB, malformed JSON, another `format`, a newer @@ -6560,8 +6748,8 @@ path isn't followed) when it isn't. The desktop app also checks the active docum comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. -`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as -one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other stored object linked to the same file as +one of them, in the layers, opacity-mask art, symbols, graph designs and patterns; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in `path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, `path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, `path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are @@ -6569,8 +6757,10 @@ errors, and nothing is relinked. Each call is one undo step. The Links panel's R (`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed -documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for -those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it +documents inside symbols, graph designs, patterns and opacity masks that show the same content. Those objects have ids +of their own: `links.list`, `links.check`, `links.findFiles`, the `missingLinks`, `modifiedLinks` and `updatedLinks` +rows and the link watch list them, and every `links.*` command takes their ids. Opening a document gives an object +there whose id is 0 or already used by another object a new id, and the next save writes it. Saving a document also reads it again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be read", and `links.update`, or `document.open` when it reads the file, lists it as missing, its images keeping what they @@ -6810,8 +7000,11 @@ listed in `warnings`. Binary DXF and DWG files answer an error saying to save th ## Links panel `links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top -first, as the Links panel does: `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, -pixelHeight, path?, found?, page?, preview?, note?}` (`note`: the image takes more than the decode allowance, 2 GiB +first, as the Links panel does, then every image and placed document in opacity-mask art and in the art of symbols, +graph designs and patterns, one row per stored object (never one per symbol instance or pattern tile): `{id, name, +linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?, page?, preview?, note?, +owner?}` (`owner`, for an object outside the layer tree: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, +`maskOf` naming the object whose opacity mask holds it; `note`: the image takes more than the decode allowance, 2 GiB or 512 MiB in the web app, as RGBA or as decoded, and it is drawn at the largest size within the allowance ("shown at reduced size"; the document keeps the whole file) or, when it can't be read that way or has more pixels than the allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus @@ -6828,7 +7021,8 @@ bounds of another visible object that has one of those or a gradient with a tran a character's fill or stroke); a DCS image with transparency of its own counts as such an object. Bounds are cut to the bounds of the clipping paths of the clip groups and clipping layers holding an object. The check looks at every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error. -`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose +`links.goTo {id}` selects an image (`onCanvas: true`); for an object outside the layer tree it clears the selection and +returns `{id, onCanvas: false, owner}`, and the panel says the link has no place on the canvas. `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. `links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, @@ -9525,7 +9719,12 @@ outline. On a tie, the anchor met first along the outline in the order it was dr Without such an anchor, the corner is the outline's point nearest the box corner, moved onto the anchor at the end of its segment when within 0.5% of the segment's parameter. Each piece of a side between anchors and corners takes a share of the content's width or height in proportion to the length of its control polygon, spread along the piece by -its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file +its curve parameter, so content spreads evenly along a segment without handles. A side whose pieces, from corner to +corner, are all one cubic curve split at anchors (each within 0.02 pt of that curve's piece) follows that curve's +parameter instead, so splitting such a side's curve without changing its shape (Add Anchor Points, or a file that +saves a curve in pieces) leaves how the side shares out the content as it was. Corners found from the outline can +still move, since one at the outline's nearest point snaps onto an anchor within 0.5% of its segment's parameter. A +side only partly made of one curve keeps the polygon shares. An envelope opened from an `.ai` file keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them. A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and @@ -9777,8 +9976,9 @@ the Selection and Blend tools show the same values as widgets 16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets `colorShift` and the lower one, shown only when the steps follow the spine as `ramp` needs, sets `ramp`, by one per screen pixel dragged along the spine and by one per arrow key -during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool -during it puts the value back: +during the drag (ten with Shift). A blend made without a spine shows the upper widget alone, placed against the +straight lines between its key centers that its steps are laid out along, which are not drawn. Each drag is one undo +step, and `press_key` Escape or a switch to another tool during it puts the value back: ```json {"name":"run_command","arguments":{"command":"object.blend.setEasing","params":{"id":40,"ramp":35}}} @@ -9896,11 +10096,26 @@ order along the path changes: Space, the start, Pivot, Rotate and the children's new place along the path counted from 0. `object.objectsOnPath.selectMember {member}` takes the same kind of `member` and returns `{group, member}`. +`object.objectsOnPath.detach {member?}` takes one object off the path: `member`, or without it the object chosen with +the Select/Move widget of the one selected group (otherwise it fails with "choose the object to detach with its +Select/Move widget"). `member` takes the same kind of object as `moveMember`, and neither it nor the group may be +hidden or locked. The object leaves the group and goes just above it in the group's layer or group, staying where it +is drawn, and becomes the selection; when the group is itself an object on another group's path, the object goes just +above that outer group, so it does not join its path. The objects after it move up the path in their order: with Auto Space they spread +evenly again (on an open path the first and last on its ends while two or more are left), and a Space value and the +start are kept, drawn as close as the path has room for. Hidden and locked objects left in the group move up too. The +group, its path and its options stay even with one object or none left, and an object moved back into it joins the +path. It is one undo step, and undo puts the object back on the path with the choice it had. It returns +`{group, member}`. In the desktop app, Detach in the Properties panel's Objects on Path Options section sends it, and +is dimmed until one group is selected with an object chosen. To take several objects off at once, drag their rows out +of the group in the Layers panel (`layer.move`). + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.options","params":{"pivot":7,"rotate":15}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.options","params":{"spacing":24,"start":0}}} +{"name":"run_command","arguments":{"command":"object.objectsOnPath.detach","params":{"member":15}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.expand","params":{}}} ``` @@ -9913,8 +10128,12 @@ justified text gets even word spacing, ragged text (left, centre or right aligne that fits to the next line to avoid a lone short word on the last line or a full line next to a short one. Single-line fills each line as far as it goes. Point type and type on a path have no line width, so both composers give the same result there. A paragraph whose characters differ in line metrics (type size, font, leading or vertical -scale, or an inline graphic taller than the text) is composed with Every-line in rectangular area type, rows and -columns included; in area type of another shape, or with text wrap, it falls back to Single-line. +scale, or an inline graphic taller than the text) is composed with Every-line in area type of any shape, rows, +columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured +where the lines above it put it, across the line's own height, so a taller line that reaches where the frame is +narrower, or beside a wrap object, gets the room there. Such a paragraph falls back to Single-line when the frame has +no room for all of it, or when the search for its breaks passes VectorCraft's own limits (1,000,000 lines tried and +2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the paragraphs of a frame). The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true, shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set. @@ -11007,4 +11226,5 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). | | 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/plugins.md b/docs/plugins.md index 124cc76174..5284dee98f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -264,7 +264,13 @@ capability model proposed for PhotoCraft plug-ins hexadecimal digits covers nothing. `plugin.remove` forgets the grant, and so does a successful `plugin.install` of a module that lists no permissions under that id, so the earlier bytes need a new approval. When `plugin.remove` can't remove the grant from `grants.json`, it returns an error, the plug-in stays installed, and a second `plugin.remove` - tries again. A refused install leaves the grant as it is. When an install of a module that lists no permissions can't + tries again. A refused install leaves the grant, the plug-in installed with the id, the `Plug-ins` folder and the + list of disabled plug-ins as they are. When another session installs a plug-in with the id or changes its grant + while `plugin.install` checks a module, the install is refused, before anything is written to the `Plug-ins` folder, + if the other plug-in would need `replace: true` or the changed grant no longer approves the module. A + `plugin.remove` in another session waits until the install has written the `Plug-ins` folder, and then removes the + module and its grant. Loading the `Plug-ins` folder or a plug-ins folder in another session waits for it too. When + an install of a module that lists no permissions can't write the module to the `Plug-ins` folder, or can't remove the grant from `grants.json`, the folder keeps the module it held and `grants.json` keeps the grant. The new module runs until the session ends. When the grant can't be removed and the module already stored can't be read back or written back, the new module stays in the folder, and @@ -773,6 +779,11 @@ is a piece drawn by itself, in the order returned, with paints of its own: starts at 1 pt, miter join, butt cap, miter limit 10. - An effect on a fill (`_context.item` `{"fill": i}`) returns only `fills`, which replace that fill on each piece; an effect on a stroke returns only `strokes`. The object's other fills and strokes stay where they are. +- Each fill or stroke such an effect returns keeps the effects after it in that fill's or stroke's stack. A later + live effect, and a later geometry effect on strokes, runs once on each of them in paint order: it is given the + piece's `fills` and `strokes` with `_context.item` naming that one (`{"fill": 1}` for the second fill on the + piece), and its result replaces only that fill or stroke. A run that fails, is refused, is deferred or would pass + the budget leaves its own fill or stroke as it was, and the others still run. - The effects after it in the stack apply to the pieces together, the reference box being the bounds of all of them. Bounds, raster effects, Pathfinder effects, compound shapes and Envelope Options › Distort Appearance use the pieces as they are drawn. Outside outline mode, an image is also hit where the pieces of its own fills and strokes paint; a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 81a6ac6639..d22e48b959 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -510,7 +510,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | -| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | +| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812; distance and angle labels while drawing, #2983) | 2–4; status-bar X/Y uses the active ruler origin and General units | | File formats | 6 | 85% | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | @@ -569,6 +569,7 @@ since 2026-10-01. - **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged. +- **Selection: deletion on paths with many subpaths:** Clear/Delete, `path.deleteAnchors`, Copy, Cut and Cut Path read each subpath's selected anchors or segments directly from the path's ordered selection, instead of scanning every selected anchor or segment of the path once per subpath. The result is unchanged: `crates/engine/tests/it/delete_path_pieces.rs` checks exact surviving subpaths and their order, open and closed contours, an untouched subpath, references to missing subpaths and indexes, one Undo/Redo step and a second Delete, for anchor and segment selections on one path. Scores and hours unchanged. - **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. @@ -584,6 +585,7 @@ since 2026-10-01. **Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape. **Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it. **Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it. +**Drawing tools, Dimension tool points on a Smart Guide stay on the guide:** By VectorCraft's own rule, a clicked point that lies on a Smart Guide already, such as a point straight below the first one on the vertical construction guide through it, or one in line with an object's center, stays where the pointer is when no anchor is within 8 pixels and no segment is within the Smart Guides snapping tolerance, instead of moving onto a segment farther than that tolerance and within 8 pixels; with Snap to Grid or Snap to Pixel on, a point stays on the grid or the whole pixel those options give. `a_point_on_a_guide_stays_on_the_guide` covers it. **Drawing tools, Dimension tool dimensions selected with their art keep their lines and label:** Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave a dimension selected with art it measures, directly or in selected groups without fills, strokes or effects of their own, as it is and change only the other art; when the new art no longer fits its link, the dimension stays where it was, no longer linked, and undo links it again. `expand_leaves_a_dimension_selected_with_its_art_as_it_is`, `expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is`, `outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is`, `a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing`, `a_group_effect_expanded_reaches_the_dimension_it_holds`, `expand_keeps_a_dimension_in_its_asset_and_its_variable_binding`, `a_dimension_keeps_no_link_to_the_art_expand_replaced`, `a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced`, `a_dimension_outlasts_the_stroke_expand_outlined` and `expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art` cover it. **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. **Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. @@ -606,10 +608,13 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. - **Raster effects inside an object or group with a Transform effect:** effects applied to a group affect everything in it ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). How a Transform effect changes the raster effects inside a group or object is VectorCraft's own rule. A group's Transform effect that moves it once now transforms the raster effects of its members, of their fills and strokes, of nested groups and of the art of the linked opacity masks in it, after the members' own Transform effects. A raster effect that a member's own Transform effect with copies or random holds back keeps its value through that effect and still transforms with the group's. VectorCraft applies the same rule to the raster effects of an object's fills and strokes under the object's Transform effect. Sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, with Scale Strokes & Effects on or off. Before, they kept their values, or with Scale Strokes & Effects on scaled by the mean scale with the offset unturned. A group's or object's Transform effect that makes copies or moves at random leaves them as before. The canvas, the redraw region, export, Expand Appearance, Object › Expand and Outline Stroke use the same values (effects `tests::rasterfollow`, `group::tests::a_compound_shapes_transform_turns_its_fills_shadow`; render `a_transform_scales_the_shadows_of_fills_and_members_with_the_art`; engine `objexpand::a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it`, `objexpand::shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them`, `outlinestroke::a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it`). Scores and hours unchanged. +- **A group's geometry effects after its members' own:** effects applied to a group affect everything in it, and effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). Running a group's effects after its members' own is VectorCraft's own rule. The geometry effects of a group or layer (the effects that reshape outlines, such as Distort & Transform, Offset Path and Warp) now reshape what its members draw: the geometry effects of each visible path and compound path in it and of their fills and strokes, and a nested group's own geometry and Pathfinder effects, run first, at any depth. A symbol instance's own geometry effects run after those of its symbol's members in the same way. Before, the group's effects reshaped the members' outlines and the members' own effects ran afterwards: a 100 pt square with its own 10 pt Offset Path in a group with a 50% Transform drew 70 pt wide, and it now draws 60 pt wide. Existing documents with such groups draw differently. The canvas, export, Expand Appearance and the art that Pathfinder operations and compound shapes take from such a group use the same order (effects `group::tests::a_members_own_offset_runs_before_its_groups_scale`, `group::tests::a_members_own_scale_runs_before_its_groups_offset`, `group::tests::a_member_of_a_plain_nested_group_takes_its_own_effects_first`, `group::tests::a_nested_groups_own_effects_run_before_its_groups`, `group::tests::a_nested_groups_pathfinder_runs_before_its_groups_effects`, `group::tests::a_members_fill_effects_run_before_its_groups`, `group::tests::shape_art_of_a_group_applies_its_members_effects_first`, `group::tests::a_symbol_instances_effects_run_after_its_symbols_members_own`, `group::tests::a_baked_group_applies_its_members_effects_first`, `group::tests::a_baked_group_that_keeps_its_type_applies_its_members_effects_first`, `tests::rasterfollow::a_groups_transform_applies_after_its_members_own`; engine `objexpand::a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them`). Not yet: the own geometry effects of images, symbol instances, paths with a linked opacity mask or a 3D effect, and nested groups with a 3D effect in the group still run after the group's. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. +- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. +- **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. @@ -626,6 +631,7 @@ since 2026-10-01. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. +- **Control bar separators stand only between groups:** the Control bar draws a separator between two groups of options only when a control follows it in the bar. When the groups after a separator are off in the panel menu, do not apply to the selection or the tool, or have no room in the window, the bar ends with the last control and no separator; two separators with nothing between them show as one. Each separator keeps its room, so the groups keep their places. This is VectorCraft's own rule. Regression test: `no_separator_is_left_at_the_end_of_the_control_bar` in `crates/ui-egui/src/chrome/tests.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. @@ -661,12 +667,13 @@ since 2026-10-01. - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). - **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`), as the panel menu's item does. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. - **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. -- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. The character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, Reset Panel clears only the selected formatting section's attributes and is unavailable in General, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. The character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Paragraph Style Options, Tabs:** the Tabs section of Paragraph Style Options and New Paragraph Style lists a style's tab stops and edits the selected stop's Alignment (Left, Center, Right or Decimal), position (X:, or Y: for vertical type), Leader and Align On character; Add, Delete Tab and Clear All Tabs add a stop 36 pt past the furthest, remove the selected one, or leave the style with no stops. OK sorts the stops by position, keeps the stop edited last of two at one position, and saves one undo step; Preview and Cancel restore the paragraphs, Reset Panel removes the style's tabs, and a position outside 0 to 100000 pt, a leader longer than 8 characters or more than 256 stops keeps the dialog open. Tests `dialogs::para_style_options::tests::tabs_section_edits_the_style_stops` and `tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step`. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy ` copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. - **Letter spacing and Full Justify between shaped clusters:** Desired Letter Spacing on any line, the letter spacing range of a justified line, and Single Word Justification's Full Justify add space only between shaped clusters, in horizontal, right-to-left and vertical type, so a combining mark stays on its letter and a Devanagari syllable's glyphs stay together. They add no space next to a zero-width format control (a soft hyphen, a bidi mark, embedding, override or isolate, a zero-width space, a joiner or non-joiner, a word joiner, a variation selector, a tag, the byte order mark), so a word wrapped in an override still reaches both edges of a justified line, and a Kinsoku push-in counts no gap at one either. A zero-width space is a line break opportunity but not a word separator ([CSS Text Level 3](https://www.w3.org/TR/css-text-3/#word-separator) does not list it), so a justified line gives it no share of its room. Desired Letter Spacing between the letters on either side of a zero-width space goes after the zero-width space, so a line that breaks there ends at its last letter, and the line breaker does not measure a gap after it. A line that Full Justify spreads and that ends in a drawn hyphen puts the same gap between its last letter and the hyphen. The cluster boundary is VectorCraft's own rule, based on the typographic character unit of [CSS Text Level 3](https://www.w3.org/TR/css-text-3/#typographic-character-unit). Tests `tests::justify::single_word_full_justify_keeps_combining_mark_with_base`, `desired_and_fitted_letter_spacing_skip_shaped_cluster`, `rtl_single_word_cluster_gap_follows_drawn_order`, `vertical_single_word_cluster_gap_follows_drawn_order`, `single_word_full_justify_keeps_conjunct_cluster`, `desired_letter_spacing_skips_bidi_format_controls`, `letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens`, `format_controls_are_the_zero_width_default_ignorables`, `zero_width_space_takes_no_word_space_room`, `desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap`, `single_word_full_justify_spreads_up_to_the_drawn_hyphen` and `layout::wrapping_tests::kinsoku_shori_type_counts_no_gap_at_a_format_control`. Public reference: the help page (). +- **Justified lines with tabs:** a tab is not a word space, so a justified area type line no longer gives it a share of its room that no glyph draws. On a justified line with a tab, the text up to the last tab keeps its spacing, each tab keeps its stop and its leader stays in its gap, and the text after the last tab takes the room (word spaces first, then letter spacing, glyph scaling and the gaps between its letters), so the line ends on the far edge. A tab separates two words, so Single Word Justification does not align a word-tab-word line. Right-to-left lines take the text drawn after the last tab drawn, and vertical lines work down the column. Spreading the room after the last tab is VectorCraft's own rule. Tests `tests::justify::a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge`, `a_justified_tab_keeps_its_leader_inside_its_gap`, `a_justified_word_tab_word_line_reaches_the_edge`, `the_last_line_with_a_tab_fills_the_measure_only_with_justify_all`, `a_justified_line_takes_its_room_after_its_last_tab`, `a_justified_vertical_line_with_a_tab_reaches_the_end_of_its_column`, `a_justified_right_to_left_line_with_a_tab_spans_its_measure` and `justified_point_type_with_a_tab_keeps_its_width`. Public reference: the help pages (, ). - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. @@ -703,7 +710,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. -- **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). @@ -744,10 +751,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. - - Actions panel: an action keeps to the limits that Load Actions… reads while it records and while its steps are excluded: at most 10,000 steps in an action and 8 MiB in its set's file, which are VectorCraft's own rules. Commands add steps to a recording until it stops ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)); a command whose step would pass a limit does not run, its document stays as it was, and the recording goes on. A command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit; the status bar names the limit, later recorded commands do not run, and Stop Playing/Recording leaves its step out. A stop that playback reaches is not recorded then, and the status bar shows the reason. Clearing a checkmark keeps the step in the action and skips it in playback ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html)); an exclusion that would make the set's file larger than 8 MiB changes no checkmark, and one that makes the file smaller always works. Stop Playing/Recording leaves out any recorded steps still past a limit and names the limit in the status bar. Begin Recording, Duplicate, New Action, renaming an action, Action Options…, Set Options…, New Set… and Load Actions… (`.aia` sets included) also refuse a change that would give a set more than 1,000 actions or a file larger than 8 MiB, and change nothing then. Tests `panels::actions::tests::{recorded_commands_keep_an_action_at_10_000_steps, recorded_commands_keep_an_action_set_file_within_8_mib, a_recorded_command_whose_noted_values_do_not_fit_is_left_out, a_stop_reached_while_recording_a_full_action_is_left_out, stop_leaves_out_recorded_steps_past_the_limits, excluding_steps_keeps_an_action_set_file_within_8_mib, a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows, a_set_renamed_while_it_records_is_measured_again, steps_recorded_after_an_exclusion_past_the_last_step_are_included, duplicate_keeps_action_set_files_within_their_limits, new_action_keeps_an_action_set_file_within_its_limits, renaming_keeps_an_action_set_file_within_8_mib, load_actions_refuses_a_set_that_would_save_larger_than_8_mib}`. + - Actions panel: an action keeps to the limits that Load Actions… reads while it records and while its steps are excluded: at most 10,000 steps in an action and 8 MiB in its set's file, which are VectorCraft's own rules. Commands add steps to a recording until it stops ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)); a command whose step would pass a limit does not run, its document stays as it was, and the recording goes on. A command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit; the status bar names the limit, later recorded commands do not run, and Stop Playing/Recording leaves its step out. The same holds when a script run, a tool's drag or a panel's or dialog's edit takes the recording past a limit: the edit and the journal stay, the status bar names the limit as soon as the edit ends (instead of the script's "Ran" message), and Stop Playing/Recording keeps only the steps that fit. A stop that playback reaches is not recorded then, and the status bar shows the reason. Clearing a checkmark keeps the step in the action and skips it in playback ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html)); an exclusion that would make the set's file larger than 8 MiB changes no checkmark, and one that makes the file smaller always works. Stop Playing/Recording leaves out any recorded steps still past a limit and names the limit in the status bar. Begin Recording, Duplicate, New Action, renaming an action, Action Options…, Set Options…, New Set… and Load Actions… (`.aia` sets included) also refuse a change that would give a set more than 1,000 actions or a file larger than 8 MiB, and change nothing then. Tests `panels::actions::tests::{recorded_commands_keep_an_action_at_10_000_steps, recorded_commands_keep_an_action_set_file_within_8_mib, a_recorded_command_whose_noted_values_do_not_fit_is_left_out, script_run_checks_recording_step_limit_on_completion, script_run_checks_recording_byte_limit_on_completion, tool_commit_checks_recording_step_limit, tool_commit_checks_recording_byte_limit, a_journal_rewrite_or_a_direct_commit_is_checked_on_the_next_frame, a_stop_reached_while_recording_a_full_action_is_left_out, stop_leaves_out_recorded_steps_past_the_limits, excluding_steps_keeps_an_action_set_file_within_8_mib, a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows, a_set_renamed_while_it_records_is_measured_again, steps_recorded_after_an_exclusion_past_the_last_step_are_included, duplicate_keeps_action_set_files_within_their_limits, new_action_keeps_an_action_set_file_within_its_limits, renaming_keeps_an_action_set_file_within_8_mib, load_actions_refuses_a_set_that_would_save_larger_than_8_mib}`. - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. + - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. + - Actions panel: Insert Menu Item… also lists the Object › Align items and the View menu commands that act on the window rather than the document (Outline, Pixel Preview, Trim View, Presentation Mode, Screen Mode, zoom, fit, Rotate View, and the show, hide and snap items), which the help page names among the commands that cannot be recorded ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); [Align and distribute objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/align-and-distribute-objects.html); `ui.action.insertMenuItem`, `ui.action.play`). VectorCraft's own rules, which the pages do not cover: an Align step keeps the Align To choice of its insertion, and one that aligns to the key object stops the playback when there is no key; a View menu step plays in its place at every speed and adds no undo step, so at Accelerated the steps on each side of it are one undo step each; Batch… refuses an action with one; and a set file with one is version 6. Tests `panels::actions::tests::{an_inserted_view_item_plays_in_its_place_at_accelerated, an_inserted_view_item_plays_step_by_step_and_with_pauses, an_inserted_view_item_plays_up_to_a_stop, a_view_step_played_while_recording_is_recorded, a_saved_view_step_survives_as_version_6, an_inserted_align_item_keeps_the_align_to_of_its_insertion, an_align_step_to_the_key_object_fails_without_a_key}`. - Actions panel: with a path selected, the panel menu's Insert Select Path adds a step that selects that path to the recording in progress, after the selected step, or at the end of the selected action, without changing the selection ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectPath`, `select.storedPath`). VectorCraft's own rules, which the page does not cover: the selection must be one whole path or compound path that can be selected, the step stores the path's id and a fingerprint of its anchors, and playback selects the path only while it is still there unchanged and can be selected, else the step is an error and the selection stays; the row reads Select Path, and a set file with the step is version 5. - Actions panel: the panel menu's Select Object… opens Set Selection, where the note typed names the objects to select, and adds a step to the recording in progress, after the selected step, or at the end of the selected action; playback selects the objects whose Attributes note matches it, and the steps after it act on them ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectObject`, `select.byNote`). VectorCraft's own rules, which the page does not cover: the note matches anywhere and in any letter case unless Case Sensitive or Whole Word is checked, hidden and locked objects are left out, the objects found replace the selection, a step that finds none stops the playback with an error, and the note is at most 1,024 bytes. Action sets with the step save as version 4. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. @@ -780,9 +788,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: an uncaught error that one or more `finally` blocks pass on unchanged, in the script or in a function it called, is reported at the line and column where it was thrown, in `script.run`'s report and the Script Console. An error a `finally` block throws is reported at that throw, and one its `return`, `break` or `continue` discards is not reported. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the current script's file and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. + - Scripts: `$.fileName` is a read-only name of the script file in use ([the help page](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: it names the file of the code that reads it, so a function a file included with `#include` or run with `$.evalFile` defined, or one an earlier run of a named engine kept, gives its own file and not the calling file; code from a script given as `source`, and kept code whose place the engine dropped, gives an empty name, and code that `eval` or `Function` compiled gives the file being run. `crates/engine/src/tests/script_include.rs`, `crates/engine/src/tests/script_engines.rs`. - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. An error in code whose place is gone, or in code that `eval` or `Function` compiled, is reported without a line and column, and the code still runs. `crates/engine/src/tests/script_engines.rs`. + - Scripts: `$.line` is the line of the running script, counting from 1, and `$.stack` is the current stack trace ([the help page](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: both count lines in the file the code is in (the script, a file it included, a file `$.evalFile` ran, or the file of an earlier run of the named engine), and `$.stack` writes each frame as `name@file:line`, outermost first; code that `eval` or `Function` compiled keeps its own line in `[eval]`, and code whose place the engine no longer keeps is at line 0 of `[unknown]`. `crates/engine/src/tests/script_include.rs`, `crates/engine/src/tests/script_engines.rs`, `crates/js/src/extension_tests.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. @@ -800,6 +810,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - Effect parameter types: `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse an effect parameter of another type than the effect reads, for keys only the params text names too (the earlier 3D `perspective`, `lightAzimuth` and `lightElevation`, Scribble's `variation`, Outline Stroke's `width`, Gaussian Blur's `linear`), a Crop Marks `style` other than `roman` or `japanese`, and an installed plug-in effect's parameter of another type than its manifest gives it, in a `command.batch` step too, with the document and undo history unchanged; a replayed action still plays. `effect.setParams` checks the parameters against a Warp's new style, and refuses a `visible` that is not true or false, in a replayed action too. `crates/engine/src/tests/params.rs` (`an_effect_parameter_without_a_default_is_held_to_its_type`, `effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type`), `crates/engine/src/tests/plugins.rs` (`live_effect_plugins_reshape_editable_art_and_expand`), `crates/engine/src/cmd/effectcmd/tests.rs` (`set_params_refuses_a_visible_that_is_not_true_or_false`, `set_params_checks_the_effect_it_leaves`). - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). + - MCP window captures from an app that does not send the image back go through a file in a new folder inside the system's temporary folder, which only the user can open on Unix, instead of a fixed name in the shared folder; the folder is removed after the read, also when the capture or the read fails (`tests::conventions::an_old_app_captures_the_window_in_a_new_private_folder`). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. @@ -811,6 +822,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - With `Options::e4x` set, an E4X name standing alone (`@a`, `@ns::a`, `@[expr]`, `ns::a`, `*`) inside `with` or a filter predicate can be assigned, with `=` or a compound operator, incremented, decremented, deleted and used as a `for` or `for each` variable, as well as read. Each of these uses the nearest XML or XMLList value in scope that holds the name and evaluates the name once. When no XML value in scope holds the name, the read or write throws a `ReferenceError` before the right-hand side runs and creates no child or attribute, as ECMA-357 section 11.1 (https://ecma-international.org/publications-and-standards/standards/ecma-357/) specifies, so a filter such as `list.(@id == '1')` throws when an item has no `@id` and no outer XML value holds one. Tests: `crates/js/src/xml_tests.rs`. - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings. `xml.xpath(expression[, variables])` evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with all 13 axes and the 27 core functions, and returns the selected nodes as an XMLList in document order without repeats. Each property of `variables` binds the variable of its name, an XMLList runs the expression on each of its elements in turn, and a prefix in a name test resolves through the context element's in-scope namespaces. An expression that breaks the grammar gives a `SyntaxError`; an unknown function, a wrong argument count, an unbound prefix or variable, and a result that is not a node-set or that holds the root or a namespace node give a `TypeError`. `id()` selects nothing, and evaluation uses no native stack, takes fuel and counts against the heap limit. `` in an XML string gives a `SyntaxError`. An XML declaration at the start of an XML string, after optional white space, is checked against XML 1.0's grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of `1.` and digits, then an optional encoding name and an optional standalone `yes` or `no`, in that order; any other declaration, or one with a character XML does not allow, gives a `SyntaxError`. The parser takes fuel for the length of every name, white space run and declaration it scans, and for the text it searched when a quote, CDATA section, comment or processing instruction is not closed. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. + - Scripts: `inScopeNamespaces()`, `namespace()`, `namespaceDeclarations()`, the XPath `namespace` axis, prefixes in an XPath expression and the qualified names `name()` gives count the namespaces they collect from a node and its ancestors against the heap limit, so a node with many namespaces in scope stops the script at the heap limit instead of growing past it. `addNamespace()`, `setNamespace()`, `setName()` and `removeNamespace()` read an element's attributes in place instead of copying them, and `removeNamespace()` counts its walk of the tree; `child()` by index, `comments()`, `text()`, `elements()`, `processingInstructions()` and `normalize()` on an XMLList count their copy of its items; `replace()` counts its copy of the children it matched; and `name()` counts the prefixed name it builds. The `XML`, `XMLList`, `Namespace` and `QName` constructors, `isXMLName()`, `attribute()`, `namespace()`, `propertyIsEnumerable()`, `setName()`, `setLocalName()`, `addNamespace()`, `removeNamespace()` and `setNamespace()` count the copy they make when they flatten a long string argument, and `QName.prototype.toString()` counts its result before it builds it. Tests: `crates/js/src/xml_tests.rs`. + - Scripts: assigning to a named child of an XML element (`x.b = v`) counts its list of the matching children against the heap limit, and assigning through an empty XMLList counts the chain of targets it climbs, so an assignment that would not fit stops before the tree changes. `copy()`, `toXMLString()`, `normalize()`, the `..` selector and inserting a node count the stack they keep for each open level of the tree, `copy()` and `toXMLString()` read an element's attributes in place instead of copying the list, `toXMLString()` also reads its namespace declarations in place, and `copy()` counts the declarations it copies before it copies them. `toXMLString()` counts the flat copy it makes of text or an attribute value built by joining strings. Deleting children or attributes by name, and assigning an attribute, remove the nodes in place instead of building a second list. `toXMLString()` counts the set of prefixes it passes looking up a namespace, and picks a new `ns` prefix in one pass over the declarations in scope. Inserting an XMLList, and assigning one to an item of an XMLList, count their copy of the list's items. Parsing counts the open elements, the text it gathers, each tag's attributes and the sets that check them, and the namespace declarations it makes. Comparing XML values, converting them to strings, and reading a name, a namespace or embedded text from a string built by joining strings count the flat copy they make; an attribute name read from a string counts its copy. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -832,6 +845,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). - In the Layers panel only the primary mouse button drags. A drag with the right or middle button on a row, a guide row, the selection square, the eye or lock column or a target circle moves, copies, hides, locks and transfers nothing and records no undo step, and a right-click still opens the row's menu (`panels::layers` test `only_the_primary_button_drags_rows_guides_art_columns_and_appearances`). + - Rows dragged in the Layers panel and held within 28 points of the list's top or bottom edge scroll a long list that way, faster nearer the edge (60 points a second at the band's inner side, 340 at the edge, at every frame rate), and keep scrolling while the pointer stays still, so a row out of view can take the drop (a move, or a copy with Alt); the Libraries panel's list scrolls by the same rule. A pointer beside the list, a drag of the eye or lock column and a release stop the scrolling, and scrolling changes nothing in the document. The band and speeds are VectorCraft's own (`panels::layers` tests `rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view`, `a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate` and `the_list_scrolls_only_for_rows_held_over_it`). - The other panels' item drags start only with the primary mouse button too: swatches and color groups, brushes, symbols, graphic styles, library items and groups, character and paragraph styles, Appearance rows and its thumbnail, the Fill and Stroke squares, Recent Colors and the Gradient panel's thumbnail. A right or middle drag on one carries nothing, moves, copies, deletes and applies nothing and records no undo step; right-click menus and the Graphic Styles panel's held right-button preview stay as they were (`panels::swatches` test `only_a_primary_drag_moves_swatches_and_groups`, `panels::text_styles` test `only_a_primary_drag_carries_a_style_row`, `panels::tests_symbol_libraries` test `only_a_primary_drag_carries_a_library_symbol_out`, `panels::tests_effectedit` test `only_a_primary_drag_carries_the_thumbnail_or_a_row`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - A floating panel's OS window hides while a tool group's flyout or the All Tools drawer is open in the main window, as it does under a modal dialog or a menu, and shows again when the flyout or the drawer closes (#5813; `a_tool_flyout_hides_the_palette_windows_until_it_closes`). @@ -841,8 +855,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - A toolbar made with New Toolbar has a + button (Add Tool…) above its ••• button. A click on it, or Enter while it has the keyboard focus, opens a menu of every tool under the All Tools drawer's headings, without the drawer: a tool picked there goes at the end of that toolbar, and the tools already in it are dimmed. Under Remove Tool, the menu lists the toolbar's tools, and a click takes one out. This menu is VectorCraft's own way to fill a toolbar without a drag (`toolbar.addTool` and `toolbar.removeTool` with `toolbar`; `the_add_tool_menu_fills_a_new_toolbar_without_a_drag`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). + - A toolbar made with New Toolbar docks in a lane along the window's left or right edge when its title bar is dropped on that edge, or with Dock to Left and Dock to Right in the title bar's context menu. Toolbars docked at one edge stack from the top, each with its share of the lane's height, and the lane is as wide as its widest toolbar; a lane too short for its stack wraps it into more columns, up to half the window's width, and past that the toolbars show only their title bars, which the mouse wheel scrolls through when even they do not fit. Dragging the title bar out of the lane, or Float Toolbar, floats it again, and Escape during the drag puts it back. Hiding and renaming keep its place, and the workspace keeps it. New toolbars still start floating. The lanes and the stacking are VectorCraft's own rules (#1759; `window.toolbar.dock`, `window.toolbar.float`; `toolbars_dock_and_float_by_their_commands`, `docked_toolbars_stack_in_lanes_along_the_window_edges`, `a_title_bar_dropped_on_an_edge_docks_the_toolbar`). - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). + - Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged. - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). @@ -866,6 +882,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). + - Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish`, `learn.next` and `learn.skills` (Back to skills) script them. Not done: the help page's short video, and progress kept between sessions and shown in the skill rings. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `learn_skills_shows_the_list_of_skills`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`. - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). @@ -898,10 +915,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. + - The canvas context menu lists Object › Path › Smooth… while the selection holds a path, in a group or a compound path too. It opens the Smooth slider, as the Object menu does (`right_click_on_a_path_opens_the_smooth_slider`, `smooth_follows_the_selections_paths`). - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. - **Snapping quick access (#6164):** Control bar › Snapping includes Snap to Glyph (`view.snapToGlyph`) and Alignment Guides (`prefs.set`, `alignmentGuides`). The headless pointer/accessibility test `the_control_bar_snapping_menu_shares_glyph_and_alignment_settings` covers both commands, independent state, checked states after reopening, and all eight menu rows within a 1200 × 400-point window. Floating-panel parity remains unverified; scores and estimates unchanged. + - **Pixel Snapping Options:** Control bar › Snapping › Pixel Snapping Options… sets whether View › Snap to Pixel snaps drawing, moving and scaling, each on by default (`view.snapToPixel.options`, the `pixelSnap` preference group; [the help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/draw-pixel-perfect-art.html)). Tests: `pixel_snapping_options_read_set_and_reset`, `pixel_snapping_options_choose_the_edits_snap_to_pixel_rounds`, `handle_drag_follows_the_while_scaling_option`, `move_follows_the_while_moving_option`, `an_anchor_drag_follows_the_while_moving_option`, `new_ruler_guide_follows_the_while_moving_option` and `the_control_bar_snapping_menu_opens_pixel_snapping_options`. Scores and estimates unchanged. - **Measure tools and Snap to Glyph:** with Snap to Glyph, Smart Guides and Alignment Guides on, and Snap to Grid and Snap to Pixel off, both ends of a Measure or Measure & Inspect drag land within the snapping tolerance on the guides of live type that the Character panel's options allow, except Anchor Point, and a Shift-drag's end slides along its 45° step to them. The guide shows during the drag. This is VectorCraft's own rule (`Targets::for_measure`, `xform::measure::tests::its_ends_land_on_glyph_guides_with_snap_to_glyph`); scores and estimates unchanged. - **Drawing readout without the rubber band:** with Enable Rubber Band for the Pen or Curvature tool off and Measurement Labels on, the Pen, the Smart Pen and the Curvature tool hide the preview segment but still show the D / Angle / dX / dY readout at the snapped pointer before the next click, and the Curvature tool also shows it when going on from the current end of a selected path. Measurement Labels show distance and placement while drawing ([the help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html)), and the rubber band preference turns off the path preview ([the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/preview-paths-drawn.html)). Showing the readout with the rubber band off is VectorCraft's own rule (`pen::tests::measurement_label_shows_with_the_rubber_band_off`, and `the_measurement_label_shows_with_the_rubber_band_off` in `draw2::curvature::tests` and `addons::smart_pen::tests`); scores and estimates unchanged. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (off by default: on, pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). @@ -1057,8 +1076,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. @@ -1087,6 +1107,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Live curve fitting in the Pencil Tool Options is on by default; while it is off, the path shown while a stroke is drawn joins the stroke's points with straight segments, and the release fits the curve, as [Pencil Tool Options](https://web.archive.org/web/20261002104400/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/pencil-tool-options.html) describes (`path.freehand` `fit`). In the Pencil and Paintbrush Tool Options, Edit selected paths within 0 px turns editing off, so a stroke that starts exactly on an end of a selected open path draws a new path; Close paths when ends are within 0 px turns closing off, so a stroke that ends exactly where it started stays open. The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. + `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). + Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`). With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html) describes (`path.freehand` `connect`). @@ -1102,7 +1124,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle in degrees as its value: a click or a drag in it points the Angle at the pointer and gives it the keyboard focus, Tab reaches it after the Roundness field, and the arrow keys step the Angle by 1° (10° with Shift, Home and End to −180° and 180°). The Blob Brush Tool Options also have Fidelity, which the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list as setting how far the pointer moves before the path gets a new anchor point. The range and default are VectorCraft's own values: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`). A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`). -- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. +- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. A guide's row has its own eye and lock, which hide or lock that guide alone in one undo step and take part in a drag down either column ([help page](https://helpx.adobe.com/in_hi/tutorial-builder/user-guide/illustrator-tutorial-builder/-ai--examples-of-common-coach-marks.html) lists a Guide among the Layers panel's items); a hidden guide is not drawn and does not snap, a locked one is drawn and snaps but can't be selected, moved or deleted, and its own state is kept through save and reopen and while its layer is hidden or locked (`layer.setProps` on its `guide.list` id). Object › Lock › Selection and Object › Hide › Selection lock or hide a selected guide with the selected art, and Unlock All and Show All unlock or show such guides and select them ([help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) for Lock and Hide Selection). A guide row dropped on the panel's trash deletes its guide, the trash deletes a guide its row selected, and after a click on a guide row Delete deletes the guide rather than the rows highlighted before ([help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) for deleting a selected guide). Not done: guides stacked among the art of their layer (they are listed above it). Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. - **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). With Paste Remembers Layers on, art copied from a sublayer pastes back into that sublayer, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=223) describes art going back to the layer it was copied from: in the same document while the sublayer is still there, two sublayers of the same name in different layers kept apart; in another document by the names of the sublayer and the layers around it. These rules are VectorCraft's own: a missing sublayer is made inside its layer (a missing layer on top, with the sublayer inside it), in the paste's one undo step; of layers with the same names, the topmost chain that matches the most names wins, a shown and unlocked one first; a matched layer that is hidden or locked, or inside one that is, refuses the paste, and nothing is pasted. @@ -1147,8 +1169,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Align to Artboard chosen, the Align panel's Distribute Objects and Distribute Spacing buttons use the active artboard (`object.distribute` and `object.distributeSpacing` with `to: "artboard"` and `artboard`). The first object's left or top edge goes to the artboard's left or top edge, and the last object's right or bottom edge goes to the artboard's right or bottom edge. Distribute Objects spaces the other objects evenly by its button's line, and two objects are enough. Distribute Spacing makes the gaps between the objects equal. With anchor points selected, the buttons space the points among themselves and do not use the artboard. When an action plays, its Distribute Objects and Distribute Spacing steps with Align to Artboard use the artboard that is active then. With a key object, the Align panel's Distribute Objects buttons use the Distribute Spacing value (`object.distribute {spacing}`): the key object, or the compound path or selected group that contains it, stays in place, and the objects' left edges, centers or right edges (top edges, centers or bottom edges) end up that far apart, outward from the key object's, in their order. With the field's default value of 0, a click puts every object on the key object's line (behavior reference: [Ask Mordy: Distributing Objects](http://rwillustrator.blogspot.com/2008/01/ask-mordy-distribute.html)). Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it. - Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then. + Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Selected Art steps that name no artboard resize the artboard that is active then, and its Fit to Artwork Bounds steps resize the artboards selected then. Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document. + A Fit to Artwork Bounds step of a batch run from the app (`command.batch`), including an action played from the Actions panel, fits each artboard the Artboard tool has selected when the step runs, as the command does on its own and as the [help page](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) describes. A step's own `index` or `indices` is kept, and the journal records the artboards the step fitted, so a replay fits the same ones. The step used to fit only the active artboard. The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only. With the Artboard tool selecting several artboards, Paste in Place, Paste in Front and Paste in Back put a copy of the copied art on each, where it was on the artboard it was copied from, in one undo step, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes (`artboards` on the three commands). Art copied from no artboard keeps its place on the first selected artboard and its offset to it on the others, and an `artboard` or `artboards` index the document does not have is an error; these rules are VectorCraft's own. The three commands used to paste one copy, on the active artboard, and an `artboard` index the document did not have pasted the art where it was. A replay of the journal repeats a Cut or Copy made with the Artboard tool on the artboards it took (`edit.cut` and `edit.copy` record `selectedArtboards` and `art`), whichever tool is chosen in the new session and whatever is selected there. A recorded action follows the tool chosen when it plays. The replay used to act on the objects selected then, or stop with nothing selected. This rule is VectorCraft's own. @@ -1172,11 +1195,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Outline Stroke, Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the object's visible geometry effects (Offset Path, Zig Zag and the others) to the path, and then apply each stroke's own geometry effects before outlining that stroke, as the canvas draws it. The filled outline of a stroke without a brush keeps the stroke's other effects, such as a drop shadow. Outline Stroke and Unite remove the overlaps of a long, low path with the sweep turned along it: when the estimated number of segments the sweep holds at once is under two thirds as large for the path turned a quarter turn (paths of 2,048 segments or more), the overlaps are removed on the turned path and the result is turned back, which moves no point by rounding. The lookups of the input curve that a piece of the result lies on, and of the input anchors that are kept, can search along y instead of x: once lookups with more than 64 candidates along x have counted 16 for each input curve or anchor, a lookup searches along y when it finds fewer candidates there (under a quarter as many for curves), and where that has rarely happened it measures along y in only one lookup of 32. On one open path of 200,000 anchors in rows of 1,000, Outline Stroke takes 17.3 s of processor time instead of 31.3 s and Unite 2.9 s instead of 6.5 s in a release build. A turned result has as many subpaths as the result without the turn, and the area where the two differ is no larger than the refit precision times the outline's length (`crates/pathops/src/sweep_axis.rs` tests). + Offset Path turns the sweep the same way: when the estimate for its fill, the ring stroked around the fill and the outline of its open subpaths is under two thirds as large turned a quarter turn, the faces are found on the turned parts, the faces that lie within the offset distance of the path are dropped by testing their points turned back, and the result is turned back. On an open path of 10,000 anchors in rows of 1,000, offsetting by 2 pt takes 1.0 s of processor time instead of 1.8 s in a release build. The offset of a long, low path has as many subpaths as the offset of the same path stood on end, and the area where the two differ is no larger than the refit precision times the outline's length (`offset_path_sweeps_a_long_low_path_turned_with_the_result_of_the_path_on_end`). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. + - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). @@ -1184,6 +1209,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). + - Revolve takes graphics on its swept side, shaded with the object's light ([the archived reference, page 185](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185)). By VectorCraft's own rule a graphic wraps around the axis at its own size at every distance from it, narrows toward the silhouette, follows the profile along its length and is hidden where the side faces away (`threeD.mapGraphic`; `revolve::tests_graphics`, `tests::side_faces_lie_on_the_unrolled_surface_and_caps_do_not`, `map_graphic_puts_a_graphic_on_the_side_of_a_revolve`). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html). @@ -1206,6 +1232,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Nearly unchanged appearance is expected when reducing points. The checklist is partial, without raising scores. Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape. Simplify keeps its result within the tolerance of the path, on long arcs and on paths of many anchors alike (`fit.rs` tests `no_fitted_piece_is_farther_than_the_tolerance` and `long_arcs_stay_within_the_tolerance`). A closed subpath that refits to one curve is dropped, so this does not hold for it. A segment that would need more than 65,536 samples at that tolerance stays as it is, and with Convert to Straight Lines it becomes evenly spaced lines instead, up to 65,536, which past the limits in `docs/mcp.md` (Simplify) can be farther than the tolerance from the segment. + Simplify, Object › Path › Smooth, the Smooth tool and freehand strokes (`path.freehand`) turn a refitted curve into a straight line only when the line runs within 0.5° of the directions the path arrives and leaves along, so no corner appears where a curve meets a line; a nearly straight piece that would turn more stays a flat curve (`ops.rs` test `simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth`). The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines. Object › Path › Simplify opens a slider under the selection ([Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html), [Auto simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/auto-simplify-paths.html)) with Auto-Simplify, a rail from the fewest anchor points to the closest fit, and More Options, which opens the dialog at the slider's tolerance. The rail's levels are VectorCraft's own scale (`object.path.simplify {level}`): level 50 is 1 pt, and every 10 levels halve or double the tolerance, from 32 pt at 0 to 1/32 pt at 100. Auto-Simplify picks the lowest level whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt; the slider opens at that level, with its result previewed, as the manual-simplify page describes. The dialog's "Open this dialog with these settings next time" makes the menu item open the dialog on the settings of its last OK. With anchors or segments of a path selected with Direct Selection, Simplify acts on just that part of the path, as the [Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) page puts it: the slider, Auto-Simplify and the dialog refit only each run of selected segments, between its end anchors, which keep their place and their handles on the side away from the run. The anchors and segments outside the runs stay exactly as they were, a closed path's run may cross its start point, separate runs on one path are refitted independently in one undo step, and the runs stay selected. A path with only lone anchors selected is still simplified whole, and `object.path.simplify {wholePaths: true}` simplifies partly selected paths whole. Tests: `crates/engine/src/tests/simplify_part.rs` and `simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path`. @@ -1226,8 +1253,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font, the text whose inline graphics draw a symbol whose art uses it, and the objects painted with a pattern whose art uses it by a visible fill or stroke above 0% opacity (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the font filters every font menu shares (one chosen in the Glyphs panel's menu or a dialog's applies too) but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). - The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). - The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. + The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). + The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. @@ -1239,8 +1266,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. + On a shaped area type frame, Inset Spacing keeps text that far from the frame's edge, holes included, measured from the edge itself rather than along the line; the archived help treats the inset as the space an area type object leaves between its text and the path that bounds it (, ). A rectangular frame keeps its inset from each side as before, and a column's inner edges keep it once. Tests: `layout::region::inset_on_a_shaped_frame_is_measured_from_its_sloped_edge`, `inset_keeps_text_clear_of_a_hole_s_sloped_edges`, `inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge`. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). + When a story flows through threaded area type, each frame is measured with the whole paragraphs of the story that it needs and a few more, not with the whole rest of the story, and it keeps the same text as before. A story of 1,500 one-line paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes of text while the frames were measured, and is now laid out over 21,035 (`thread::tests::many_frames_measure_about_the_text_they_take`). Type on a path, Shrink Text to Fit, lines aligned other than to the top, and a frame whose share ends in the story's last paragraph are still measured with the whole rest. A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. + In threaded text, a paragraph format given to the empty paragraph after a frame's last paragraph break stays after the story flows again, in that frame and in the frames the paragraph continues into, unless the same edit also formats the next frame's first paragraph, whose format then applies (VectorCraft's own rule; `thread::tests::restyled_empty_last_paragraph_wins_over_unedited_next_frame`, `cmd::threads::tests::editing_a_frames_empty_last_paragraph_survives_thread_reflow`). Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. Object › Rasterize on a frame of threaded text takes it out of the thread with the text it shows, which the image draws, and the other frames keep the text they hold. When the last frame is rasterized, the text it has no room for goes after the text of the last frame that stays. Type › Create Outlines, Object › Expand and Object › Expand Appearance on a frame of threaded text take it out of the thread with the text it shows, which the outlines draw, and the other frames keep the text they hold. When the last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps the frame as type in a group with its shadow, outer glow or Crop Marks, the type stays in the thread. The public help on threads ([Create text threads between objects](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html), [Remove or break text threads between objects](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/remove-or-break-text-threads-between-objects.html)) does not describe outlining a threaded frame; this handling is VectorCraft's own behavior. @@ -1252,6 +1282,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency leave art without transparency as it is when it overlaps only other art without transparency, unless it overlaps a member of a see-through object's group and lies above that member and below the group's topmost object. Such art joins the group, and the group takes the place of its topmost object, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under the group within one image pixel of a member joins it too. The 1 pt antialiasing margin goes around a group's art once, however deeply its groups nest. On a grid of 3,000 rectangles with a 0.5 pt stroke, 60 of them with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. These rules are VectorCraft's own (`tests::flatten::opaque_art_that_touches_only_opaque_art_stays`, `tests::flatten::opaque_art_the_group_would_move_under_joins_it`). + When they group art for flattening, Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency compare the box of an object without transparency only with the boxes of see-through objects in the pass that splits art by transparency. In both passes, a box that lies inside an earlier box that ends no further right and joins everything it overlaps is not compared with later boxes. With one see-through object inside 512 nested objects without transparency, the grouping compares 1,024 pairs of boxes where it compared 262,145, and the groups stay the same (`cmd::flatten::analysis::tests::nested_opaque_boxes_meet_only_the_see_through_box`, `a_shrinking_nest_meets_only_its_outermost_box`). New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`). @@ -1289,6 +1320,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. + With the Selection tool, the Tabs panel shows the ruler and stops of the first paragraph of the first selected text object, also when the text is inside a selected group or a shape is selected before it; a selection without text shows "No text selected". A stop set on that ruler is set on every selected text object and the text in selected groups, and other objects are left as they were. This is VectorCraft's own rule. Tests `panels::tabs::tests::the_ruler_shows_text_selected_after_an_object_that_is_not_text`, `the_ruler_shows_text_inside_a_selected_group`, `a_stop_added_on_a_mixed_selection_sets_every_selected_text` and `a_selection_without_text_shows_no_ruler`. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish and Dutch also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. @@ -1328,6 +1360,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, notches read only their own piece:** the notch rule of `subpixel::outline` reads only the shared piece, so both regions of an edge cut a corner pixel alike and leave no gap; a code-drawn noisy multi-colour coverage test in `crates/trace` (see [quality notes](image-trace-quality.md#notches-read-only-their-own-piece)). - **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)). - **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged. +- **Image Trace, staircases in smooth areas:** in Color and Grayscale traces, a staircase of three or more steps of 1 to 8 px along a border between two colors, where the image changes gradually by the same rule (`subpixel::low_contrast`, read for the staircase alone, which ends at any step that shows half of the step between the two colors or more; the 8 px limit and the one-half cut are VectorCraft's own values), is traced as the slope through the middles of its steps with no corner at each step, also on an outline that runs on along a sharp edge in the image. The corner where a staircase meets a straight edge longer than 8 px stays. Longer steps, staircases along a sharp edge and Black and White traces keep their corners; 6 Colors on gradient and photo leaves a gap of 0.9 px (was 1.2) and an overlap of 1.0 px (was 0.8), and High Fidelity Photo with Gradients on a blurred input takes 50 anchors (was 47); see [quality notes](image-trace-quality.md#staircases-in-smooth-areas) for these and the other rows that got worse. Tests: eight `subpixel::tests` in `crates/trace`, starting with `a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps`. Scores and hours unchanged. - **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`. - **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`. - **Image Trace, blurred edges:** in Color with a Limited palette of at most 16 colors (3 Colors, 6 Colors, 16 Colors) and in Grayscale with at most 16 grays (Shades of Gray), by VectorCraft's own rule (RGB distances, 0 to 255 a channel), a blend pixel with no unblended pixel of its own color within 4 pixels takes the nearer side of the pair of colors within 8 pixels that it is best a blend of, of the 8 colors within 8 pixels nearest to its color, when that pair explains it twice as well as its own color, or when it lies between the two and off the line between them by less than 0.15 of their distance apart; it keeps its color when it is one of the pair. Grayscale compares lightness. The in-between tones of an edge blurred over several pixels no longer trace as rings around it. High Fidelity Photo, Low Fidelity Photo and Black and White trace as before, and so do Automatic, Full Tone and Document Library palettes. Noisy and textured images settle more of their pixels. By `crates/trace/tests/quality` (256 px variants), 16 Colors on blurred flat art went from 9.0 paths to 3.0, 158 anchors to 34 and 0.239 px off to 0.069, and 6 Colors from 6.0 paths to 3.0 and 0.226 px off to 0.071; askew shapes take up to 11 px more area in colors the art does not have, and with 16 Colors the largest distance from a thin line's outline to the trace goes from 23.5 px to 45.3 (see [quality notes](image-trace-quality.md#blurred-edges)). Code-drawn blurred-disc and grayscale ramp tests in `crates/trace`. @@ -1368,10 +1401,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Detach is missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Detach is in its own entry below. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. Objects on Path Move All widget: with one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below its first object; dragging it along the path, round its bends, moves every object along the path together in one undo step, keeping the distances between them (`object.objectsOnPath.options {start}`). On an open path the widget shows only when the distance from the first object to the last is shorter than the path, so not for objects spread evenly from end to end, and the objects stop at the path's ends; on a closed path they go on round across its first anchor. The first object's distance along the path shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::{dragging_the_move_all_widget_moves_every_object_along_the_path, a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room` in `crates/ui-egui`. Objects on Path Select/Move widget: with one Objects on Path group selected, the Selection and Objects on Path tools show a small ring on each object's attachment point, highlighted under the pointer. A click on one chooses that object and fills its ring blue while the group stays selected (`object.objectsOnPath.selectMember`); a click on the object's art still selects the group. Dragging a ring along the path moves its object to the object's place nearest the pointer, and the objects between move one place toward where it was, in one undo step (`object.objectsOnPath.moveMember`); hidden and locked objects keep their places; only the order along the path changes, not the Layers panel's order or the Space. On an open path the drag stops at the ends, on a closed path it goes on round across the first anchor, and Escape cancels it ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::moving_an_object_shifts_the_ones_between_it_and_its_new_place` in `crates/doc`, `tests::objects_on_path::move_member_takes_the_target_s_place_and_shifts_the_objects_between` in `crates/engine`, `objects_on_path::tests::{dragging_a_select_move_widget_moves_its_object_to_the_nearest_place, a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_a_select_move_widget_per_object` in `crates/ui-egui`. + Objects on Path Detach: with one Objects on Path group selected and an object chosen with its Select/Move widget, Detach in the Properties panel's Objects on Path Options section takes that one object off the path (`object.objectsOnPath.detach`), and the objects after it move up the path in their order: with Auto Space they spread evenly again, the ends of an open path staying occupied while two or more are left, and a Space value is kept ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html), [Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html)). Detach is dimmed until an object is chosen. VectorCraft's own rules: the object stays where it is drawn, goes just above the group in the Layers panel and is selected alone; the start is kept; the group and its path stay with one object or none left; a hidden or locked object or group is not detached; and Detach is one undo step. Several objects still leave together by dragging their rows out of the group in the Layers panel. Evidence: `tests::objects_on_path::{detach_takes_the_chosen_object_off_the_path_and_the_others_move_up_in_one_undo_step, after_the_first_object_is_detached_the_second_takes_the_start, detach_on_a_closed_path_spreads_the_others_all_the_way_round_again, detach_keeps_a_space_value_and_the_start, the_group_and_its_path_stay_with_one_object_or_none_left, an_object_detached_from_a_group_on_another_path_does_not_join_that_path, detach_refuses_what_is_not_one_chosen_editable_object_on_a_path}` in `crates/engine`, `panels::properties::tests::detach_takes_the_chosen_object_off_the_path` in `crates/ui-egui`. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. @@ -1439,6 +1473,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0 and a grayscale image colorized with one, leaving out process colors that look the same, Registration, gradients whose stops are all at an opacity of 0, fills and strokes at an opacity of 0, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Tests: `cmd::docinfo::tests::info_lists_the_spot_color_objects`, `spot_color_objects_take_colorized_images_and_skip_clear_gradients` and `spot_color_objects_skip_clear_fills_and_strokes` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. + Document Info's category menu and headings show every category title in the interface language, Font Details, Linked Images and Embedded Images included, and the Objects category shows every count label in it too: Compound Paths, Text Objects, Placed Documents, Symbol Instances, Gradient Meshes, Blends, Envelopes, Intertwine Groups, Opacity Masks and Objects with Effects. The Document category's Pattern Swatches row and the Objects category's Path Length row are translated in every catalog that has every menu label. `document.info` and the saved report keep the English labels. Showing these labels in the interface language is VectorCraft's own rule. Test: `i18n::tests::document_info_labels_are_in_complete_catalogs` (vectorcraft-ui-egui). The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). @@ -1531,12 +1566,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A function-based PDF shading (type 1) whose colour changes along one axis only, as a browser writes a repeating linear gradient, comes in as a linear gradient over its domain, each hard colour change two stops at one offset; one whose colour changes in two directions comes in as its average colour, with a warning. Either paints only over the shading's domain. Both came in as a flat grey that wasn't in the file (#3621). Each such shading is read once per import, and all of them within one budget of 200,000 function evaluations, so a file filling thousands of paths with long functions still opens in about a second (it took minutes); past the budget they come in as their average colour, then grey, with a warning. A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623). SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `