diff --git a/ROADMAP.md b/ROADMAP.md index 4c33d5d16..cbce1fbfa 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-11-01 · **Change:** minor (Progress log row for the Arrange Tools add-on's Randomize Opacity; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Arrange Tools add-on's Sort Objects; 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 @@ -130,6 +130,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). +- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917). - 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged. @@ -210,6 +211,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| | 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for the Document Tools add-on's Save a Timestamped Copy (#4926); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for Divide into Dashes (Path Precision Tools add-on, #4950); headline numbers unchanged. | diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 0a7d168d0..9b11a7115 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -3,13 +3,15 @@ //! 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. Group Overlapping Objects groups the -//! objects whose visual bounds overlap, and Randomize Opacity gives each selected object a random -//! opacity. Its submenu (Object › Plug-ins › Arrange Tools), its dialogs and Zoom to Selection (a -//! view command) are the UI's. +//! objects whose visual bounds overlap, Randomize Opacity gives each selected object a random +//! opacity, and Sort Objects sorts the selected objects by one attribute and gives them another in +//! that order. 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 std::collections::HashMap; use std::sync::Arc; use serde_json::{Value, json}; @@ -31,8 +33,10 @@ const LIMIT: f64 = 1e6; const LEAST: f64 = 0.001; /// The most columns Arrange in Grid takes. const MAX_COLUMNS: u64 = 10_000; -/// The most objects Group Overlapping Objects looks at. +/// The most objects Group Overlapping Objects and Sort Objects look at. const MAX_OBJECTS: usize = 20_000; +/// The attributes Sort Objects sorts by and gives (`addon.arrange.sort`). +pub const SORT_KEYS: [&str; 7] = ["width", "height", "area", "x", "y", "opacity", "stacking"]; pub fn specs() -> Vec { vec![ @@ -90,6 +94,15 @@ pub fn specs() -> Vec { has_selection, randomize_opacity ), + cmd!( + "addon.arrange.sort", + "Sort Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + has_multi, + sort + ), ] } @@ -393,3 +406,130 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "count": ids.len() })) } + +/// An object Sort Objects sorts: its layer or group, its place in it, its box as +/// `object.setBounds` measures it and its opacity (%). +struct Sorted { + id: NodeId, + parent: Option, + slot: usize, + b: OrientedBox, + opacity: f64, +} + +/// Attribute `key` (one of [`SORT_KEYS`]) of `o`, the `i`th of the objects in stacking order. +fn attribute(key: &str, o: &Sorted, i: usize) -> f64 { + let r = o.b.rect; + // The top-left corner where `object.setBounds` places it (reference point 0). + let corner = o.b.to_doc() * Point::new(r.x0, r.y0); + match key { + "width" => r.width(), + "height" => r.height(), + "area" => r.width() * r.height(), + "x" => corner.x, + "y" => corner.y, + "opacity" => o.opacity, + _ => i as f64, + } +} + +fn sort(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.sort"; + let by = choice(p, C, "by", &SORT_KEYS)?.unwrap_or("area"); + let apply = choice(p, C, "apply", &SORT_KEYS)?.unwrap_or("stacking"); + let order = choice(p, C, "order", &["ascending", "descending", "random"])?.unwrap_or("ascending"); + let distribute = switch_or(p, "distribute", false, C)?; + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let ids = checked_ids_or_selection(s, p, C)?; + if ids.len() > MAX_OBJECTS { + return Err(bad(C, "too many objects selected (at most 20000)")); + } + let d = &s.doc()?.doc; + // The objects in stacking order, from the back (their index paths, found in one walk). + let paths = d.index_paths(ids.iter().copied()); + let mut placed: Vec<(&Vec, NodeId)> = ids.iter().filter_map(|id| Some((paths.get(id)?, *id))).collect(); + placed.sort_by(|a, b| a.0.cmp(b.0)); + let items: Vec = placed + .into_iter() + .filter_map(|(path, id)| { + let (&slot, up) = path.split_last()?; + let parent = if up.is_empty() { None } else { Some(d.node_at(up)?.id) }; + Some(Sorted { id, parent, slot, b: s.transform_box(&[id])?, opacity: f64::from(d.node(id)?.opacity) * 100.0 }) + }) + .collect(); + let keys: Vec = items.iter().enumerate().map(|(i, o)| attribute(by, o, i)).collect(); + // The objects in sorted order; equal keys keep their stacking order. + let mut rank: Vec = (0..items.len()).collect(); + match order { + "random" => rng.shuffle(&mut rank), + "descending" => rank.sort_by(|a, b| keys[*b].total_cmp(&keys[*a])), + _ => rank.sort_by(|a, b| keys[*a].total_cmp(&keys[*b])), + } + let count = json!({ "count": items.len() }); + if apply == "stacking" { + // Each layer or group's objects take its objects' places, from the back, in sorted order. + let mut groups: HashMap, Vec> = HashMap::new(); + for o in rank.iter().filter_map(|i| items.get(*i)) { + groups.entry(o.parent).or_default().push(o.slot); + } + if groups.values().all(|slots| slots.is_sorted()) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (parent, from) in groups { + let ch = d.children_mut(parent)?; + let nodes: Vec> = from.iter().filter_map(|i| ch.get(*i).cloned()).collect(); + let mut to = from; + to.sort_unstable(); + for (slot, n) in to.into_iter().zip(nodes) { + if let Some(c) = ch.get_mut(slot) { + *c = n; + } + } + } + Ok(()) + })?; + return Ok(count); + } + // The values the objects get, smallest first: theirs, or evenly from the smallest to the largest. + let mut values: Vec = items.iter().enumerate().map(|(i, o)| attribute(apply, o, i)).collect(); + values.sort_by(f64::total_cmp); + if let (true, Some(&lo), Some(&hi)) = (distribute && values.len() > 1, values.first(), values.last()) { + let steps = (values.len() - 1) as f64; + values = (0..values.len()).map(|j| lo + (hi - lo) * j as f64 / steps).collect(); + } + let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); + if apply == "opacity" { + if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (o, v) in &wanted { + if let Some(n) = d.node_mut(o.id) { + n.opacity = (v / 100.0) as f32; + } + } + Ok(()) + })?; + return Ok(count); + } + let mut xfs = Vec::with_capacity(wanted.len()); + for (o, v) in wanted { + let q = match apply { + "x" => json!({ "x": v, "reference": 0 }), + "y" => json!({ "y": v, "reference": 0 }), + "width" => json!({ "width": v, "strokes": strokes }), + "height" => json!({ "height": v, "strokes": strokes }), + // An area: both sides in proportion; an object without one keeps its size. + _ => { + let (w, h) = (o.b.rect.width(), o.b.rect.height()); + let k = if w * h > 1e-12 { (v / (w * h)).sqrt() } else { 1.0 }; + json!({ "width": w * k, "height": h * k, "strokes": strokes }) + } + }; + xfs.push((o.id, object_bounds_transform(s, &q, o.id)?)); + } + transform_each(s, "Sort Objects", strokes, &xfs)?; + Ok(count) +} diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs index e19969f2e..85191935b 100644 --- a/crates/engine/src/cmd/addons/arrange/tests.rs +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -263,6 +263,7 @@ fn off_without_a_selection() { "addon.arrange.size", "addon.arrange.groupOverlapping", "addon.arrange.randomizeOpacity", + "addon.arrange.sort", ] { assert!(!enabled(&s, c), "{c}"); } @@ -416,3 +417,148 @@ fn randomize_opacity_refuses_bad_params() { let e = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 80, "max": 20})).unwrap_err().to_string(); assert!(e.contains("`min` is greater than `max`"), "{e}"); } + +fn layer_order(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +/// Three squares: `a` 30 wide at x 0, `b` 10 wide at x 100, `c` 20 wide at x 200, all selected. +fn squares(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = rect(s, 0.0, 0.0, 30.0, 30.0); + let b = rect(s, 100.0, 0.0, 10.0, 10.0); + let c = rect(s, 200.0, 0.0, 20.0, 20.0); + select(s, &[a, b, c]); + (a, b, c) +} + +#[test] +fn sort_by_area_lines_objects_up_along_x() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + let n = steps(&s); + let r = s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x"})).unwrap(); + assert_eq!(r["count"], json!(3)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Smallest first: b, c, a take the left edges 0, 100, 200; y stays. + assert!(near(bounds(&s, b), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (100.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, a), (200.0, 0.0, 30.0, 30.0)), "{:?}", bounds(&s, a)); + // Large to small: a, c, b. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, c).x0, bounds(&s, b).x0), (0.0, 100.0, 200.0)); + // Already in order: no step. + let n = steps(&s); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!(steps(&s), n); +} + +#[test] +fn sort_distributes_values_evenly() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 10.0, 50.0, 10.0, 50.0); + let c = rect(&mut s, 200.0, 0.0, 10.0, 20.0); + select(&mut s, &[a, b, c]); + // Left edges 0, 10, 200 step evenly: 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "x", "distribute": true})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, b).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0)); + // Heights that step evenly with x (10 to 50), each about its centre. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "height", "distribute": true})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 60.0, 10.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (200.0, -15.0, 10.0, 50.0)), "{:?}", bounds(&s, c)); +} + +#[test] +fn sort_restacks_within_each_layer() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Largest at the back. + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "stacking", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + s.execute("addon.arrange.sort", &json!({})).unwrap(); + assert_eq!(layer_order(&s), [b, c, a], "by area, small to large, into the stacking order by default"); + // An object in another layer keeps its layer; the others trade places among themselves. + s.execute("layer.new", &json!({})).unwrap(); + let big = rect(&mut s, 0.0, 100.0, 50.0, 50.0); + select(&mut s, &[a, b, c, big]); + s.execute("addon.arrange.sort", &json!({"order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + let doc = &s.doc().unwrap().doc; + assert_ne!(doc.layer_of(big), doc.layer_of(a), "left in its own layer"); +} + +#[test] +fn sort_by_stacking_and_opacity() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Back to front a, b, c; reversed: c, b, a take the left edges 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "stacking", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, c).x0, bounds(&s, b).x0, bounds(&s, a).x0), (0.0, 100.0, 200.0)); + for (id, o) in [(a, 20), (b, 80), (c, 50)] { + s.execute("object.setProps", &json!({"ids": [id.0], "opacity": o})).unwrap(); + } + select(&mut s, &[a, b, c]); + let opacity = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().opacity; + // By x, large to small: a (x 200) gets the least opacity, then b, then c. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "opacity", "order": "descending"})).unwrap(); + assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (0.2, 0.5, 0.8)); + // By opacity into the stacking order, large to small: the most opaque at the back. + s.execute("addon.arrange.sort", &json!({"by": "opacity", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [c, b, a]); +} + +#[test] +fn sort_by_area_into_areas_scales_in_proportion() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Areas 900, 100, 400 by x: a (x 0) 100, b 500, c 900. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "area", "distribute": true})).unwrap(); + let side = |s: &Session, id| bounds(s, id).width(); + assert!((side(&s, a) - 10.0).abs() < 1e-6 && (side(&s, b) - 500f64.sqrt()).abs() < 1e-6 && (side(&s, c) - 30.0).abs() < 1e-6); + assert!((bounds(&s, b).height() - 500f64.sqrt()).abs() < 1e-6, "in proportion"); +} + +#[test] +fn sort_in_random_order_repeats_with_a_seed() { + let mut s = session(); + let ids: Vec = (0..12).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect(); + select(&mut s, &ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + let first = layer_order(&s); + assert_ne!(first, ids, "shuffled"); + let mut sorted = first.clone(); + sorted.sort_by_key(|i| i.0); + assert_eq!(sorted, ids, "the same objects"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_order(&s), ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + assert_eq!(layer_order(&s), first, "the same seed, the same order"); +} + +#[test] +fn sort_takes_ids_and_refuses_bad_params() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.sort")); + s.execute("addon.arrange.sort", &json!({"ids": [b.0, a.0], "apply": "x"})).unwrap(); + assert_eq!((bounds(&s, b).x0, bounds(&s, a).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0), "c is left alone"); + select(&mut s, &[a, b, c]); + refuses( + &mut s, + "addon.arrange.sort", + &[ + json!({"by": "color"}), + json!({"apply": 3}), + json!({"order": "up"}), + json!({"distribute": "yes"}), + json!({"seed": -1}), + json!({"seed": 0.5}), + json!({"scaleStrokes": 1}), + json!({"ids": "x"}), + json!({"ids": [999_999]}), + ], + ); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 06046338f..330fd4540 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -592,6 +592,11 @@ fn structured_junk() { ("addon.arrange.randomizeOpacity", json!({"min": 0, "max": 100, "seed": u64::MAX})), ("addon.arrange.randomizeOpacity", json!({"min": f64::MAX, "max": -1e308, "seed": -1, "ids": [u64::MAX]})), ("addon.arrange.randomizeOpacity", json!({"min": 100, "max": 0, "seed": 1e308, "ids": []})), + ("addon.arrange.sort", json!({"by": "area", "apply": "area", "distribute": true, "scaleStrokes": true})), + ("addon.arrange.sort", json!({"by": "stacking", "apply": "stacking", "order": "random", "seed": u64::MAX})), + ("addon.arrange.sort", json!({"by": "opacity", "apply": "y", "order": "descending", "ids": [u64::MAX]})), + ("addon.arrange.sort", json!({"by": "x", "apply": "opacity", "distribute": true, "seed": 1e308, "ids": []})), + ("addon.arrange.sort", json!({"by": "nope", "apply": null, "order": 3, "distribute": "x"})), ("addon.pathtools.divideDashes", json!({"dash": 1e-308, "gap": -1e308, "fit": "x"})), ("addon.pathtools.divideDashes", json!({"dash": 0.01, "gap": 1e6, "fit": true, "removeGaps": false})), ("addon.pathtools.divideDashes", json!({"dash": f64::MAX, "gap": [0], "removeGaps": null})), diff --git a/crates/ui-egui/src/addons/arrange.rs b/crates/ui-egui/src/addons/arrange.rs index f251aa69d..d024799f8 100644 --- a/crates/ui-egui/src/addons/arrange.rs +++ b/crates/ui-egui/src/addons/arrange.rs @@ -1,7 +1,7 @@ //! Arrange Tools in the interface: its submenu (Object › Plug-ins › Arrange Tools); the dialogs of -//! Arrange in Grid, Replace with Top Object, Set Size of Each and Randomize Opacity, which preview -//! on the canvas and run their `addon.arrange.*` command on OK; Group Overlapping Objects; and Zoom -//! to Selection ([`ZOOM`]), which fits the active view to the selection. +//! Arrange in Grid, Replace with Top Object, Set Size of Each, Randomize Opacity and Sort Objects, +//! which preview on the canvas and run their `addon.arrange.*` command on OK; Group Overlapping +//! Objects; and Zoom to Selection ([`ZOOM`]), which fits the active view to the selection. use egui::Ui; use serde_json::{Value, json}; @@ -19,12 +19,14 @@ pub const GRID: &str = "arrangeGrid"; pub const REPLACE: &str = "arrangeReplace"; pub const SIZE: &str = "arrangeSize"; pub const OPACITY: &str = "arrangeOpacity"; +pub const SORT: &str = "arrangeSort"; /// Each dialog's kind, command and heading. -const DIALOGS: [(&str, &str, &str); 4] = [ +const DIALOGS: [(&str, &str, &str); 5] = [ (GRID, "addon.arrange.grid", "Arrange in Grid"), (REPLACE, "addon.arrange.replace", "Replace with Top Object"), (SIZE, "addon.arrange.size", "Set Size of Each"), (OPACITY, "addon.arrange.randomizeOpacity", "Randomize Opacity"), + (SORT, "addon.arrange.sort", "Sort Objects"), ]; /// 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; @@ -44,6 +46,7 @@ pub fn menu() -> Vec { Item::Cmd("Set Size of Each…", "addon.arrange.size", Value::Null), Item::Cmd("Group Overlapping Objects", "addon.arrange.groupOverlapping", json!({})), Item::Cmd("Randomize Opacity…", "addon.arrange.randomizeOpacity", Value::Null), + Item::Cmd("Sort Objects…", "addon.arrange.sort", Value::Null), Item::Sep, Item::Cmd("Zoom to Selection", ZOOM, json!({})), ] @@ -64,6 +67,11 @@ pub(crate) fn dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Va REPLACE => json!({ "fit": "keep", "keep": false, "scaleStrokes": strokes }), // A new seed each time the document has changed, so running it again varies the result. OPACITY => json!({ "min": 30, "max": 100, "seed": st.revision % SEEDS }), + // The random order's seed (not shown): new whenever the document has changed, so each run + // shuffles anew while Preview and OK agree. + SORT => json!({ + "by": "area", "apply": "stacking", "order": "ascending", "distribute": false, "seed": st.revision % SEEDS, "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| { @@ -151,6 +159,27 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); } + SORT => { + let attributes = [ + ("width", "Width"), + ("height", "Height"), + ("area", "Area"), + ("x", "X Position"), + ("y", "Y Position"), + ("opacity", "Opacity"), + ("stacking", "Stacking Order"), + ]; + form::choice(ui, d, "by", tl!("Sort By:"), widths, &attributes); + form::choice(ui, d, "apply", tl!("Apply To:"), widths, &attributes); + let orders = [("ascending", "Small to Large"), ("descending", "Large to Small"), ("random", "Random")]; + form::choice(ui, d, "order", tl!("Order:"), widths, &orders); + // Stacking order only trades places; only sizes scale strokes. + let apply = d.str("apply"); + ui.add_enabled_ui(apply != "stacking", |ui| form::check(ui, d, "distribute", tl!("Distribute Evenly"))); + ui.add_enabled_ui(matches!(apply.as_str(), "width" | "height" | "area"), |ui| { + 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:"))] { diff --git a/crates/ui-egui/src/addons/arrange/tests.rs b/crates/ui-egui/src/addons/arrange/tests.rs index 6aa864673..40e5eb0f8 100644 --- a/crates/ui-egui/src/addons/arrange/tests.rs +++ b/crates/ui-egui/src/addons/arrange/tests.rs @@ -2,7 +2,7 @@ use serde_json::json; use vectorcraft_doc::NodeId; use vectorcraft_engine::Session; -use super::{GRID, OPACITY, REPLACE, SIZE, ZOOM}; +use super::{GRID, OPACITY, REPLACE, SIZE, SORT, ZOOM}; use crate::tests_labels::painted_text; use crate::{VectorcraftApp, menus}; @@ -42,6 +42,7 @@ fn the_plugins_submenu_lists_the_tools() { ("Set Size of Each…", "addon.arrange.size"), ("Group Overlapping Objects", "addon.arrange.groupOverlapping"), ("Randomize Opacity…", "addon.arrange.randomizeOpacity"), + ("Sort Objects…", "addon.arrange.sort"), ("Zoom to Selection", ZOOM), ] { assert_eq!(under(label).and_then(|e| e.command.as_deref()), Some(id), "{label}"); @@ -168,3 +169,24 @@ fn randomize_opacity_opens_its_dialog_and_runs_on_ok() { assert!((0.4..=0.6).contains(&doc.node(id).unwrap().opacity), "{id:?}"); } } + +#[test] +fn sort_objects_opens_its_dialog_and_runs_on_ok() { + let (mut app, a, b) = app(); + click(&mut app, "Sort Objects…"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(SORT)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Sort Objects", "Sort By:", "Apply To:", "Order:", "Area", "Stacking Order", "Small to Large", "Distribute Evenly", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((&d.fields["by"], &d.fields["apply"], &d.fields["order"]), (&json!("area"), &json!("stacking"), &json!("ascending"))); + // Large to small along x: b (the larger) takes the left edge 0, a the left edge 100. + d.fields.insert("apply".into(), json!("x")); + d.fields.insert("order".into(), json!("descending")); + 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, b).x0, bounds(&app, a).x0), (0.0, 100.0)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 371c02695..b0210026a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -400,7 +400,7 @@ registry! { RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC, PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC, DivideDashes: [crate::addons::pathtools::dashes::KIND] => crate::addons::pathtools::dashes::SPEC, - ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE, crate::addons::arrange::OPACITY] => crate::addons::arrange::SPEC, + ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE, crate::addons::arrange::OPACITY, crate::addons::arrange::SORT] => crate::addons::arrange::SPEC, ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm), } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 01896f2ed..f2389f4b8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6006,3 +6006,15 @@ breakText Glyphs Глифи Maximum: Максимум: Seed: Начално число: @msg `min` is greater than `max` `min` е по-голямо от `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Сортиране на обекти… + Sort Objects Сортиране на обекти + Sort By: Сортиране по: + Apply To: Прилагане към: + Area Площ + X Position Позиция по X + Y Position Позиция по Y + Stacking Order Ред на подреждане + Small to Large От малко към голямо + Large to Small От голямо към малко + Distribute Evenly Равномерно разпределение diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e32201e97..2e9b5fd55 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6039,3 +6039,15 @@ shape Open Otvoren Maximum: Maksimum: Seed: Početna vrijednost: @msg `min` is greater than `max` `min` je veće od `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Sortiraj objekte… + Sort Objects Sortiraj objekte + Sort By: Sortiraj po: + Apply To: Primijeni na: + Area Površina + X Position Položaj X + Y Position Položaj Y + Stacking Order Redoslijed slaganja + Small to Large Od manjeg prema većem + Large to Small Od većeg prema manjem + Distribute Evenly Rasporedi ravnomjerno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index e5ec3feb6..0c2238de1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6112,3 +6112,15 @@ breakText Glyphs Glifs Maximum: Màxim: Seed: Llavor: @msg `min` is greater than `max` `min` és més gran que `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Ordena els objectes… + Sort Objects Ordena els objectes + Sort By: Ordena per: + Apply To: Aplica a: + Area Àrea + X Position Posició X + Y Position Posició Y + Stacking Order Ordre d’apilament + Small to Large De petit a gran + Large to Small De gran a petit + Distribute Evenly Distribueix uniformement diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0efefa092..d8d77feef 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6122,3 +6122,15 @@ breakText Glyphs Glyfy Maximum: Maximum: Seed: Počáteční hodnota: @msg `min` is greater than `max` `min` je větší než `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Seřadit objekty… + Sort Objects Seřadit objekty + Sort By: Seřadit podle: + Apply To: Použít na: + Area Plocha + X Position Poloha X + Y Position Poloha Y + Stacking Order Pořadí vrstvení + Small to Large Od nejmenšího po největší + Large to Small Od největšího po nejmenší + Distribute Evenly Rozmístit rovnoměrně diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f4d16eef2..7a1740223 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6021,3 +6021,15 @@ breakText Glyphs Glyffer Maximum: Maksimum: Seed: Startværdi: @msg `min` is greater than `max` `min` er større end `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Sortér objekter… + Sort Objects Sortér objekter + Sort By: Sortér efter: + Apply To: Anvend på: + Area Areal + X Position X-position + Y Position Y-position + Stacking Order Stablingsrækkefølge + Small to Large Fra lille til stor + Large to Small Fra stor til lille + Distribute Evenly Fordel jævnt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 18ea74459..48e587d88 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5707,3 +5707,15 @@ breakText Glyphs Glyphen Maximum: Maximum: Seed: Startwert: @msg `min` is greater than `max` `min` ist größer als `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Objekte sortieren… + Sort Objects Objekte sortieren + Sort By: Sortieren nach: + Apply To: Anwenden auf: + Area Fläche + X Position X-Position + Y Position Y-Position + Stacking Order Stapelreihenfolge + Small to Large Von klein nach groß + Large to Small Von groß nach klein + Distribute Evenly Gleichmäßig verteilen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 15c008eb8..bec866697 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6074,3 +6074,15 @@ breakText Glyphs Γλύφοι Maximum: Μέγιστο: Seed: Αρχική τιμή: @msg `min` is greater than `max` Το `min` είναι μεγαλύτερο από το `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Ταξινόμηση αντικειμένων… + Sort Objects Ταξινόμηση αντικειμένων + Sort By: Ταξινόμηση κατά: + Apply To: Εφαρμογή σε: + Area Εμβαδόν + X Position Θέση X + Y Position Θέση Y + Stacking Order Σειρά στοίβαξης + Small to Large Από μικρό σε μεγάλο + Large to Small Από μεγάλο σε μικρό + Distribute Evenly Ομοιόμορφη κατανομή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f69b7bec9..d5a664029 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6064,3 +6064,15 @@ breakText Glyphs Glifos Maximum: Máximo: Seed: Semilla: @msg `min` is greater than `max` `min` es mayor que `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Ordenar objetos… + Sort Objects Ordenar objetos + Sort By: Ordenar por: + Apply To: Aplicar a: + Area Área + X Position Posición X + Y Position Posición Y + Stacking Order Orden de apilamiento + Small to Large De menor a mayor + Large to Small De mayor a menor + Distribute Evenly Distribuir uniformemente diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index fc76393f6..f1badd485 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6152,3 +6152,15 @@ breakText Glyphs Merkit Maximum: Suurin: Seed: Siemen: @msg `min` is greater than `max` `min` on suurempi kuin `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Lajittele objektit… + Sort Objects Lajittele objektit + Sort By: Lajitteluperuste: + Apply To: Käytä kohteeseen: + Area Pinta-ala + X Position X-sijainti + Y Position Y-sijainti + Stacking Order Pinoamisjärjestys + Small to Large Pienestä suureen + Large to Small Suuresta pieneen + Distribute Evenly Jaa tasaisesti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 24a3a59ad..be6e4d1ae 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6119,3 +6119,15 @@ breakText Glyphs Glyphes Maximum: Maximum : Seed: Graine : @msg `min` is greater than `max` `min` est supérieur à `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Trier les objets… + Sort Objects Trier les objets + Sort By: Trier par : + Apply To: Appliquer à : + Area Surface + X Position Position X + Y Position Position Y + Stacking Order Ordre d’empilement + Small to Large Du plus petit au plus grand + Large to Small Du plus grand au plus petit + Distribute Evenly Répartir uniformément diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 29305857e..7243f3dee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6108,3 +6108,15 @@ breakText Glyphs Glifák Maximum: Maximum: Seed: Kezdőérték: @msg `min` is greater than `max` `min` nagyobb, mint `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Objektumok rendezése… + Sort Objects Objektumok rendezése + Sort By: Rendezés alapja: + Apply To: Alkalmazás erre: + Area Terület + X Position X pozíció + Y Position Y pozíció + Stacking Order Halmozási sorrend + Small to Large Kicsitől a nagyig + Large to Small Nagytól a kicsiig + Distribute Evenly Egyenletes elosztás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b682c757f..84e58c69b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6021,3 +6021,15 @@ breakText Glyphs Glif Maximum: Maksimum: Seed: Benih: @msg `min` is greater than `max` `min` lebih besar dari `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Urutkan objek… + Sort Objects Urutkan objek + Sort By: Urutkan menurut: + Apply To: Terapkan ke: + Area Luas + X Position Posisi X + Y Position Posisi Y + Stacking Order Urutan tumpukan + Small to Large Kecil ke besar + Large to Small Besar ke kecil + Distribute Evenly Distribusikan merata diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 74fcfb60c..6845ed74e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5741,3 +5741,15 @@ breakText Glyphs Glifi Maximum: Massimo: Seed: Seme: @msg `min` is greater than `max` `min` è maggiore di `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Ordina oggetti… + Sort Objects Ordina oggetti + Sort By: Ordina per: + Apply To: Applica a: + Area Area + X Position Posizione X + Y Position Posizione Y + Stacking Order Ordine di sovrapposizione + Small to Large Dal più piccolo al più grande + Large to Small Dal più grande al più piccolo + Distribute Evenly Distribuisci uniformemente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 69ee5fd86..cfe312e9e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5808,3 +5808,15 @@ breakText Glyphs 字形 Maximum: 最大: Seed: シード: @msg `min` is greater than `max` `min` が `max` より大きくなっています +# Arrange Tools add-on: Sort Objects. + Sort Objects… オブジェクトを並べ替え… + Sort Objects オブジェクトを並べ替え + Sort By: 並べ替えの基準: + Apply To: 適用先: + Area 面積 + X Position X 位置 + Y Position Y 位置 + Stacking Order 重ね順 + Small to Large 小さい順 + Large to Small 大きい順 + Distribute Evenly 均等に分布 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 07fc51fb9..d6f951f6e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6111,3 +6111,15 @@ breakText Glyphs გლიფები Maximum: მაქსიმუმი: Seed: საწყისი რიცხვი: @msg `min` is greater than `max` `min` მეტია, ვიდრე `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… ობიექტების დალაგება… + Sort Objects ობიექტების დალაგება + Sort By: დალაგება: + Apply To: გამოყენება: + Area ფართობი + X Position X პოზიცია + Y Position Y პოზიცია + Stacking Order დაწყობის რიგი + Small to Large პატარიდან დიდისკენ + Large to Small დიდიდან პატარისკენ + Distribute Evenly თანაბრად განაწილება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 022d19494..2df53a098 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5704,3 +5704,15 @@ breakText Glyphs 글리프 Maximum: 최대: Seed: 시드: @msg `min` is greater than `max` `min`이(가) `max`보다 큽니다 +# Arrange Tools add-on: Sort Objects. + Sort Objects… 개체 정렬… + Sort Objects 개체 정렬 + Sort By: 정렬 기준: + Apply To: 적용 대상: + Area 면적 + X Position X 위치 + Y Position Y 위치 + Stacking Order 쌓는 순서 + Small to Large 작은 것부터 + Large to Small 큰 것부터 + Distribute Evenly 균등 분포 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a1ace5190..0591d4042 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6065,3 +6065,15 @@ breakText Glyphs Glyphs Maximum: Maximum: Seed: Startwaarde: @msg `min` is greater than `max` `min` is groter dan `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Objecten sorteren… + Sort Objects Objecten sorteren + Sort By: Sorteren op: + Apply To: Toepassen op: + Area Oppervlakte + X Position X-positie + Y Position Y-positie + Stacking Order Stapelvolgorde + Small to Large Van klein naar groot + Large to Small Van groot naar klein + Distribute Evenly Gelijkmatig verdelen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fc4cb5748..6acc18ccf 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5977,3 +5977,15 @@ breakText Glyphs Glify Maximum: Maksimum: Seed: Ziarno: @msg `min` is greater than `max` `min` jest większe niż `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Sortuj obiekty… + Sort Objects Sortuj obiekty + Sort By: Sortuj według: + Apply To: Zastosuj do: + Area Pole + X Position Położenie X + Y Position Położenie Y + Stacking Order Kolejność ułożenia + Small to Large Od najmniejszego do największego + Large to Small Od największego do najmniejszego + Distribute Evenly Rozmieść równomiernie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bf70bd2fd..f55b23cf2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6196,3 +6196,15 @@ swatch Yellow Green Verde-amarelado Maximum: Máximo: Seed: Semente: @msg `min` is greater than `max` `min` é maior que `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Classificar objetos… + Sort Objects Classificar objetos + Sort By: Classificar por: + Apply To: Aplicar a: + Area Área + X Position Posição X + Y Position Posição Y + Stacking Order Ordem de empilhamento + Small to Large Do menor para o maior + Large to Small Do maior para o menor + Distribute Evenly Distribuir uniformemente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 566cd78a2..038c380a3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6099,3 +6099,15 @@ breakText Glyphs Глифы Maximum: Максимум: Seed: Начальное число: @msg `min` is greater than `max` `min` больше, чем `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Сортировать объекты… + Sort Objects Сортировать объекты + Sort By: Сортировать по: + Apply To: Применить к: + Area Площадь + X Position Положение по X + Y Position Положение по Y + Stacking Order Порядок наложения + Small to Large От меньшего к большему + Large to Small От большего к меньшему + Distribute Evenly Распределить равномерно diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e93c877fe..91c94e58e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6174,3 +6174,15 @@ breakText Glyphs Glyfer Maximum: Maximum: Seed: Startvärde: @msg `min` is greater than `max` `min` är större än `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Sortera objekt… + Sort Objects Sortera objekt + Sort By: Sortera efter: + Apply To: Tillämpa på: + Area Area + X Position X-position + Y Position Y-position + Stacking Order Staplingsordning + Small to Large Från liten till stor + Large to Small Från stor till liten + Distribute Evenly Fördela jämnt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9fdffe544..43682acca 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6079,3 +6079,15 @@ breakText Glyphs Glifler Maximum: En Çok: Seed: Başlangıç Değeri: @msg `min` is greater than `max` `min`, `max` değerinden büyük +# Arrange Tools add-on: Sort Objects. + Sort Objects… Nesneleri Sırala… + Sort Objects Nesneleri Sırala + Sort By: Sıralama Ölçütü: + Apply To: Uygulanacak Değer: + Area Alan + X Position X Konumu + Y Position Y Konumu + Stacking Order İstifleme Sırası + Small to Large Küçükten Büyüğe + Large to Small Büyükten Küçüğe + Distribute Evenly Eşit Dağıt diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f0fed9fb4..d6610c36b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5721,3 +5721,15 @@ breakText Glyphs Гліфи Maximum: Максимум: Seed: Початкове число: @msg `min` is greater than `max` `min` більше за `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Сортувати об’єкти… + Sort Objects Сортувати об’єкти + Sort By: Сортувати за: + Apply To: Застосувати до: + Area Площа + X Position Положення за X + Y Position Положення за Y + Stacking Order Порядок накладання + Small to Large Від меншого до більшого + Large to Small Від більшого до меншого + Distribute Evenly Розподілити рівномірно diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7cb29ce4f..5315376b9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5724,3 +5724,15 @@ breakText Glyphs Glyph Maximum: Tối đa: Seed: Giá trị khởi đầu: @msg `min` is greater than `max` `min` lớn hơn `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… Sắp xếp đối tượng… + Sort Objects Sắp xếp đối tượng + Sort By: Sắp xếp theo: + Apply To: Áp dụng cho: + Area Diện tích + X Position Vị trí X + Y Position Vị trí Y + Stacking Order Thứ tự xếp chồng + Small to Large Từ nhỏ đến lớn + Large to Small Từ lớn đến nhỏ + Distribute Evenly Phân bố đều diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b160639b0..156b25084 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5964,3 +5964,15 @@ breakText Glyphs 字形 Maximum: 最大值: Seed: 种子: @msg `min` is greater than `max` `min` 大于 `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… 排序对象… + Sort Objects 排序对象 + Sort By: 排序依据: + Apply To: 应用于: + Area 面积 + X Position X 位置 + Y Position Y 位置 + Stacking Order 堆叠顺序 + Small to Large 从小到大 + Large to Small 从大到小 + Distribute Evenly 均匀分布 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index aefdbb366..33406bd06 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5761,3 +5761,15 @@ breakText Glyphs 字符 Maximum: 最大值: Seed: 種子: @msg `min` is greater than `max` `min` 大於 `max` +# Arrange Tools add-on: Sort Objects. + Sort Objects… 排序物件… + Sort Objects 排序物件 + Sort By: 排序依據: + Apply To: 套用至: + Area 面積 + X Position X 位置 + Y Position Y 位置 + Stacking Order 堆疊順序 + Small to Large 從小到大 + Large to Small 從大到小 + Distribute Evenly 均勻分佈 diff --git a/docs/mcp.md b/docs/mcp.md index 2a8210444..e95d5d182 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6342,12 +6342,13 @@ as `object.setBounds` measures them (Use Preview Bounds), except Arrange in Grid | `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). | | `addon.arrange.groupOverlapping` | none | Groups the selected objects (at most 20000) wherever their visual bounds overlap or touch, chains included: when A overlaps B and B overlaps C, all three go in one group. Only objects in the same layer or group are grouped together; objects that overlap nothing stay as they are. Each new group goes where its frontmost member was in the stacking order, and the groups and the objects left alone stay selected; with nothing overlapping it makes no step. Needs two objects or more. → `{groups: [id…], grouped}` (how many objects went into groups) | | `addon.arrange.randomizeOpacity` | `min`, `max` % 0…100 (30, 100; `min` not more than `max`); `seed` whole number (0); `ids` (the selection) | Gives each selected object its own opacity (the object opacity the Transparency panel sets), picked at random and evenly from `min` to `max`; the same seed, objects and range give the same opacities. Groups take one opacity of their own; the objects inside keep theirs. → `{count}` | +| `addon.arrange.sort` | `by`, `apply`: `width`, `height`, `area` (width × height), `x` (left edge), `y` (top edge), `opacity` (%), `stacking` (stacking order from the back) (`area`, `stacking`); `order`: `ascending` (small to large), `descending` (large to small), `random` (`ascending`); `distribute` (false); `seed` whole number (0, for `random`); `scaleStrokes`; `ids` (the selection) | Sorts the selected objects (at most 20000) by `by` and gives them the values of `apply` in that order: the first gets the smallest value. Without `distribute` the objects trade the values they have; with it the values step evenly from the smallest to the largest. A width, height or area changes about each object's centre (an area in proportion), a place moves the top-left corner. Stacking order always trades places, and only among objects in the same layer or group. Needs two objects or more. → `{count}` | 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…, Set Size of Each… and Randomize Opacity… open dialogs -(`ui.menuDialog {command}`, dialog kinds `arrangeGrid`, `arrangeReplace`, `arrangeSize` and `arrangeOpacity`) whose -fields are the command's parameters, with Preview (Randomize Opacity's seed starts new whenever the document has changed); Set Size of Each's `__width` and `__height` say which sizes OK applies. +and fails with nothing selected. Arrange in Grid…, Replace with Top Object…, Set Size of Each…, Randomize Opacity… and Sort Objects… open dialogs +(`ui.menuDialog {command}`, dialog kinds `arrangeGrid`, `arrangeReplace`, `arrangeSize`, `arrangeOpacity` and `arrangeSort`) whose +fields are the command's parameters, with Preview (Randomize Opacity's seed, and Sort Objects' `seed`, which is not shown, start new whenever the document has changed); 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}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 55872ac36..231426c8e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Arrange Tools add-on: Randomize Opacity, #4925; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Arrange Tools add-on: Sort Objects, #4917; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1417,6 +1417,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each top-level selected object (or each of `ids`) its own object opacity, picked at random and evenly between a minimum and a maximum percent (30 to 100 by default); a seed (0 by default; the dialog starts with a new one whenever the document has changed) repeats a result exactly. A group takes one opacity of its own and the objects inside keep theirs. One undo step; a minimum above the maximum, a percent outside 0 to 100 or a seed that is not a whole number is refused without a change. Tests: `randomize_opacity_*` in `cmd::addons::arrange::tests` in `vectorcraft-engine` (range, one undo step, same and different seeds, defaults, `ids`, groups, bad params), `randomize_opacity_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Arrange Tools add-on. +## Arrange Tools add-on: Sort Objects (2026-11-01) + +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Arrange Tools › Sort Objects… sorts the top-level selected objects (or `ids`, at most 20000) by one attribute (width, height, area, x or y position, opacity or stacking order; small to large, large to small, or a random order from a seed) and gives them another in that order, the first the smallest value: the objects trade the values they have, or with Distribute Evenly the values step evenly from the smallest to the largest. Lining objects up left to right by size, restacking them so the largest is at the back, or giving them heights that step with their x position are each one run. Sizes and places are measured and set as `object.setBounds` measures them (Use Preview Bounds); a width, height or area changes about each object's centre (an area in proportion), and stacking order trades places only among objects of the same layer or group. One undo step; no step when nothing changes. Tests: `sort_*` in `cmd::addons::arrange::tests` in `vectorcraft-engine` (each attribute sorted by and applied, both directions, distribute, layers kept apart, the seeded random order, `ids`, no step when in order, bad params), `sort_objects_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Arrange Tools add-on. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. @@ -1438,6 +1442,7 @@ Symbols area (#4938), from the public symbol documentation ([create and place sy | Date | Change | Summary | |---|---|---| | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); evidence added; scores unchanged. | +| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); evidence added; scores unchanged. | | 2026-11-01 | minor | Symbols panel: Select All Unused (`symbol.unused`) shows the symbols nothing places selected, and Delete Symbol deletes them all, asked once, in one undo step (`symbol.delete {names}`, #4918); scores and hours unchanged | | 2026-11-01 | minor | Type core: Area Type Options › Text Flow (#4978): area type with several rows and columns fills them by columns (the default) or by rows, from the dialog's radio buttons or `text.areaOptions {flow}`; evidence updated; scores unchanged. | | 2026-11-01 | minor | Symbols: dynamic symbols (#4938): Symbol Options › Symbol Type, per-instance appearance for objects of a dynamic symbol's art that stays linked through redefinition, Break Link baking, Reset and Static dropping it; evidence added. Score unchanged at 87% (estimated), hours 5–9 → 4–8. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bc788b8f0..6dc32fc85 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-11-01 · **Change:** minor (Arrange Tools add-on: Randomize Opacity, #4925) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Arrange Tools add-on: Sort Objects, #4917) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -335,6 +335,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | 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 | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Group Overlapping Objects groups the selected objects whose visual bounds overlap or touch (chains included, within one layer or group), each group at its frontmost member's stacking place, one undo step | done | #4928; `addon.arrange.groupOverlapping`, `crates/engine/src/cmd/addons/arrange.rs` and its tests; not a feature of the reference app, so it is not scored | | Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each selected object its own opacity at random between Minimum and Maximum (%), with a Seed that repeats it and Preview, one undo step | done | #4925; `addon.arrange.randomizeOpacity`, `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 | +| Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Sort Objects… sorts the selected objects by Sort By (width, height, area, X or Y position, opacity, stacking order) in an Order (Small to Large, Large to Small, Random) and gives them Apply To in that order, trading the values or with Distribute Evenly; Scale Strokes & Effects; Preview; one undo step | done | #4917; `addon.arrange.sort`, `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 | | Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › Save a Timestamped Copy writes `_YYYY-MM-DD_HH-MM.` next to the document (a folder picked for one never saved), which keeps its path, title and modified state; off on the web | done | #4926; `addon.document.saveTimestampedCopy`, `crates/engine/src/cmd/addons/document.rs`, `crates/ui-egui/src/addons/document.rs` and their tests; not a feature of the reference app, so it is not scored | | Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › New Symbols from Layers makes a symbol of each layer's art named after the layer (the highlighted layers when two or more are, else all); Convert Layers to Symbols also replaces each layer's art with an instance in place; empty layers skipped, one undo step | done | #4927; `addon.document.symbolsFromLayers`, `crates/engine/src/cmd/addons/document.rs`, `crates/ui-egui/src/addons/document.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 | @@ -391,6 +392,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| | 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged | +| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); scores and estimates unchanged | | 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) | | 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged | | 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged |