diff --git a/ROADMAP.md b/ROADMAP.md index e7d8f0bba..515e2824d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on: Arrange in Grid, Swap Objects, Replace with Top Object, Set Size of Each and Zoom to Selection; headline numbers unchanged; previously: OpenType SVG colour fonts draw and export as vector art; previously: COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. @@ -202,6 +203,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Progress log row for the Arrange Tools add-on; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux Flatpak clipboard's core Wayland protocol text fallback; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index ca6625540..2e153491b 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -1,6 +1,7 @@ //! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own //! with commands under `addon..*` (see `docs/mcp.md`). +mod arrange; mod cleanup; mod color; mod pathtools; @@ -10,7 +11,8 @@ use super::CommandSpec; /// The commands of every built-in add-on. pub fn specs() -> Vec { - let mut v = cleanup::specs(); + let mut v = arrange::specs(); + v.extend(cleanup::specs()); v.extend(color::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs new file mode 100644 index 000000000..2011559c3 --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -0,0 +1,272 @@ +//! Arrange Tools (`addon.arrange.*`), a built-in add-on: Arrange in Grid lays the selected objects +//! out in rows and columns, a row or a column; Swap Objects swaps the places (and sizes) of two; +//! Replace with Top Object puts a copy of the topmost selected object in place of each of the +//! others; and Set Size of Each gives every selected object an exact width and height. Places and +//! sizes are measured and set as `object.setBounds` does. Its submenu (Object › Plug-ins › Arrange +//! Tools), its dialogs and Zoom to Selection (a view command) are the UI's. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, OrientedBox}; +use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_render::effects; + +use super::super::edit::selected_roots; +use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; +use super::super::typecmd::refresh_bounds_of; +use super::super::{CommandSpec, bad, cmd, has_doc, has_multi, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// The largest gap, width or height (pt) the commands take. +const LIMIT: f64 = 1e6; +/// The smallest width or height (pt) Set Size of Each takes. +const LEAST: f64 = 0.001; +/// The most columns Arrange in Grid takes. +const MAX_COLUMNS: u64 = 10_000; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.arrange.grid", + "Arrange in Grid", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{layout?: \"grid\" (rows and columns) | \"row\" (one row) | \"column\" (one column), columns?: 1..10000 (grid; default ⌈√count⌉; never more than the count), gapX?: pt −1000000..1000000 (10), gapY?: pt −1000000..1000000 (gapX), centerOnArtboard?: false (true: the grid is centred on the artboard under the selection's centre, else the first; false: it starts at the selection's top-left)} lay the selected objects (the top-level ones) out in their stacking order from the back, left to right and top to bottom, as one undo step. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, each object is centred in its cell, and everything is measured by visual bounds (strokes and effects as drawn) → {count}", + has_selection, + grid + ), + cmd!( + "addon.arrange.swap", + "Swap Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{size?: false (true: each also takes the other's width and height), scaleStrokes?: the Scale Strokes & Effects preference} swap the places (bounds centres) of the two selected objects, measured as object.setBounds measures them (Use Preview Bounds), as one undo step. Off unless exactly two objects are selected", + exactly_two, + swap + ), + cmd!( + "addon.arrange.replace", + "Replace with Top Object", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{fit?: \"keep\" (the copy keeps its size) | \"bounds\" (scaled to the replaced object's width and height) | \"proportional\" (scaled to fit inside them, in proportion) (\"keep\"), scaleStrokes?: the Scale Strokes & Effects preference, keep?: false (true: the replaced objects stay, under their copies)} put a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order, as one undo step; sizes and centres are measured as object.setBounds measures them, and the copies are selected → {ids}", + has_multi, + replace + ), + cmd!( + "addon.arrange.size", + "Set Size of Each", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{width?: pt, height?: pt (at least one; each 0.001..1000000), proportional?: false (with only one of them given, the other follows in proportion; else it stays), reference?: 0..8 (the 9-point grid row by row from the top-left; 4, the centre: the point of each object that stays put), scaleStrokes?: the Scale Strokes & Effects preference} give each selected object (the top-level ones) that width and height, measured as object.setBounds measures them (Use Preview Bounds), as one undo step → {count}", + has_selection, + size + ), + ] +} + +/// Exactly two objects are selected (Swap Objects). +fn exactly_two(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let n = selected_roots(s).map_err(|e| e.to_string())?.len(); + if n == 2 { Ok(()) } else { Err("select exactly two objects".into()) } +} + +/// Number param `key` (`default` when left out) within `lo..=hi`. +fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str) -> Result { + let v = number_or(p, key, default, cmd)?; + if (lo..=hi).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// Whole-number param `key` within `lo..=hi`, `None` when left out. +fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?; + if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + +/// A width or height param of Set Size of Each, `None` when left out. +fn size_param(p: &Value, key: &str, cmd: &str) -> Result> { + p.get(key).filter(|v| !v.is_null()).map(|_| number_in(p, key, 0.0, (LEAST, LIMIT), cmd)).transpose() +} + +/// `object.setBounds` params that centre an object on `at`, with Scale Strokes & Effects `strokes` +/// and, when given, a width and height. +fn place(at: Point, size: Option<(f64, f64)>, strokes: bool) -> Value { + let mut q = json!({ "x": at.x, "y": at.y, "reference": 4, "strokes": strokes }); + if let Some((w, h)) = size { + q["width"] = json!(w); + q["height"] = json!(h); + } + q +} + +/// Transform each object of `xfs` by its own transform as undo step `label`, strokes and effects +/// scaling with Scale Strokes & Effects `strokes`, pattern tiles as Transform Pattern Tiles says. +/// When every transform is (within rounding) the identity, nothing changes. +fn transform_each(s: &mut Session, label: &str, strokes: bool, xfs: &[(NodeId, Affine)]) -> Result<()> { + let still = |xf: &Affine| xf.as_coeffs().iter().zip(Affine::IDENTITY.as_coeffs()).all(|(a, b)| (a - b).abs() <= 1e-9); + if xfs.iter().all(|(_, xf)| still(xf)) { + return Ok(()); + } + let p = json!({ "strokes": strokes }); + let ids: Vec = xfs.iter().map(|(id, _)| *id).collect(); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &ids)?; + s.edit(label, |d, _| { + for (id, xf) in xfs { + if let Some(n) = d.node_mut(*id) { + transform_node(n, *xf, sc, true); + } + } + Ok(()) + }) +} + +/// The box of object `id` as `object.setBounds` measures it. +fn box_of(s: &Session, id: NodeId) -> Result { + s.transform_box(&[id]).ok_or_else(|| EngineError::Other("no bounds".into())) +} + +fn grid(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.grid"; + let layout = choice(p, C, "layout", &["grid", "row", "column"])?.unwrap_or("grid"); + let columns = whole_in(p, "columns", (1, MAX_COLUMNS), C)?; + let gap_x = number_in(p, "gapX", 10.0, (-LIMIT, LIMIT), C)?; + let gap_y = number_in(p, "gapY", gap_x, (-LIMIT, LIMIT), C)?; + let center = switch_or(p, "centerOnArtboard", false, C)?; + let roots = selected_roots(s)?; + let d = &s.doc()?.doc; + let items: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], true)?))).collect(); + let Some(all) = items.iter().map(|(_, b)| *b).reduce(|a, b| a.union(b)) else { return Ok(json!({ "count": 0 })) }; + let n = items.len(); + let cols = match layout { + "row" => n, + "column" => 1, + _ => columns.map_or_else(|| (n as f64).sqrt().ceil() as usize, |c| usize::try_from(c).unwrap_or(n)), + } + .clamp(1, n); + let mut widths = vec![0.0_f64; cols]; + let mut heights = vec![0.0_f64; n.div_ceil(cols)]; + for (i, (_, b)) in items.iter().enumerate() { + if let Some(w) = widths.get_mut(i % cols) { + *w = w.max(b.width()); + } + if let Some(h) = heights.get_mut(i / cols) { + *h = h.max(b.height()); + } + } + let total = |sizes: &[f64], gap: f64| sizes.iter().sum::() + gap * sizes.len().saturating_sub(1) as f64; + let origin = if center { + let board = d.artboards.get(d.artboard_at(all.center()).unwrap_or(0)).map_or(all, |a| a.rect); + Point::new(board.center().x - total(&widths, gap_x) / 2.0, board.center().y - total(&heights, gap_y) / 2.0) + } else { + Point::new(all.x0, all.y0) + }; + // The middle of each column (row): the sizes and gaps before it, and half its own size. + let middles = |sizes: &[f64], gap: f64, from: f64| -> Vec { + sizes + .iter() + .scan(from, |at, size| { + let mid = *at + size / 2.0; + *at += size + gap; + Some(mid) + }) + .collect() + }; + let (xs, ys) = (middles(&widths, gap_x, origin.x), middles(&heights, gap_y, origin.y)); + let xfs: Vec<(NodeId, Affine)> = items + .iter() + .enumerate() + .filter_map(|(i, (id, b))| Some((*id, Affine::translate(Point::new(*xs.get(i % cols)?, *ys.get(i / cols)?) - b.center())))) + .collect(); + transform_each(s, "Arrange in Grid", false, &xfs)?; + Ok(json!({ "count": n })) +} + +fn swap(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.swap"; + let size = switch_or(p, "size", false, C)?; + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let roots = selected_roots(s)?; + let &[a, b] = roots.as_slice() else { return Err(bad(C, "select exactly two objects")) }; + let (ba, bb) = (box_of(s, a)?, box_of(s, b)?); + let to = |o: &OrientedBox| place(o.center(), size.then(|| (o.rect.width(), o.rect.height())), strokes); + let xfs = [(a, object_bounds_transform(s, &to(&bb), a)?), (b, object_bounds_transform(s, &to(&ba), b)?)]; + transform_each(s, "Swap Objects", strokes, &xfs)?; + Ok(json!({})) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.replace"; + let fit = choice(p, C, "fit", &["keep", "bounds", "proportional"])?.unwrap_or("keep"); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let keep = switch_or(p, "keep", false, C)?; + let roots = selected_roots(s)?; + let Some((&top, others)) = roots.split_last().filter(|(_, others)| !others.is_empty()) else { + return Err(bad(C, "select at least two objects")); + }; + let tb = box_of(s, top)?; + let (tw, th) = (tb.rect.width(), tb.rect.height()); + let mut xfs = Vec::with_capacity(others.len()); + for &id in others { + let Some(b) = s.transform_box(&[id]) else { continue }; + let (w, h) = (b.rect.width(), b.rect.height()); + let size = match fit { + "bounds" => Some((w, h)), + // The smaller of the two scales that fit; a side the copy doesn't have doesn't count. + "proportional" => { + [(w, tw), (h, th)].into_iter().filter(|(_, t)| *t > 1e-9).map(|(v, t)| v / t).reduce(f64::min).map(|k| (tw * k, th * k)) + } + _ => None, + }; + xfs.push((id, object_bounds_transform(s, &place(b.center(), size, strokes), top)?)); + } + let p = json!({ "strokes": strokes }); + let mut sc = scaling(s, &p); + sc.patterns = transform_patterns(s, &p, &[top])?; + let ids = s.edit("Replace with Top Object", |d, sel| { + let Some(n) = d.node(top).cloned() else { return Ok(vec![]) }; + let mut items = Vec::with_capacity(xfs.len()); + for (id, xf) in &xfs { + let mut c = d.reid(&n); + transform_node(&mut c, *xf, sc, true); + items.push((*id, vec![c])); + } + let out: Vec = items.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect(); + d.insert_beside(items, !keep)?; + refresh_bounds_of(d, &out); + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn size(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.size"; + let width = size_param(p, "width", C)?; + let height = size_param(p, "height", C)?; + if width.is_none() && height.is_none() { + return Err(bad(C, "give `width` or `height`")); + } + let proportional = switch_or(p, "proportional", false, C)?; + let reference = whole_in(p, "reference", (0, 8), C)?.unwrap_or(4); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let mut q = json!({ "reference": reference, "strokes": strokes, "proportional": proportional && (width.is_none() || height.is_none()) }); + if let Some(w) = width { + q["width"] = json!(w); + } + if let Some(h) = height { + q["height"] = json!(h); + } + let mut xfs = vec![]; + for id in selected_roots(s)? { + if s.transform_box(&[id]).is_some() { + xfs.push((id, object_bounds_transform(s, &q, id)?)); + } + } + transform_each(s, "Set Size of Each", strokes, &xfs)?; + Ok(json!({ "count": xfs.len() })) +} diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs new file mode 100644 index 000000000..db19cc1e9 --- /dev/null +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -0,0 +1,263 @@ +//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off, +//! and bad params refused without a change. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Rect; + +use crate::Session; + +/// A new document whose new art has no stroke, so visual and geometric bounds agree. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool { + [(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Each of `bad` is refused and leaves the document as it was. +fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) { + let (before, n) = (s.doc().unwrap().doc.clone(), steps(s)); + for p in bad { + assert!(s.execute(cmd, p).is_err(), "{cmd} {p}"); + } + assert_eq!(steps(s), n, "{cmd}"); + assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document"); +} + +fn enabled(s: &Session, cmd: &str) -> bool { + s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled +} + +#[test] +fn grid_lays_objects_out_in_rows_and_columns_from_the_back() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0); + let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0); + let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0); + let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0); + select(&mut s, &[d, c, b, a]); + let n = steps(&s); + // Four objects: two columns. The grid starts at the selection's top-left (10, 5). + let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap(); + assert_eq!(r["count"], json!(4)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred. + assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0))); +} + +#[test] +fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap(); + assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10"); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap(); + assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX"); + // 20 × 44 centred on the 400 × 300 artboard. + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a)); + // In place already: no step. + let n = steps(&s); + s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap(); + assert_eq!(steps(&s), n); + // More columns than objects: one row. + s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap(); + assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn grid_measures_visual_bounds() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap(); + // Visual boxes 24 wide, touching. + assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_trades_places_and_optionally_sizes() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.swap")); + select(&mut s, &[a, b, c]); + assert!(!enabled(&s, "addon.arrange.swap")); + refuses(&mut s, "addon.arrange.swap", &[json!({})]); + select(&mut s, &[a, b]); + assert!(enabled(&s, "addon.arrange.swap")); + let n = steps(&s); + s.execute("addon.arrange.swap", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + // Centres (10, 5) and (120, 120) swap; sizes stay. + assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b)); +} + +#[test] +fn swap_with_sizes_scales_strokes_as_asked() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0); + select(&mut s, &[a, b]); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width(); + s.execute("addon.arrange.swap", &json!({"size": true})).unwrap(); + assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap(); + assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b)); +} + +#[test] +fn replace_puts_a_copy_of_the_top_object_in_each_place() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[top]); + assert!(!enabled(&s, "addon.arrange.replace")); + refuses(&mut s, "addon.arrange.replace", &[json!({})]); + select(&mut s, &[a, b, top]); + let n = steps(&s); + let r = s.execute("addon.arrange.replace", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let st = s.doc().unwrap(); + assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted"); + assert!(st.doc.node(top).is_some(), "the top object stays"); + assert_eq!(st.selection.objects, ids, "the copies are selected"); + // Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle). + assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0])); + assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1])); + let layer = &s.doc().unwrap().doc.layers[0]; + let order: Vec = layer.children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [ids[0], ids[1], top]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).is_some()); +} + +#[test] +fn replace_fits_the_copies_and_can_keep_the_originals() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0); + let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy)); + let order: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(order, [a, copy, top], "kept: the copy goes right above it"); + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[a, top]); + let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + // 10 × 5 into 20 × 40: twice as large, centred on (10, 20). + assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy)); +} + +#[test] +fn size_gives_each_object_a_width_and_height() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + let n = steps(&s); + s.execute("addon.arrange.size", &json!({"width": 30})).unwrap(); + assert_eq!(steps(&s), n + 1); + // About each centre; the height stays. + assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b)); + s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap(); + assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a)); +} + +#[test] +fn bad_params_are_refused() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0); + select(&mut s, &[a, b]); + refuses( + &mut s, + "addon.arrange.grid", + &[ + json!({"layout": "spiral"}), + json!({"columns": 0}), + json!({"columns": 1.5}), + json!({"columns": 1e12}), + json!({"gapX": 1e308}), + json!({"gapY": "10"}), + json!({"centerOnArtboard": "yes"}), + ], + ); + refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]); + refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]); + refuses( + &mut s, + "addon.arrange.size", + &[ + json!({}), + json!({"width": 0}), + json!({"height": -5}), + json!({"width": 1e308}), + json!({"width": "10"}), + json!({"width": 10, "reference": 9}), + json!({"width": 10, "proportional": "yes"}), + ], + ); +} + +#[test] +fn off_without_a_selection() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 20.0, 10.0); + s.execute("select.none", &json!({})).unwrap(); + for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size"] { + assert!(!enabled(&s, c), "{c}"); + } + assert!(!enabled(&Session::new(), "addon.arrange.swap")); +} diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4839a0ac4..bac3540fa 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -32,7 +32,7 @@ mod transform; pub(crate) use align::choice; pub(crate) use arrange::group_nodes; -pub(crate) use bounds::bounds_rewrite; +pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; pub(super) use shape::recut; diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 2e0747170..9d4795235 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -6,7 +6,7 @@ use super::{ }; pub(super) fn set_bounds(s: &mut Session, p: &Value) -> Result { - match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? { + match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0, true)? { Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b), Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false), } @@ -32,7 +32,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<( if cmd != "object.setBounds" { return None; } - let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None }; + let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4, true) else { return None }; let mut p = params.as_object().cloned().unwrap_or_default(); for k in ["x", "y", "width", "height", "reference", "proportional"] { p.remove(k); @@ -41,14 +41,26 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<( Some(("object.transform".to_string(), Value::Object(p))) } +/// The transform that gives object `id` on its own the place and size `p` asks for, as +/// `object.setBounds` measures them (Use Preview Bounds, `strokes`, `reference`, `proportional`), +/// whatever anchors are direct-selected. The art keeps at least 1e-4 of its width and height. +pub(crate) fn object_bounds_transform(s: &Session, p: &Value, id: NodeId) -> Result { + match bounds_transform(s, p, vec![id], 1e-4, false)? { + Bounds::Objects(_, xf) => Ok(xf), + Bounds::Anchors(..) => Err(EngineError::Other("no bounds".into())), + } +} + /// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the -/// art with a width or height below `least` of the one it has keeps `least` of it. -fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64) -> Result { +/// art with a width or height below `least` of the one it has keeps `least` of it. With `anchors`, +/// direct-selected anchors are what changes ([`Bounds::Anchors`]). +fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchors: bool) -> Result { check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?; let st = s.doc()?; check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; check_switches(p, &["proportional"], "object.setBounds")?; - if transforms_objects(p) + if anchors + && transforms_objects(p) && !bool_or(p, "copy", false) && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) { diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index e80e93ce8..69c69097e 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -579,6 +579,15 @@ fn structured_junk() { ("addon.pathtools.movePointsTogether", json!({"enabled": true})), ("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})), ("addon.pathtools.movePointsTogether", json!({"enabled": false})), + // Arrange Tools add-on. + ("addon.arrange.grid", json!({"columns": u64::MAX, "gapX": 1e6, "gapY": -1e6, "centerOnArtboard": true})), + ("addon.arrange.grid", json!({"layout": "row", "gapX": f64::MAX, "columns": -1})), + ("addon.arrange.grid", json!({"layout": "column", "gapX": -1e6})), + ("addon.arrange.swap", json!({"size": true, "scaleStrokes": true})), + ("addon.arrange.replace", json!({"fit": "proportional", "keep": true})), + ("addon.arrange.replace", json!({"fit": "bounds", "scaleStrokes": true})), + ("addon.arrange.size", json!({"width": 1e6, "height": 0.001, "reference": 8, "scaleStrokes": true})), + ("addon.arrange.size", json!({"width": 0.001, "proportional": true, "reference": u64::MAX})), // Smart Pen add-on. ("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})), ("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})), diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index 36106b72b..4d148ff58 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -1,6 +1,7 @@ //! VectorCraft's built-in add-ons in the interface: each has a panel and a submenu at the top of //! Object › Plug-ins, and acts through its engine commands (`addon..*`). +pub mod arrange; pub mod cleanup; pub mod color; pub mod pathtools; @@ -11,6 +12,7 @@ use crate::menus::Item; /// The add-ons' submenus, then a separator: the head of Object › Plug-ins. pub fn plugin_menu() -> Vec { vec![ + Item::Sub("Arrange Tools", arrange::menu()), Item::Sub("Artwork Cleanup", cleanup::menu()), Item::Sub("Color Effects", color::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), diff --git a/crates/ui-egui/src/addons/arrange.rs b/crates/ui-egui/src/addons/arrange.rs new file mode 100644 index 000000000..8435483cb --- /dev/null +++ b/crates/ui-egui/src/addons/arrange.rs @@ -0,0 +1,175 @@ +//! Arrange Tools in the interface: its submenu (Object › Plug-ins › Arrange Tools); the dialogs of +//! Arrange in Grid, Replace with Top Object and Set Size of Each, which preview on the canvas and +//! run their `addon.arrange.*` command on OK; and Zoom to Selection ([`ZOOM`]), which fits the +//! active view to the selection. + +use egui::Ui; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::dialogs::{DialogSpec, form}; +use crate::menus::Item; +use crate::state::Dialog; +use crate::widgets; + +/// Zoom to Selection: a view command, since the view is the interface's. +pub const ZOOM: &str = "addon.arrange.zoomSelection"; +/// The dialogs' kinds. +pub const GRID: &str = "arrangeGrid"; +pub const REPLACE: &str = "arrangeReplace"; +pub const SIZE: &str = "arrangeSize"; +/// Each dialog's kind, command and heading. +const DIALOGS: [(&str, &str, &str); 3] = [ + (GRID, "addon.arrange.grid", "Arrange in Grid"), + (REPLACE, "addon.arrange.replace", "Replace with Top Object"), + (SIZE, "addon.arrange.size", "Set Size of Each"), +]; +/// The widths and heights Set Size of Each takes (pt), and the gaps Arrange in Grid takes. +const SIZES: std::ops::RangeInclusive = 0.001..=1e6; +const GAPS: std::ops::RangeInclusive = -1e6..=1e6; +const LABEL_W: f32 = 110.0; +const FIELD_W: f32 = 90.0; + +/// Object › Plug-ins › Arrange Tools. +pub fn menu() -> Vec { + vec![ + Item::Cmd("Arrange in Grid…", "addon.arrange.grid", Value::Null), + Item::Cmd("Swap Objects", "addon.arrange.swap", json!({})), + Item::Cmd("Swap Objects and Sizes", "addon.arrange.swap", json!({ "size": true })), + Item::Cmd("Replace with Top Object…", "addon.arrange.replace", Value::Null), + Item::Cmd("Set Size of Each…", "addon.arrange.size", Value::Null), + Item::Sep, + Item::Cmd("Zoom to Selection", ZOOM, json!({})), + ] +} + +/// The dialog (kind, fields) the menu item of command `id` opens; `None` for other commands and +/// without a document. +pub(crate) fn dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { + let &(kind, ..) = DIALOGS.iter().find(|(_, cmd, _)| *cmd == id)?; + let st = app.session.active()?; + let strokes = app.session.prefs.scale_strokes; + let fields = match kind { + // A square grid to start with: as many columns as the square root of the count. + GRID => json!({ + "layout": "grid", "columns": (st.selection.objects.len().max(1) as f64).sqrt().ceil() as u64, + "gapX": 10, "gapY": 10, "centerOnArtboard": false, + }), + REPLACE => json!({ "fit": "keep", "keep": false, "scaleStrokes": strokes }), + // The first selected object's size, as the command measures it (a side it lacks: the least). + _ => { + let b = st.selection.objects.first().and_then(|id| app.session.transform_box(&[*id])).map_or((100.0, 100.0), |b| { + (b.rect.width().clamp(*SIZES.start(), *SIZES.end()), b.rect.height().clamp(*SIZES.start(), *SIZES.end())) + }); + json!({ + "width": b.0, "height": b.1, "__width": true, "__height": true, "proportional": false, "reference": 4, "scaleStrokes": strokes, + }) + } + }; + Some((kind, fields)) +} + +pub(crate) const SPEC: DialogSpec = DialogSpec { + heading: |d| tl!(command(d).1).to_string(), + body, + confirm, + preview: true, + min_width: 320.0, + ok_enabled: Some(|_, d| d.kind != SIZE || d.bool("__width") || d.bool("__height")), + ..DialogSpec::FORM +}; + +/// The command and heading of dialog `d`. +fn command(d: &Dialog) -> (&'static str, &'static str) { + DIALOGS.iter().find(|(kind, ..)| *kind == d.kind).map_or(("", ""), |&(_, cmd, label)| (cmd, label)) +} + +/// The command's params: the fields, and of Set Size of Each's width and height those ticked. +fn params(d: &Dialog) -> Value { + let mut p = form::params(d); + if let Some(o) = p.as_object_mut() { + for side in ["width", "height"] { + if !d.bool(&format!("__{side}")) { + o.remove(side); + } + } + } + p +} + +fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + let widths = (LABEL_W, 150.0); + match d.kind.as_str() { + GRID => { + let layouts = [("grid", "Grid by Row"), ("row", "Arrange by Row"), ("column", "Arrange by Column")]; + form::choice(ui, d, "layout", tl!("Layout:"), widths, &layouts); + ui.add_enabled_ui(d.str("layout") == "grid", |ui| { + widgets::label_row(ui, tl!("Columns:"), LABEL_W, |ui| { + if let Some(v) = widgets::range_field(ui, ("arrange", "columns"), d.f64("columns", 1.0), 1.0..=10_000.0, "", 0, FIELD_W) { + d.fields.insert("columns".into(), json!(v as u64)); + } + }); + }); + widgets::label_row(ui, tl!("H Spacing:"), LABEL_W, |ui| { + form::length_within(ui, d, "gapX", unit, FIELD_W, Some(GAPS)); + }); + widgets::label_row(ui, tl!("V Spacing:"), LABEL_W, |ui| { + form::length_within(ui, d, "gapY", unit, FIELD_W, Some(GAPS)); + }); + form::check(ui, d, "centerOnArtboard", tl!("Center on Artboard")); + } + REPLACE => { + let fits = [("keep", "Actual Size"), ("bounds", "Bounds"), ("proportional", "Bounds (Fit Proportionally)")]; + form::choice(ui, d, "fit", tl!("Size:"), widths, &fits); + form::check(ui, d, "keep", tl!("Keep Original")); + form::check(ui, d, "scaleStrokes", tl!("Scale Strokes & Effects")); + } + _ => { + egui::Grid::new("arrange-size").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (side, tick, label) in [("width", "__width", tl!("Width:")), ("height", "__height", tl!("Height:"))] { + form::check(ui, d, tick, label); + let on = d.bool(tick); + ui.add_enabled_ui(on, |ui| { + form::length_within(ui, d, side, unit, FIELD_W, Some(SIZES)); + }); + ui.end_row(); + } + }); + // Only one size given: the other can follow it. + let one = d.bool("__width") != d.bool("__height"); + if widgets::check(ui, tl!("Constrain Width and Height Proportions"), d.bool("proportional"), one) { + let on = !d.bool("proportional"); + d.fields.insert("proportional".into(), json!(on)); + } + ui.horizontal(|ui| { + if let Some(i) = widgets::reference_point(ui, d.f64("reference", 4.0).clamp(0.0, 8.0) as usize) { + d.fields.insert("reference".into(), json!(i)); + } + widgets::dim_label(ui, tl!("Reference Point")); + }); + form::check(ui, d, "scaleStrokes", tl!("Scale Strokes & Effects")); + } + } + let (cmd, label) = command(d); + form::preview(app, ui, d, label, cmd, params(d)); + false +} + +/// OK: run the command (keep its preview) and close. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, command(d).0, params(d)) +} + +/// Zoom to Selection: the active view zooms and centres so the selection's bounds fill the +/// window, with a margin. An error with nothing selected. +pub(crate) fn zoom(app: &mut VectorcraftApp) -> Result { + if app.session.active().is_none_or(|st| st.selection.objects.is_empty()) { + return Err("nothing selected".into()); + } + crate::canvas::fit(app, ZOOM); + Ok(Value::Null) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/arrange/tests.rs b/crates/ui-egui/src/addons/arrange/tests.rs new file mode 100644 index 000000000..63e58448b --- /dev/null +++ b/crates/ui-egui/src/addons/arrange/tests.rs @@ -0,0 +1,127 @@ +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use super::{GRID, REPLACE, SIZE, ZOOM}; +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +/// An app with two rectangles without a stroke, both selected. +fn app() -> (VectorcraftApp, NodeId, NodeId) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + let mut rect = |x, y, w, h| NodeId(app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()); + let (a, b) = (rect(0, 0, 20, 10), rect(100, 100, 40, 40)); + app.run("select.all", json!({})).unwrap(); + (app, a, b) +} + +fn bounds(app: &VectorcraftApp, id: NodeId) -> vectorcraft_geom::Rect { + app.session.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +/// Click the Arrange Tools item `label`. +fn click(app: &mut VectorcraftApp, label: &str) { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Arrange Tools"] && e.label == label).expect(label); + let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &id, p); +} + +#[test] +fn the_plugins_submenu_lists_the_tools() { + let (app, ..) = app(); + let entries = menus::menu_entries(&app); + let under = |label: &str| entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Arrange Tools"] && e.label == label); + for (label, id) in [ + ("Arrange in Grid…", "addon.arrange.grid"), + ("Swap Objects", "addon.arrange.swap"), + ("Swap Objects and Sizes", "addon.arrange.swap"), + ("Replace with Top Object…", "addon.arrange.replace"), + ("Set Size of Each…", "addon.arrange.size"), + ("Zoom to Selection", ZOOM), + ] { + assert_eq!(under(label).and_then(|e| e.command.as_deref()), Some(id), "{label}"); + } + assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins"] && e.label == "Install Plug-in…"), "the rest follows"); +} + +#[test] +fn arrange_in_grid_opens_its_dialog_and_runs_on_ok() { + let (mut app, a, b) = app(); + click(&mut app, "Arrange in Grid…"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(GRID)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Arrange in Grid", "Layout:", "Columns:", "H Spacing:", "V Spacing:", "Center on Artboard", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["columns"], json!(2), "√2, rounded up"); + d.fields.insert("layout".into(), json!("row")); + let n = app.session.doc().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1); + assert_eq!((bounds(&app, a).x0, bounds(&app, b).x0, bounds(&app, b).y0), (0.0, 30.0, 0.0)); +} + +#[test] +fn arrange_in_grid_previews_and_cancel_rolls_back() { + let (mut app, _, b) = app(); + click(&mut app, "Arrange in Grid…"); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(bounds(&app, b).x0, 30.0, "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(bounds(&app, b).x0, 100.0, "rolled back"); +} + +#[test] +fn set_size_of_each_sets_the_sides_ticked() { + let (mut app, a, b) = app(); + click(&mut app, "Set Size of Each…"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(SIZE)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Set Size of Each", "Width:", "Height:", "Constrain Width and Height Proportions", "Reference Point", "Scale Strokes & Effects"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((&d.fields["width"], &d.fields["height"]), (&json!(20.0), &json!(10.0)), "the first object's size"); + d.fields.insert("width".into(), json!(30)); + d.fields.insert("__height".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((bounds(&app, a).width(), bounds(&app, a).height()), (30.0, 10.0)); + assert_eq!((bounds(&app, b).width(), bounds(&app, b).height()), (30.0, 40.0)); +} + +#[test] +fn replace_opens_with_the_scale_strokes_preference_and_swap_runs_directly() { + let (mut app, a, b) = app(); + app.run("prefs.set", json!({"key": "scaleStrokes", "value": true})).unwrap(); + click(&mut app, "Replace with Top Object…"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), &d.fields["scaleStrokes"], &d.fields["fit"]), (REPLACE, &json!(true), &json!("keep"))); + crate::dialogs::cancel(&mut app); + click(&mut app, "Swap Objects"); + assert!(app.ui.dialog.is_none()); + assert_eq!( + (bounds(&app, a).center(), bounds(&app, b).center()), + (vectorcraft_geom::Point::new(120.0, 120.0), vectorcraft_geom::Point::new(10.0, 5.0)) + ); +} + +#[test] +fn zoom_to_selection_fits_the_selection_in_the_view() { + let (mut app, _, b) = app(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(560.0, 460.0))); + app.run("select.set", json!({"ids": [b.0]})).unwrap(); + assert!(menus::enabled(&app, ZOOM)); + app.run(ZOOM, json!({})).unwrap(); + let v = app.view().unwrap(); + // 40 × 40 into 500 × 400 (the window less the margin). + assert_eq!((v.center, v.zoom), (vectorcraft_geom::Point::new(120.0, 120.0), 10.0)); + app.run("select.none", json!({})).unwrap(); + assert!(!menus::enabled(&app, ZOOM)); + assert!(app.run(ZOOM, json!({})).is_err()); +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index add171611..b25f36b9d 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -1,7 +1,7 @@ use super::drag::{drag_id, drop_press}; use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2}; -/// Fit the view (View → Fit Artboard / Fit All / Actual Size). +/// Fit the view (View → Fit Artboard / Fit All / Actual Size, Arrange Tools › Zoom to Selection). pub fn fit(app: &mut VectorcraftApp, how: &str) { let Some(rect) = app.canvas_rect else { if let Some(v) = app.view_mut() { @@ -15,6 +15,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { "view.fitAll" => { st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)) } + crate::addons::arrange::ZOOM => st.doc.bounds_of(&st.selection.objects, true), // The navigator's artboard (the first if it has gone). _ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect), }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index acbad1750..83e7850f4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -39,7 +39,7 @@ pub mod find_links; pub mod flare_options; pub mod flatten; pub mod flattener_presets; -mod form; +pub(crate) mod form; pub(crate) mod free_distort; pub mod freehand; mod gradient_stop; @@ -387,6 +387,7 @@ registry! { PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC, RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC, PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC, + ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE] => crate::addons::arrange::SPEC, ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm), } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index ce0177750..ed6be47e8 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -49,7 +49,7 @@ pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, w /// [`length`] whose value is kept within `range` (points): a value typed outside it becomes the /// nearer end, so the field shows the distance the command applies (#4637). -pub(super) fn length_within( +pub(crate) fn length_within( ui: &mut egui::Ui, d: &mut Dialog, key: &str, @@ -247,7 +247,7 @@ pub(super) fn text_area_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name } /// A checkbox bound to `d.fields[key]` (its box shows while unchecked too). -pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { +pub(crate) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let b = d.bool(key); if crate::widgets::check(ui, label, b, true) { d.fields.insert(key.into(), Value::Bool(!b)); @@ -256,7 +256,7 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { /// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the /// dropdown `widths.1` wide; `options` are (value, label). -pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) { +pub(crate) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) { crate::widgets::label_row(ui, label, widths.0, |ui| dropdown(ui, d, key, widths.1, options, false)); } @@ -339,7 +339,7 @@ pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: imp } /// Command/effect parameters from the dialog fields (drop UI-only keys). -pub(super) fn params(d: &Dialog) -> Value { +pub(crate) fn params(d: &Dialog) -> Value { Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect()) } @@ -817,7 +817,7 @@ pub(super) const PREVIEWED: &str = "__previewed"; /// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run /// again on the interaction's snapshot whenever they differ from the last preview (whether a widget /// or `ui.dialog.set` changed them); turning it off rolls back. -pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) { +pub(crate) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) { let _ = preview_result(app, ui, d, label, cmd, params); } @@ -866,7 +866,7 @@ pub(super) fn preview_now(app: &mut crate::VectorcraftApp, d: &mut Dialog, label /// OK of a dialog using [`preview`]: keep the previewed `cmd` as one undo step (or run it when no /// preview runs) and close; the command's warning shows in the status bar either way. On an error /// the dialog stays open. -pub(super) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: Value) -> Result { +pub(crate) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: Value) -> Result { let r = if app.session.in_interaction() { match app.session.preview(cmd, ¶ms) { Ok(v) => { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 99f0bbc2d..5b65923a1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5891,3 +5891,15 @@ breakText Glyphs Глифи Gap 4 Разстояние 4 Dash 5 Тире 5 Gap 5 Разстояние 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Инструменти за подреждане + Arrange in Grid… Подреждане в мрежа… + Arrange in Grid Подреждане в мрежа + Swap Objects Размяна на обекти + Swap Objects and Sizes Размяна на обекти и размери + Replace with Top Object… Замяна с горния обект… + Replace with Top Object Замяна с горния обект + Set Size of Each… Задаване на размер на всеки… + Set Size of Each Задаване на размер на всеки +@msg select exactly two objects изберете точно два обекта +@msg give `width` or `height` задайте `width` или `height` diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 700fd9dde..5798a7a79 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5924,3 +5924,15 @@ shape Open Otvoren @msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b" @msg the paths have nothing to merge there putanje tu nemaju šta spojiti @msg the path is locked or hidden putanja je zaključana ili skrivena +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Alati za raspoređivanje + Arrange in Grid… Rasporedi u mrežu… + Arrange in Grid Rasporedi u mrežu + Swap Objects Zamijeni objekte + Swap Objects and Sizes Zamijeni objekte i veličine + Replace with Top Object… Zamijeni gornjim objektom… + Replace with Top Object Zamijeni gornjim objektom + Set Size of Each… Postavi veličinu svakog… + Set Size of Each Postavi veličinu svakog +@msg select exactly two objects odaberite tačno dva objekta +@msg give `width` or `height` zadajte `width` ili `height` diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dc8fc7eff..d93092418 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5991,3 +5991,15 @@ breakText Glyphs Glifs Gap 4 Separació 4 Dash 5 Guió 5 Gap 5 Separació 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Eines d’organització + Arrange in Grid… Organitza en quadrícula… + Arrange in Grid Organitza en quadrícula + Swap Objects Intercanvia els objectes + Swap Objects and Sizes Intercanvia els objectes i les mides + Replace with Top Object… Reemplaça per l’objecte superior… + Replace with Top Object Reemplaça per l’objecte superior + Set Size of Each… Defineix la mida de cadascun… + Set Size of Each Defineix la mida de cadascun +@msg select exactly two objects selecciona exactament dos objectes +@msg give `width` or `height` indica `width` o `height` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5498e3548..50a8f953e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6000,3 +6000,15 @@ breakText Glyphs Glyfy Gap 4 Mezera 4 Dash 5 Čárka 5 Gap 5 Mezera 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Nástroje uspořádání + Arrange in Grid… Uspořádat do mřížky… + Arrange in Grid Uspořádat do mřížky + Swap Objects Prohodit objekty + Swap Objects and Sizes Prohodit objekty a velikosti + Replace with Top Object… Nahradit horním objektem… + Replace with Top Object Nahradit horním objektem + Set Size of Each… Nastavit velikost jednotlivě… + Set Size of Each Nastavit velikost jednotlivě +@msg select exactly two objects vyberte právě dva objekty +@msg give `width` or `height` zadejte `width` nebo `height` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index c4e19d993..87249badf 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5905,3 +5905,15 @@ breakText Glyphs Glyffer Gap 4 Mellemrum 4 Dash 5 Streg 5 Gap 5 Mellemrum 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Arrangeringsværktøjer + Arrange in Grid… Arrangér i gitter… + Arrange in Grid Arrangér i gitter + Swap Objects Byt objekter + Swap Objects and Sizes Byt objekter og størrelser + Replace with Top Object… Erstat med øverste objekt… + Replace with Top Object Erstat med øverste objekt + Set Size of Each… Angiv størrelse for hver… + Set Size of Each Angiv størrelse for hver +@msg select exactly two objects markér præcis to objekter +@msg give `width` or `height` angiv `width` eller `height` diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 87ac4fc70..513ff8e4f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5586,3 +5586,15 @@ breakText Glyphs Glyphen Gap 4 Lücke 4 Dash 5 Strich 5 Gap 5 Lücke 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Anordnungswerkzeuge + Arrange in Grid… In Raster anordnen… + Arrange in Grid In Raster anordnen + Swap Objects Objekte vertauschen + Swap Objects and Sizes Objekte und Größen vertauschen + Replace with Top Object… Durch oberstes Objekt ersetzen… + Replace with Top Object Durch oberstes Objekt ersetzen + Set Size of Each… Größe einzeln festlegen… + Set Size of Each Größe einzeln festlegen +@msg select exactly two objects wählen Sie genau zwei Objekte aus +@msg give `width` or `height` geben Sie `width` oder `height` an diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c7c2ab898..ce282989f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5953,3 +5953,15 @@ breakText Glyphs Γλύφοι Gap 4 Διάκενο 4 Dash 5 Παύλα 5 Gap 5 Διάκενο 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Εργαλεία διάταξης + Arrange in Grid… Διάταξη σε πλέγμα… + Arrange in Grid Διάταξη σε πλέγμα + Swap Objects Εναλλαγή αντικειμένων + Swap Objects and Sizes Εναλλαγή αντικειμένων και μεγεθών + Replace with Top Object… Αντικατάσταση με το επάνω αντικείμενο… + Replace with Top Object Αντικατάσταση με το επάνω αντικείμενο + Set Size of Each… Ορισμός μεγέθους καθενός… + Set Size of Each Ορισμός μεγέθους καθενός +@msg select exactly two objects επιλέξτε ακριβώς δύο αντικείμενα +@msg give `width` or `height` δώστε `width` ή `height` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b517c4c79..fe945423e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5943,3 +5943,15 @@ breakText Glyphs Glifos Gap 4 Hueco 4 Dash 5 Guion 5 Gap 5 Hueco 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Herramientas de organización + Arrange in Grid… Organizar en cuadrícula… + Arrange in Grid Organizar en cuadrícula + Swap Objects Intercambiar objetos + Swap Objects and Sizes Intercambiar objetos y tamaños + Replace with Top Object… Reemplazar por el objeto superior… + Replace with Top Object Reemplazar por el objeto superior + Set Size of Each… Definir el tamaño de cada uno… + Set Size of Each Definir el tamaño de cada uno +@msg select exactly two objects selecciona exactamente dos objetos +@msg give `width` or `height` indica `width` o `height` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 92f4e4894..7ebd23d6b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6031,3 +6031,15 @@ breakText Glyphs Merkit Gap 4 Väli 4 Dash 5 Viiva 5 Gap 5 Väli 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Järjestelytyökalut + Arrange in Grid… Järjestä ruudukkoon… + Arrange in Grid Järjestä ruudukkoon + Swap Objects Vaihda objektien paikat + Swap Objects and Sizes Vaihda objektien paikat ja koot + Replace with Top Object… Korvaa ylimmällä objektilla… + Replace with Top Object Korvaa ylimmällä objektilla + Set Size of Each… Aseta kunkin koko… + Set Size of Each Aseta kunkin koko +@msg select exactly two objects valitse täsmälleen kaksi objektia +@msg give `width` or `height` anna `width` tai `height` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 27a2d5201..859b2bfcd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5999,3 +5999,15 @@ breakText Glyphs Glyphes Gap 4 Écart 4 Dash 5 Tiret 5 Gap 5 Écart 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Outils de disposition + Arrange in Grid… Disposer en grille… + Arrange in Grid Disposer en grille + Swap Objects Permuter les objets + Swap Objects and Sizes Permuter les objets et leurs tailles + Replace with Top Object… Remplacer par l’objet supérieur… + Replace with Top Object Remplacer par l’objet supérieur + Set Size of Each… Définir la taille de chaque objet… + Set Size of Each Définir la taille de chaque objet +@msg select exactly two objects sélectionnez exactement deux objets +@msg give `width` or `height` indiquez `width` ou `height` diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ec6347305..02e7303ed 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5987,3 +5987,15 @@ breakText Glyphs Glifák Gap 4 Hézag 4 Dash 5 Vonal 5 Gap 5 Hézag 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Elrendezőeszközök + Arrange in Grid… Elrendezés rácsba… + Arrange in Grid Elrendezés rácsba + Swap Objects Objektumok cseréje + Swap Objects and Sizes Objektumok és méretek cseréje + Replace with Top Object… Csere a legfelső objektumra… + Replace with Top Object Csere a legfelső objektumra + Set Size of Each… Egyenkénti méret beállítása… + Set Size of Each Egyenkénti méret beállítása +@msg select exactly two objects jelölj ki pontosan két objektumot +@msg give `width` or `height` add meg a `width` vagy a `height` értéket diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f958f41b1..c2d369575 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5905,3 +5905,15 @@ breakText Glyphs Glif Gap 4 Jarak 4 Dash 5 Garis 5 Gap 5 Jarak 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Alat penyusunan + Arrange in Grid… Susun dalam grid… + Arrange in Grid Susun dalam grid + Swap Objects Tukar objek + Swap Objects and Sizes Tukar objek dan ukuran + Replace with Top Object… Ganti dengan objek teratas… + Replace with Top Object Ganti dengan objek teratas + Set Size of Each… Atur ukuran masing-masing… + Set Size of Each Atur ukuran masing-masing +@msg select exactly two objects pilih tepat dua objek +@msg give `width` or `height` berikan `width` atau `height` diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a326cf55f..8021409d2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5620,3 +5620,15 @@ breakText Glyphs Glifi Gap 4 Spazio 4 Dash 5 Tratto 5 Gap 5 Spazio 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Strumenti di disposizione + Arrange in Grid… Disponi in griglia… + Arrange in Grid Disponi in griglia + Swap Objects Scambia oggetti + Swap Objects and Sizes Scambia oggetti e dimensioni + Replace with Top Object… Sostituisci con l’oggetto superiore… + Replace with Top Object Sostituisci con l’oggetto superiore + Set Size of Each… Imposta dimensione di ciascuno… + Set Size of Each Imposta dimensione di ciascuno +@msg select exactly two objects seleziona esattamente due oggetti +@msg give `width` or `height` indica `width` o `height` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 50afc7d72..269651408 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5686,3 +5686,15 @@ breakText Glyphs 字形 Gap 4 間隔 4 Dash 5 線分 5 Gap 5 間隔 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 配置ツール + Arrange in Grid… グリッドに配置… + Arrange in Grid グリッドに配置 + Swap Objects オブジェクトを入れ替え + Swap Objects and Sizes オブジェクトとサイズを入れ替え + Replace with Top Object… 最前面のオブジェクトで置き換え… + Replace with Top Object 最前面のオブジェクトで置き換え + Set Size of Each… 個別にサイズを設定… + Set Size of Each 個別にサイズを設定 +@msg select exactly two objects オブジェクトをちょうど 2 つ選択してください +@msg give `width` or `height` `width` または `height` を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8b52357ad..fd0eaae44 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5990,3 +5990,15 @@ breakText Glyphs გლიფები Gap 4 შუალედი 4 Dash 5 ტირე 5 Gap 5 შუალედი 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools განლაგების ხელსაწყოები + Arrange in Grid… ბადეში განლაგება… + Arrange in Grid ბადეში განლაგება + Swap Objects ობიექტების გაცვლა + Swap Objects and Sizes ობიექტებისა და ზომების გაცვლა + Replace with Top Object… ზედა ობიექტით ჩანაცვლება… + Replace with Top Object ზედა ობიექტით ჩანაცვლება + Set Size of Each… თითოეულის ზომის დაყენება… + Set Size of Each თითოეულის ზომის დაყენება +@msg select exactly two objects მონიშნეთ ზუსტად ორი ობიექტი +@msg give `width` or `height` მიუთითეთ `width` ან `height` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d1ebee17d..00666a840 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5583,3 +5583,15 @@ breakText Glyphs 글리프 Gap 4 간격 4 Dash 5 대시 5 Gap 5 간격 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 배치 도구 + Arrange in Grid… 격자로 배치… + Arrange in Grid 격자로 배치 + Swap Objects 개체 맞바꾸기 + Swap Objects and Sizes 개체와 크기 맞바꾸기 + Replace with Top Object… 맨 위 개체로 바꾸기… + Replace with Top Object 맨 위 개체로 바꾸기 + Set Size of Each… 각각 크기 설정… + Set Size of Each 각각 크기 설정 +@msg select exactly two objects 개체를 정확히 두 개 선택하세요 +@msg give `width` or `height` `width` 또는 `height`를 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 334707edc..f04ea4da4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5949,3 +5949,15 @@ breakText Glyphs Glyphs Gap 4 Tussenruimte 4 Dash 5 Streepje 5 Gap 5 Tussenruimte 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Schikgereedschappen + Arrange in Grid… In raster schikken… + Arrange in Grid In raster schikken + Swap Objects Objecten omwisselen + Swap Objects and Sizes Objecten en formaten omwisselen + Replace with Top Object… Vervangen door bovenste object… + Replace with Top Object Vervangen door bovenste object + Set Size of Each… Formaat van elk instellen… + Set Size of Each Formaat van elk instellen +@msg select exactly two objects selecteer precies twee objecten +@msg give `width` or `height` geef `width` of `height` op diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 49d549c17..17810fe50 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5856,3 +5856,15 @@ breakText Glyphs Glify Gap 4 Odstęp 4 Dash 5 Kreska 5 Gap 5 Odstęp 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Narzędzia układania + Arrange in Grid… Ułóż w siatce… + Arrange in Grid Ułóż w siatce + Swap Objects Zamień obiekty miejscami + Swap Objects and Sizes Zamień obiekty miejscami i rozmiarami + Replace with Top Object… Zastąp górnym obiektem… + Replace with Top Object Zastąp górnym obiektem + Set Size of Each… Ustaw rozmiar każdego… + Set Size of Each Ustaw rozmiar każdego +@msg select exactly two objects zaznacz dokładnie dwa obiekty +@msg give `width` or `height` podaj `width` lub `height` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 26d60b423..e1fc3b3e6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5920,3 +5920,15 @@ breakText Glyphs Glifos Gap 4 Lacuna 4 Dash 5 Traço 5 Gap 5 Lacuna 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Ferramentas de Organização + Arrange in Grid… Organizar em Grade… + Arrange in Grid Organizar em Grade + Swap Objects Trocar Objetos + Swap Objects and Sizes Trocar Objetos e Tamanhos + Replace with Top Object… Substituir pelo Objeto Superior… + Replace with Top Object Substituir pelo Objeto Superior + Set Size of Each… Definir Tamanho de Cada Um… + Set Size of Each Definir Tamanho de Cada Um +@msg select exactly two objects selecione exatamente dois objetos +@msg give `width` or `height` informe `width` ou `height` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2fec55064..412d4d129 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5978,3 +5978,15 @@ breakText Glyphs Глифы Gap 4 Зазор 4 Dash 5 Штрих 5 Gap 5 Зазор 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Инструменты размещения + Arrange in Grid… Разместить по сетке… + Arrange in Grid Разместить по сетке + Swap Objects Поменять объекты местами + Swap Objects and Sizes Поменять объекты местами и размерами + Replace with Top Object… Заменить верхним объектом… + Replace with Top Object Заменить верхним объектом + Set Size of Each… Задать размер каждого… + Set Size of Each Задать размер каждого +@msg select exactly two objects выделите ровно два объекта +@msg give `width` or `height` укажите `width` или `height` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fd253ed7a..08b0b8105 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6053,3 +6053,15 @@ breakText Glyphs Glyfer Gap 4 Mellanrum 4 Dash 5 Streck 5 Gap 5 Mellanrum 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Ordningsverktyg + Arrange in Grid… Ordna i rutnät… + Arrange in Grid Ordna i rutnät + Swap Objects Byt plats på objekt + Swap Objects and Sizes Byt plats och storlek på objekt + Replace with Top Object… Ersätt med översta objektet… + Replace with Top Object Ersätt med översta objektet + Set Size of Each… Ange storlek för vart och ett… + Set Size of Each Ange storlek för vart och ett +@msg select exactly two objects markera exakt två objekt +@msg give `width` or `height` ange `width` eller `height` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 849c9c74f..fff035657 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5958,3 +5958,15 @@ breakText Glyphs Glifler Gap 4 Boşluk 4 Dash 5 Çizgi 5 Gap 5 Boşluk 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Düzenleme Araçları + Arrange in Grid… Izgarada Düzenle… + Arrange in Grid Izgarada Düzenle + Swap Objects Nesnelerin Yerini Değiştir + Swap Objects and Sizes Nesnelerin Yerini ve Boyutunu Değiştir + Replace with Top Object… En Üstteki Nesneyle Değiştir… + Replace with Top Object En Üstteki Nesneyle Değiştir + Set Size of Each… Her Birinin Boyutunu Ayarla… + Set Size of Each Her Birinin Boyutunu Ayarla +@msg select exactly two objects tam olarak iki nesne seç +@msg give `width` or `height` `width` veya `height` belirt diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9f06a0f78..1c6f93e5d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5600,3 +5600,15 @@ breakText Glyphs Гліфи Gap 4 Проміжок 4 Dash 5 Штрих 5 Gap 5 Проміжок 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Інструменти розміщення + Arrange in Grid… Розмістити сіткою… + Arrange in Grid Розмістити сіткою + Swap Objects Поміняти об’єкти місцями + Swap Objects and Sizes Поміняти об’єкти місцями та розмірами + Replace with Top Object… Замінити верхнім об’єктом… + Replace with Top Object Замінити верхнім об’єктом + Set Size of Each… Задати розмір кожного… + Set Size of Each Задати розмір кожного +@msg select exactly two objects виділіть рівно два об’єкти +@msg give `width` or `height` укажіть `width` або `height` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 814b2bd0d..99d8437f8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5602,3 +5602,15 @@ breakText Glyphs Glyph Gap 4 Khoảng hở 4 Dash 5 Nét 5 Gap 5 Khoảng hở 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools Công cụ sắp xếp + Arrange in Grid… Sắp xếp theo lưới… + Arrange in Grid Sắp xếp theo lưới + Swap Objects Hoán đổi đối tượng + Swap Objects and Sizes Hoán đổi đối tượng và kích thước + Replace with Top Object… Thay bằng đối tượng trên cùng… + Replace with Top Object Thay bằng đối tượng trên cùng + Set Size of Each… Đặt kích thước từng cái… + Set Size of Each Đặt kích thước từng cái +@msg select exactly two objects hãy chọn đúng hai đối tượng +@msg give `width` or `height` hãy cho `width` hoặc `height` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8fa91dcae..6ecaf0930 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5842,3 +5842,15 @@ breakText Glyphs 字形 Gap 4 间隔 4 Dash 5 虚线 5 Gap 5 间隔 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 排列工具 + Arrange in Grid… 按网格排列… + Arrange in Grid 按网格排列 + Swap Objects 交换对象 + Swap Objects and Sizes 交换对象和大小 + Replace with Top Object… 替换为顶层对象… + Replace with Top Object 替换为顶层对象 + Set Size of Each… 分别设置大小… + Set Size of Each 分别设置大小 +@msg select exactly two objects 请恰好选择两个对象 +@msg give `width` or `height` 请提供 `width` 或 `height` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 852548318..75ee3a413 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5639,3 +5639,15 @@ breakText Glyphs 字符 Gap 4 間隙 4 Dash 5 虛線 5 Gap 5 間隙 5 +# Arrange Tools add-on (Object › Plug-ins › Arrange Tools). + Arrange Tools 排列工具 + Arrange in Grid… 依格狀排列… + Arrange in Grid 依格狀排列 + Swap Objects 對調物件 + Swap Objects and Sizes 對調物件和大小 + Replace with Top Object… 以最上層物件取代… + Replace with Top Object 以最上層物件取代 + Set Size of Each… 個別設定大小… + Set Size of Each 個別設定大小 +@msg select exactly two objects 請選取正好兩個物件 +@msg give `width` or `height` 請提供 `width` 或 `height` diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 382c730aa..70c863971 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -37,6 +37,9 @@ fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { /// The dialog (kind, fields) the menu item of command `id` opens, as the reference app's does. pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> { + if let Some(found) = crate::addons::arrange::dialog(app, id) { + return Some(found); + } Some(match id { // Move opens on the offset moved last and the checkboxes it was last confirmed with. "object.move" => ("move", crate::dialogs::transform::move_fields(app)), diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 772d5a8e1..bb20fbbca 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -529,6 +529,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::addons::pathtools::corner::open(app), crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app), + crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app), "tool.setOption" => app.session.set_tool_option_cmd(p), "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 55ee84b80..c1cdf4f59 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -206,7 +206,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { | "view.fitAll" | "view.actualSize" | "view.rotate" => app.session.active().is_some(), - "view.rotateToSelection" => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), + "view.rotateToSelection" | crate::addons::arrange::ZOOM => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()), id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), "file.clearRecent" => !app.ui.recent_files.is_empty(), id if id.starts_with("type.recentFont") => { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index bdc096616..ba3e700c6 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -12,6 +12,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together", ), + ( + crate::addons::arrange::ZOOM, + "Zoom to Selection", + "", + "{} zoom and centre the active view so the selection's visual bounds fill the window, with a margin (as view.fitArtboard fits the artboard); off with nothing selected", + ), ( "app.language", "Interface Language", @@ -922,7 +928,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.menuDialog", "Menu Dialog", "", - "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", ), ( "ui.widthPointEdit", diff --git a/docs/mcp.md b/docs/mcp.md index c1568ae63..09d114cf0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6160,3 +6160,29 @@ one, out of range values and a request with no fix on. ```json {"name":"run_command","arguments":{"command":"addon.color.printFixes","params":{"scope":"document","inkLimit":300,"removeWhiteOverprint":true,"overprintBlack":100}}} ``` + +## Arrange Tools add-on + +Arrange Tools is a built-in add-on that lays out, swaps, replaces and sizes selected objects: its commands are +`addon.arrange.*`, and in the desktop app it lives in Object › Plug-ins › Arrange Tools. Each command acts on the +top-level selected objects and is one undo step; a run that moves nothing makes no step. Places and sizes are measured +as `object.setBounds` measures them (Use Preview Bounds), except Arrange in Grid, which uses visual bounds. + +| Command | Parameters (default) | Does | +|---|---|---| +| `addon.arrange.grid` | `layout`: `grid`, `row`, `column` (`grid`); `columns`: 1…10000 (⌈√count⌉, never more than the count); `gapX` pt (10), `gapY` pt (`gapX`), each −1000000…1000000; `centerOnArtboard` (false) | Lays the objects out in their stacking order from the back, left to right and top to bottom. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, and each object is centred in its cell. The grid starts at the selection's top-left, or is centred on the artboard under the selection's centre (the first artboard when none is there). → `{count}` | +| `addon.arrange.swap` | `size` (false); `scaleStrokes` (the Scale Strokes & Effects preference) | Swaps the bounds centres of exactly two selected objects (off otherwise); with `size`, each also takes the other's width and height. | +| `addon.arrange.replace` | `fit`: `keep`, `bounds` (the replaced object's width and height), `proportional` (fits inside them) (`keep`); `scaleStrokes`; `keep` (false) | Puts a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order; the replaced objects are deleted unless `keep` (the copy then goes right above each). Needs two objects or more; the copies are selected. → `{ids}` | +| `addon.arrange.size` | `width`, `height` pt, 0.001…1000000 (at least one); `proportional` (false: a side not given stays; true: it follows the given one in proportion); `reference` 0…8 (4, the centre); `scaleStrokes` | Gives every selected object that width and height, keeping its reference point in place (the 9-point grid row by row from the top-left). | + +Zoom to Selection is the UI command `addon.arrange.zoomSelection {}` (remote mode only): it zooms and centres the +active view so the selection's visual bounds fill the window with a margin, as `view.fitArtboard` fits the artboard, +and fails with nothing selected. Arrange in Grid…, Replace with Top Object… and Set Size of Each… open dialogs +(`ui.menuDialog {command}`, dialog kinds `arrangeGrid`, `arrangeReplace` and `arrangeSize`) whose fields are the +command's parameters, with Preview; Set Size of Each's `__width` and `__height` say which sizes OK applies. + +```json +{"name":"run_command","arguments":{"command":"addon.arrange.grid","params":{"columns":4,"gapX":12,"gapY":12}}} +{"name":"run_command","arguments":{"command":"addon.arrange.replace","params":{"fit":"proportional"}}} +{"name":"run_command","arguments":{"command":"addon.arrange.size","params":{"width":72,"proportional":true}}} +``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 38d9ba8b6..639e28693 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-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's, with Export PDFs As and sub-folders by scale or format; previously: Artwork Cleanup add-on: Join Point Text, #4826; scores unchanged; previously: G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1236,7 +1236,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi ## Artwork Cleanup add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Artwork Cleanup and its +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Artwork Cleanup and its panel run `addon.cleanup.*`: Simplify Anchors (Keep Shape) on the shared kernel `vectorcraft_pathops::addons::keep_shape`, a health check of 16 kinds of issue on the selection or the document with Show, Fix Selected and Fix All (each one undo step), Deep Ungroup and Rejoin Path Pieces. Tests: `addons::` in @@ -1255,7 +1255,7 @@ refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and th ## Path Precision Tools add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Path Precision Tools, +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools, its Anchor Inspector panel and three tools kept out of the shipped toolbars (listed in the All Tools drawer) run `addon.pathtools.*`: an Anchor Eraser Brush on the shared keep-shape kernel, an Extend Path tool (straight, stretched curve or arc; shortening back along the path), a Measure & Inspect tool with hover readouts (lengths, area, curvature, @@ -1318,7 +1318,7 @@ ends), `addons::shared_edge` in `vectorcraft-tools`, `cmd::addons::pathtools::sh ## Smart Pen add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Smart Pen selects the +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Smart Pen selects the Smart Pen Tool (kept out of the shipped toolbars, listed in the All Tools drawer, no shortcut) and opens its panel. The tool draws with a straight or curved next segment (a curve after a straight segment leaves it tangent), smooth drags with Alt, Shift and Space, Tab to switch a segment, a rubber band, closing, joining and continuing open ends; and edits @@ -1333,7 +1333,7 @@ through the session) and `addons::pen::tests` in `vectorcraft-ui-egui` (the subm ## Color Effects add-on (2026-10-30) -A VectorCraft addition, not an Illustrator feature, so no area score moves. Effect › Color Effects adds live Exposure +A VectorCraft addition, not a feature of the reference app, so no area score moves. Effect › Color Effects adds live Exposure (stops of linear light, offset and gamma), Desaturate and Invert Colors as colour adjustments evaluated with those of Effect › Color Adjustments (`vectorcraft_effects::addons::color`), and `addon.color.filter` applies any colour adjustment to the colours themselves through the Edit Colors pass. Object › Plug-ins › Color Effects and its panel reach them. @@ -1360,10 +1360,29 @@ tints, curves, multiplied inks, skip spot), `duotone_*` and `cmd::addons::color: params and RGB documents refused), and `the_duotone_dialog_has_a_row_per_tone_and_the_curve_of_one` and `print_ink_fixes_opens_its_options_and_runs_on_ok` in `vectorcraft-ui-egui`. Commands: `docs/mcp.md` › Color Effects add-on. +## Arrange Tools add-on (2026-10-31) + +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Arrange Tools: Arrange in +Grid… lays the top-level selected objects out in their stacking order from the back, in rows and columns (⌈√count⌉ by +default), one row or one column, each centred in a cell as wide as its column's widest object and as tall as its row's +tallest (visual bounds), with horizontal and vertical gaps, from the selection's top-left or centred on the artboard; +Swap Objects swaps the bounds centres of two objects, and Swap Objects and Sizes their widths and heights too; Replace +with Top Object… puts a copy of the topmost selected object centred on each of the others, in its stacking place, at its +own size, the replaced object's size or fitted inside it in proportion, deleting or keeping the replaced objects; Set +Size of Each… gives every object an exact width and/or height (one side may follow in proportion) about one of the nine +reference points; Zoom to Selection fits the active view to the selection. Sizes and places are measured as +`object.setBounds` measures them (Use Preview Bounds), strokes and effects scale as Scale Strokes & Effects says, each +command is one undo step, and the three dialogs preview on the canvas. Tests: `cmd::addons::arrange::tests` in +`vectorcraft-engine` (grid, row, column and artboard centring, visual bounds, swap with and without sizes and stroke +scaling, replace with each fit and kept originals in stacking order, size with proportions and reference points, one +undo step, off states, bad params refused), `addons::arrange::tests` in `vectorcraft-ui-egui` (the submenu, the +dialogs, Preview and Cancel, Zoom to Selection) and `command_sweep`. Commands: `docs/mcp.md` › Arrange Tools add-on. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): evidence added; scores unchanged. | | 2026-10-31 | minor | Guides and snapping: Snap to Perpendicular and Snap to Tangent apply to an existing line's end dragged with Direct Selection, as the public pages describe for editing existing lines (the end anchor of a straight segment, not mid-line anchors or curved segments); a regression test covers both snaps, both toggles and non-snapping controls (#2530); scores and estimates unchanged. | | 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index afd2fac04..add5e8fd7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's; previously: Properties: Blend Options in Quick Actions for one selected blend with any tool, #4817; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Arrange Tools add-on; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -134,7 +134,7 @@ tools, never checked side by side) isn't captured row by row. | Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) | -| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion | +| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not a feature of the reference app as documented; under discussion | | Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 | ## Smart guides and snapping @@ -326,16 +326,17 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, ); arcs, spirals, flares and Pen paths are not turned | | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | -| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its six tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored | -| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point); an Alt-drag away from paths selects the points inside a marquee | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | -| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | -| Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored | +| Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools runs Arrange in Grid… (grid by row, one row or one column; columns, H and V spacing, Center on Artboard; Preview), Swap Objects, Swap Objects and Sizes, Replace with Top Object… (Actual Size, Bounds or Bounds (Fit Proportionally); Keep Original; Scale Strokes & Effects; Preview), Set Size of Each… (Width and Height each ticked on or off, Constrain Width and Height Proportions with one side, the reference point, Scale Strokes & Effects; Preview) and Zoom to Selection, each one undo step | done | `addon.arrange.*`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored | +| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its six tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored | +| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point); an Alt-drag away from paths selects the points inside a marquee | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not a feature of the reference app, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not a feature of the reference app, so it is not scored | +| Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not a feature of the reference app, so it is not scored | | Artwork Cleanup add-on text tools (VectorCraft addition): the panel's Text row, which the panel can hide, and Object › Plug-ins › Artwork Cleanup set the alignment of point type keeping its place, break type into lines (Shift-click: paragraphs), words or glyphs, split type at the cursor, reset a text transform (Alt-click: and its horizontal scale), combine point type, remove line or paragraph breaks and replace missing fonts, each one undo step | done | `addon.cleanup.*` text commands, `crates/ui-egui/src/addons/cleanup.rs` and its tests; a VectorCraft addition, so it is not scored | -| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images; Vector Halftone draws an object as halftone dots live (the dots Object › Vector Halftone makes), which Expand Appearance and the exporters write as paths; Duotone prints each colour's tone with one to four inks (a tint of a spot tone's swatch, or the CMYK mix of several), live or as a filter, its dialog a row per tone and the picked tone's curve; Print Ink Fixes (a CMYK document's ink limit, white overprint removed, black overprint set) opens its options from the submenu and the panel's Inks | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored | +| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images; Vector Halftone draws an object as halftone dots live (the dots Object › Vector Halftone makes), which Expand Appearance and the exporters write as paths; Duotone prints each colour's tone with one to four inks (a tint of a spot tone's swatch, or the CMYK mix of several), live or as a filter, its dialog a row per tone and the picked tone's curve; Print Ink Fixes (a CMYK document's ink limit, white overprint removed, black overprint set) opens its options from the submenu and the panel's Inks | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not a feature of the reference app, so it is not scored | ## Look and feel @@ -376,6 +377,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Arrange Tools add-on (a VectorCraft addition): Object › Plug-ins › Arrange Tools; scores and estimates unchanged | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged | | 2026-10-31 | minor | Properties: Quick Actions has Blend Options for one selected blend with any tool, not only the Blend tool, as the blending page describes (#4817); scores unchanged | | 2026-10-31 | minor | Smart guides and snapping: Snap to Perpendicular and Snap to Tangent for an existing line's end dragged with Direct Selection (#2530), so the tangent and perpendicular row is done; scores and estimates unchanged |