From e8bd0ac410c424cb85466fff5cbcbcf1093afeb6 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 19 Oct 2026 21:44:44 -0700 Subject: [PATCH] Distribute: selected ruler guides are spaced with the objects (#1597 in part) Distribute Objects and Distribute Spacing space the selected ruler guides across the axis (vertical ones for the horizontal options, horizontal ones for the vertical options) together with the selected objects, as items with no size. Guides along the axis stay where they are. Two or more guides selected without objects are enough, and the Align panel's Distribute buttons are enabled for them. With Align to Artboard, the outermost items go to the artboard's edges. A key guide (select.key {guide}) is one of those items. With a spacing, it stays put and the other items are spaced from it, as from a key object with no size; a key guide along the axis leaves everything where it is. The Align panel sends the Distribute Spacing value with both rows of Distribute buttons while a guide is the key, and enables the value for it. --- crates/engine/src/cmd/object.rs | 133 ++++++++++++----- crates/engine/src/tests_keyguide.rs | 208 ++++++++++++++++++++++++++- crates/engine/tests/command_sweep.rs | 35 +++++ crates/ui-egui/src/panels/align.rs | 82 ++++++++++- docs/mcp.md | 24 +++- docs/ui-parity.md | 7 +- 6 files changed, 440 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 7522106c4..5bb300280 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -252,7 +252,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key object, select.key: the key object stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; not used without a key object or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line: without spacing and a key object they are spaced evenly, the first and last objects stay put, and fewer than three objects don't move; with spacing and a key object, see spacing (two objects are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", can_distribute, distribute ), @@ -261,7 +261,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected; default: the selection; \"key\" spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the objects equal, in the order of their left (top) edges: the first object's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", + "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", can_distribute, distribute_spacing ), @@ -1473,10 +1473,68 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st ok() } -/// Distribute needs two or more objects, or anchor points selected with Direct Selection (they -/// may all be on one path). +/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct +/// Selection (they may all be on one path). fn can_distribute(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|st| !st.selection.anchors.is_empty()) { Ok(()) } else { has_multi(s) } + if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) { + Ok(()) + } else { + has_multi(s) + } +} + +/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide +/// across the axis (#1597), which has no size. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Spaced { + Object(NodeId), + Guide(usize), +} + +/// The selected objects `ids` with their bounds (`preview`: visual bounds), and the selected +/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds. +/// Guides along the axis are left out, so they stay where they are. +fn spaced_items(s: &Session, ids: &[NodeId], preview: bool, horiz: bool) -> Result> { + let st = s.doc()?; + let objects = items_bounds(&st.doc, ids, preview).into_iter().map(|(id, r)| (Spaced::Object(id), r)); + let guides = st.selection.guides.iter().filter_map(|&i| { + let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; + Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) + }); + Ok(objects.chain(guides).collect()) +} + +/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing: +/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when +/// aligning to the key (#2143). +fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option { + let key = match key_root(s, ids) { + Some(id) => Spaced::Object(id), + None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?, + }; + items.iter().position(|(it, _)| *it == key) +} + +/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution), +/// aligning to the key. It has no line there, so a spacing from it leaves everything where it +/// is, as Align does. +fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool { + key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz) +} + +/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as +/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position. +fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result { + let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) }; + let (mut moves, mut guides) = (vec![], vec![]); + let d = &s.doc()?.doc; + for &(it, delta) in deltas { + match it { + Spaced::Object(id) => moves.push((id, along(delta))), + Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), + } + } + apply_moves(s, label, p, ids, &moves, &guides) } /// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them @@ -1530,7 +1588,6 @@ fn distribute(s: &mut Session, p: &Value) -> Result { (h, _) => distribute_anchors(s, "Distribute", h.is_some(), None), }; } - let mut items = items_bounds(&s.doc()?.doc, &ids, preview); let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { (Some(h), _) => ( true, @@ -1550,41 +1607,46 @@ fn distribute(s: &mut Session, p: &Value) -> Result { ), _ => return Err(bad("object.distribute", "give horizontal or vertical")), }; + // The selected guides across the axis are spaced with the objects (#1597). A guide has no + // size, so its left edge, center and right edge are all its line. + let mut items = spaced_items(s, &ids, preview, horiz)?; items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz))); let n = items.len(); - // With a spacing, the key object (or the compound path or selected group it is in) stays where - // it is, and the lines are placed `spacing` apart, outward from its line, in their order. - // Without a key object the spacing is not used. `from_key` is the key's index in `items` and - // the spacing. - let from_key = spacing.zip(key_root(s, &ids)).and_then(|(g, k)| Some((items.iter().position(|(id, _)| *id == k)?, g))); - // Two objects are enough on an artboard, where they go to its edges, and when they are spaced - // from a key object. + if spacing.is_some() && key_guide_along(s, p, horiz) { + return ok(); + } + // With a spacing, the key object (or the compound path or selected group it is in) or the key + // guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in + // their order. Without a key the spacing is not used. `from_key` is the key's index in + // `items` and the spacing. + let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g)); + // Two items are enough on an artboard, where they go to its edges, and when they are spaced + // from a key. if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) { return ok(); } let (first_box, last_box) = (&items[0].1, &items[n - 1].1); - // `first` and `last` are the lines that the first and last objects end on. Without an artboard - // the first and last objects stay where they are. On an artboard the first object moves until - // its left (top) edge is on the artboard's left (top) edge, and the last object moves until its + // `first` and `last` are the lines that the first and last items end on. Without an artboard + // the first and last items stay where they are. On an artboard the first item moves until + // its left (top) edge is on the artboard's left (top) edge, and the last item moves until its // right (bottom) edge is on the artboard's right (bottom) edge. let (first, last) = match board { Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1), Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1), None => (key(first_box, horiz), key(last_box, horiz)), }; - let moves: Vec<(NodeId, Vec2)> = items + let deltas: Vec<(Spaced, f64)> = items .iter() .enumerate() - .map(|(i, (id, r))| { + .map(|(i, (it, r))| { let target = match from_key { Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64), None => first + (last - first) * i as f64 / (n - 1) as f64, }; - let delta = target - key(r, horiz); - (*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }) + (*it, target - key(r, horiz)) }) .collect(); - apply_moves(s, "Distribute", p, &ids, &moves, &[]) + apply_spaced(s, "Distribute", p, &ids, horiz, &deltas) } fn distribute_spacing(s: &mut Session, p: &Value) -> Result { @@ -1596,16 +1658,21 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { if !s.doc()?.selection.anchors.is_empty() { return distribute_anchors(s, "Distribute Spacing", horiz, spacing); } - let mut items = items_bounds(&s.doc()?.doc, &ids, preview); + // The selected guides across the axis are spaced with the objects, as items with no size (#1597). + let mut items = spaced_items(s, &ids, preview, horiz)?; items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); + if spacing.is_some() && key_guide_along(s, p, horiz) { + return ok(); + } let n = items.len(); let &[(_, first), .., (_, last)] = items.as_slice() else { - return Err(EngineError::Other("select two or more objects to distribute".into())); + // Selected guides along the axis have nothing to space. + return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() }; }; let size = |r: &Rect| if horiz { r.width() } else { r.height() }; let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; - // The objects start at `lo`, and without a spacing the last one ends at `hi`. These are the - // first object's left (top) edge and the last one's right (bottom) edge, or the artboard's + // The items start at `lo`, and without a spacing the last one ends at `hi`. These are the + // first item's left (top) edge and the last one's right (bottom) edge, or the artboard's // edges with Align to Artboard and no spacing. let (lo, hi) = match board.filter(|_| spacing.is_none()) { Some(r) if horiz => (r.x0, r.x1), @@ -1619,17 +1686,15 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { }; let mut pos = lo; let mut deltas = vec![]; - for (id, r) in &items { - deltas.push((*id, pos - start(r))); + for (it, r) in &items { + deltas.push((*it, pos - start(r))); pos += size(r) + gap; } - // With a spacing (aligning to a key object) the key object stays where it is, with the - // compound path or selected group it is in: the others are spaced from it. - let key = key_root(s, &ids); - let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1); - let moves: Vec<(NodeId, Vec2)> = - deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect(); - apply_moves(s, "Distribute Spacing", p, &ids, &moves, &[]) + // With a spacing the key stays where it is: the key object, with the compound path or + // selected group it is in, or the key guide (#2143). The others are spaced from it. + let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1); + let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect(); + apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas) } fn set_bounds(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests_keyguide.rs b/crates/engine/src/tests_keyguide.rs index 5c908709e..2c3dcfcdf 100644 --- a/crates/engine/src/tests_keyguide.rs +++ b/crates/engine/src/tests_keyguide.rs @@ -1,4 +1,5 @@ -//! #2143: a ruler guide selected with objects can be the key object Align lines them up on. +//! #2143: a ruler guide selected with objects can be the key object Align lines them up on, and +//! Distribute spaces ruler guides selected without objects. use serde_json::{Value, json}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -183,3 +184,208 @@ fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() s.execute("object.align", &json!({"horizontal": "center"})).unwrap(); assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(), [700.0, 700.0]); } + +/// Objects 40 pt wide at `xs` (y 100) and a vertical guide at `pos` that is their key. +fn key_guide_across(xs: &[f64], pos: f64) -> (Session, Vec) { + let mut s = session(); + let ids: Vec = xs.iter().map(|&x| rect(&mut s, x, 100.0)).collect(); + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + (s, ids) +} + +fn lefts(s: &Session, ids: &[NodeId]) -> Vec { + ids.iter().map(|id| bounds(s, *id).x0).collect() +} + +#[test] +fn distribute_with_a_spacing_spaces_the_objects_from_a_key_guide() { + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0); + // Two objects are before the guide and one after it: the guide is spaced from as a key object + // with no size would be. + for (h, want) in [("left", [330.0, 340.0, 360.0]), ("center", [310.0, 320.0, 340.0]), ("right", [290.0, 300.0, 320.0])] { + s.execute("object.distribute", &json!({"horizontal": h, "spacing": 10})).unwrap(); + assert_eq!(lefts(&s, &ids), want, "{h}"); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0, "{h}: the guide stays put"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(lefts(&s, &ids), [100.0, 300.0, 500.0], "{h}: one undo step"); + } + // `to: "key"` is what the Align panel sends; 0 puts every line on the guide. + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 0, "to": "key"})).unwrap(); + assert_eq!(lefts(&s, &ids), [350.0; 3]); + // A guide before or after every object. + for (pos, want) in [(50.0, [60.0, 70.0, 80.0]), (600.0, [570.0, 580.0, 590.0])] { + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], pos); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap(); + assert_eq!(lefts(&s, &ids), want, "guide at {pos}"); + } + // Two objects are enough, and a negative spacing reverses the order. + let (mut s, ids) = key_guide_across(&[100.0, 500.0], 300.0); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": -20})).unwrap(); + assert_eq!(lefts(&s, &ids), [320.0, 280.0]); +} + +#[test] +fn distribute_from_a_horizontal_key_guide_and_along_a_guide() { + let mut s = session(); + let ids: Vec = [100.0, 200.0, 400.0].iter().map(|&y| rect(&mut s, 50.0, y)).collect(); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + let tops = |s: &Session| ids.iter().map(|id| bounds(s, *id).y0).collect::>(); + s.execute("object.distribute", &json!({"vertical": "bottom", "spacing": 5})).unwrap(); + // Bottoms 120 and 220 are above the guide, 420 below it. + assert_eq!(tops(&s), [270.0, 275.0, 285.0]); + // A horizontal guide gives no line across a horizontal distribution: the objects stay where + // they are, as Align leaves them, and no undo step is added. + let undo = s.doc().unwrap().history.undo.len(); + let xs: Vec = ids.iter().map(|id| bounds(&s, *id).x0).collect(); + s.execute("object.distribute", &json!({"horizontal": "center", "spacing": 5})).unwrap(); + assert_eq!(ids.iter().map(|id| bounds(&s, *id).x0).collect::>(), xs); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +#[test] +fn distribute_without_a_spacing_spaces_the_key_guide_too() { + // Without a spacing, the key guide is spaced with the objects as an item with no size: lines + // 100, 150, 350 (the guide) and 400 go to 100, 200, 300 and 400. + let (mut s, ids) = key_guide_across(&[100.0, 150.0, 400.0], 350.0); + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 200.0, 400.0], vec![300.0])); + // `to: "selection"` wins over the key guide, so a spacing isn't used either. + let (mut s, ids) = key_guide_across(&[100.0, 150.0, 400.0], 350.0); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10, "to": "selection"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 200.0, 400.0], vec![300.0])); +} + +#[test] +fn distribute_spacing_spaces_the_objects_from_a_key_guide() { + // Left edges 100, 300 and 500 of 40 pt objects with a vertical key guide at 350, 10 pt apart: + // the object before the guide ends 10 pt before it, the one after it starts 10 pt after it. + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![250.0, 300.0, 360.0], vec![350.0])); + // A guide along the axis leaves everything where it is. + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 10})).unwrap(); + assert_eq!(lefts(&s, &ids), [250.0, 300.0, 360.0]); + assert_eq!(bounds(&s, ids[0]).y0, 100.0); +} + +#[test] +fn other_selected_guides_are_spaced_from_a_key_guide() { + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap(); + s.execute("guide.select", &json!({"indexes": [1, 2], "toggle": true})).unwrap(); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap(); + // The vertical guide at 600 is the last line, 10 pt after the last object's; the horizontal + // guide stays. + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![330.0, 340.0, 360.0], vec![350.0, 370.0, 50.0])); +} + +/// Guides at `vertical` x positions and `horizontal` y positions, all selected without objects. +fn guides_alone(vertical: &[f64], horizontal: &[f64]) -> Session { + let mut s = session(); + for (v, pos) in vertical.iter().map(|p| (true, p)).chain(horizontal.iter().map(|p| (false, p))) { + s.execute("guide.add", &json!({"vertical": v, "pos": pos})).unwrap(); + } + let all: Vec = (0..vertical.len() + horizontal.len()).collect(); + s.execute("guide.select", &json!({"indexes": all})).unwrap(); + s +} + +/// Selects the objects `ids` and the ruler guides `guides`, and nothing else. +fn select_with_guides(s: &mut Session, ids: &[NodeId], guides: &[usize]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + let now = s.doc().unwrap().selection.guides.clone(); + let flip: Vec = (0..s.doc().unwrap().doc.guides.len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); + s.execute("guide.select", &json!({"indexes": flip, "toggle": true})).unwrap(); + assert_eq!((s.doc().unwrap().selection.objects.len(), s.doc().unwrap().selection.guides.len()), (ids.len(), guides.len())); +} + +fn positions(s: &Session) -> Vec { + s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() +} + +#[test] +fn distribute_spaces_guides_selected_without_objects() { + let mut s = guides_alone(&[100.0, 150.0, 500.0], &[50.0, 60.0, 400.0]); + // A guide has no width: the three options of an axis give the same result, and only the + // guides across the axis move. + for h in ["left", "center", "right"] { + s.execute("object.distribute", &json!({"horizontal": h})).unwrap(); + assert_eq!(positions(&s), [100.0, 300.0, 500.0, 50.0, 60.0, 400.0], "{h}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(positions(&s), [100.0, 150.0, 500.0, 50.0, 60.0, 400.0], "{h}: one undo step"); + } + s.execute("object.distribute", &json!({"vertical": "center"})).unwrap(); + assert_eq!(positions(&s), [100.0, 150.0, 500.0, 50.0, 225.0, 400.0]); + // Align to Artboard: the outermost guides go to its edges (the document is 800 by 600). + s.execute("object.distribute", &json!({"horizontal": "center", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(&positions(&s)[..3], [0.0, 400.0, 800.0]); + // Distribute Spacing: equal gaps, or `spacing` apart from the first. + let mut s = guides_alone(&[100.0, 150.0, 500.0], &[]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!(positions(&s), [100.0, 300.0, 500.0]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!(positions(&s), [100.0, 110.0, 120.0]); + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 10})).unwrap(); + assert_eq!(positions(&s), [100.0, 110.0, 120.0], "no horizontal guide to space"); +} + +#[test] +fn distributing_guides_needs_two_items() { + // Two guides are the ends already; one guide can't be distributed. + let mut s = guides_alone(&[100.0, 500.0], &[]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.distribute", &json!({"horizontal": "center"})).unwrap(); + assert_eq!((positions(&s), s.doc().unwrap().history.undo.len()), (vec![100.0, 500.0], undo)); + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + assert!(s.execute("object.distribute", &json!({"horizontal": "center"})).is_err()); + // One object and one guide are enough for Distribute Spacing. + let a = rect(&mut s, 300.0, 100.0); + select_with_guides(&mut s, &[a], &[0]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!((positions(&s), bounds(&s, a).x0), (vec![100.0, 500.0], 110.0)); +} + +#[test] +fn guides_selected_with_objects_are_distributed_with_them() { + // 40 pt objects at 100 and 300, vertical guides at 150 and 400, and a horizontal guide. + let mut s = guides_alone(&[150.0, 400.0], &[50.0]); + let ids: Vec = [100.0, 300.0].iter().map(|&x| rect(&mut s, x, 100.0)).collect(); + select_with_guides(&mut s, &ids, &[0, 1, 2]); + // Lines 100, 150 (a guide), 300 and 400 (a guide): the guide between the objects goes to + // 200, and the outermost items stay. The horizontal guide stays too. + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 300.0], vec![200.0, 400.0, 50.0])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(positions(&s), [150.0, 400.0, 50.0], "one undo step"); + // Distribute Spacing: a guide has no width. From 100 to 360 there are 260 pt, 80 of them + // objects: three gaps of 60 pt. + s.execute("guide.move", &json!({"index": 1, "pos": 360})).unwrap(); + select_with_guides(&mut s, &ids, &[0, 1, 2]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 260.0], vec![200.0, 360.0, 50.0])); + // Align to Artboard: the outermost items go to its edges (the document is 800 wide). + s.execute("object.distribute", &json!({"horizontal": "left", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(lefts(&s, &ids)[0], 0.0); + assert_eq!(positions(&s)[1], 800.0); +} + +#[test] +fn selected_guides_are_spaced_from_a_key_object() { + // Lines 100, 150 (a guide) and 300 (the key object), 10 pt apart outward from the key. + let mut s = guides_alone(&[150.0], &[]); + let ids: Vec = [100.0, 300.0].iter().map(|&x| rect(&mut s, x, 100.0)).collect(); + select_with_guides(&mut s, &ids, &[0]); + s.execute("select.key", &json!({"id": ids[1].0})).unwrap(); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![280.0, 300.0], vec![290.0])); + // Distribute Spacing: the object ends 10 pt before the guide, the guide 10 pt before the key. + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![240.0, 300.0], vec![290.0])); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 93e53dbde..155e515b7 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -138,6 +138,41 @@ fn distribute_with_a_huge_spacing_from_the_key_object() { } } +#[test] +fn distribute_with_a_huge_spacing_from_a_key_guide() { + for (vertical, pos) in [(true, 50.0), (true, 10000.0), (false, -10000.0)] { + for p in [json!({"horizontal": "left", "spacing": 1e308}), json!({"vertical": "bottom", "spacing": -1e308})] { + let mut s = Fixture::Multi.session(); + s.execute("guide.add", &json!({"vertical": vertical, "pos": pos})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + if no_panic(s.execute("object.distribute", &p)).is_ok() { + check_session(&s).unwrap_or_else(|e| panic!("guide at {pos}, {p}: {e}")); + vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("guide at {pos}, {p}: {e}")); + } + } + } +} + +#[test] +fn distribute_guides_selected_alone_with_a_huge_spacing() { + for (cmd, p) in [ + ("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 1e308})), + ("object.distributeSpacing", json!({"axis": "vertical", "spacing": -1e308})), + ("object.distribute", json!({"horizontal": "center", "to": "artboard", "artboard": 99})), + ] { + let mut s = Fixture::Empty.session(); + for (v, pos) in [(true, 10.0), (true, 20.0), (true, 9000.0), (false, -9000.0), (false, 0.0)] { + s.execute("guide.add", &json!({"vertical": v, "pos": pos})).unwrap(); + } + s.execute("guide.select", &json!({"indexes": [0, 1, 2, 3, 4]})).unwrap(); + if no_panic(s.execute(cmd, &p)).is_ok() { + check_session(&s).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + } + } +} + #[test] fn bug_artboard_huge_props_break_svg() { for key in ["x", "y", "width", "height"] { diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 942171193..aa98afbd0 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -123,9 +123,9 @@ fn guide_for_key(app: &VectorcraftApp) -> Option { app.session.active().filter(|st| !st.selection.is_empty()).and_then(|st| st.selection.guides.first().copied()) } -/// Whether the key is an object, which Distribute Spacing spaces from (not a guide). -fn has_key_object(app: &VectorcraftApp) -> bool { - app.session.active().is_some_and(|st| st.selection.key.is_some()) +/// The number of selected ruler guides, which Distribute spaces with the objects (#1597). +fn selected_guides(app: &VectorcraftApp) -> usize { + app.session.active().map_or(0, |st| st.selection.guides.len()) } /// The topmost selected object, the last in paint order. @@ -145,12 +145,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let n = selection_len(app); let to = align_to(app); // `spacing` is the Distribute Spacing value. The Distribute Objects and Distribute Spacing - // buttons send it while there is a key object. + // buttons send it while there is a key object or a key guide (#2143). let spacing: f64 = pstate::>(ui.ctx(), "align-spacing").unwrap_or(0.0); - let key = to == AlignTo::Key && has_key_object(app); + let key = to == AlignTo::Key; let sp = if key { Some(spacing) } else { None }; - // Two or more objects, or anchor points, which may all be on one path. - let distributes = n >= 2 || has_anchors(app); + // Two or more objects and ruler guides (#1597), or anchor points, which may all be on one path. + let distributes = n + selected_guides(app) >= 2 || has_anchors(app); widgets::subheader(ui, tl!("Align Objects:")); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; @@ -633,6 +633,74 @@ mod tests { assert_eq!(lefts(&app), [35.0, 50.0, 65.0], "15 pt apart: {}", app.ui.status); } + /// #2143: with a key guide, the Distribute Objects and Distribute Spacing buttons send the + /// Distribute Spacing value too, and the objects are spaced from the guide, which stays put. + #[test] + fn the_distribute_buttons_send_the_spacing_with_a_key_guide() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ids: Vec = [(10, 20), (50, 40), (120, 20)] + .into_iter() + .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) + .collect(); + app.run("guide.add", json!({"vertical": true, "pos": 100})).unwrap(); + app.run("select.set", json!({"ids": ids})).unwrap(); + app.run("guide.select", json!({"indexes": [0], "toggle": true})).unwrap(); + app.run("select.key", json!({"guide": 0})).unwrap(); + let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); + let sent = |app: &VectorcraftApp| { + let (id, p) = app.session.journal.last().unwrap(); + (id.clone(), p["to"].clone(), p.get("spacing").cloned()) + }; + let ctx = egui::Context::default(); + let (left, space) = distribute_buttons(&mut app, &ctx); + click(&ctx, &mut app, left.center(), show); + assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(0.0)))); + assert_eq!(lefts(&app), [100.0; 3], "on the guide: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + set_pstate(&ctx, "align-spacing", Some(15.0)); + click(&ctx, &mut app, left.center(), show); + assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + // Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it, + // the one after it starts 15 pt after it. + click(&ctx, &mut app, space.center(), show); + assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0)))); + assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + } + + /// Three vertical ruler guides selected without objects: the Distribute buttons are on, and + /// Horizontal Distribute Center puts the middle guide halfway between the other two, or with + /// Align to Artboard spaces them from its left edge to its right edge, as a public forum answer + /// describes for equal columns (https://community.adobe.com/questions-652/illustrator-equal-guides-822924). + #[test] + fn the_distribute_buttons_space_guides_selected_alone() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + for pos in [20, 50, 180] { + app.run("guide.add", json!({"vertical": true, "pos": pos})).unwrap(); + } + app.run("guide.select", json!({"indexes": [0, 1, 2]})).unwrap(); + let ctx = egui::Context::default(); + frame_events(&ctx, &mut app, vec![], show); + let row = widget_rects(&ctx, egui::vec2(32.0, 32.0)); + let bottom = row.iter().map(|r| r.top()).fold(f32::MIN, f32::max); + let mut row: Vec = row.into_iter().filter(|r| r.top() == bottom).collect(); + row.sort_by(|a, b| a.left().total_cmp(&b.left())); + assert_eq!(row.len(), 6, "the Distribute row"); + // Its fifth button is Horizontal Distribute Center. + click(&ctx, &mut app, row[4].center(), show); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step"); + // With Align to Artboard, the outermost guides go to its edges: equal columns across it. + app.ui.align_to = AlignTo::Artboard; + click(&ctx, &mut app, row[4].center(), show); + assert_eq!(pos(&app), [0.0, 100.0, 200.0], "{}", app.ui.status); + } + /// Runs the Object → Align item `label` as a click on it does. fn object_align(app: &mut VectorcraftApp, label: &str) { let entry = crate::menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Align"] && e.label == label).expect("menu item"); diff --git a/docs/mcp.md b/docs/mcp.md index 3a7e11bea..0d9e0a94f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1109,7 +1109,7 @@ With a key object (or a key guide), `object.align` moves the other selected rule Aligning to the selection or the artboard leaves guides where they are. `select.key {guide}` makes a selected ruler guide (its `guide.list` index) the key instead, while one or more objects are selected (#2143). `object.align` then lines the objects up on the guide and the guide stays put: a vertical guide sets where their left edges, centres or right edges go and vertical alignment leaves them where they are, a horizontal guide the other way round. -Selected anchor points align to it the same way. Distribute Spacing doesn't space from a key guide. +Selected anchor points align to it the same way. `object.distribute {spacing}` and `object.distributeSpacing {spacing}` space the objects from it (see [Align to Artboard](#align-to-artboard)). A click with the Selection tool on a guide selected with objects runs `select.key {guide}`, and a click on the key guide again runs `select.key {}`. The key guide goes when it leaves the selection or no object is left selected, and an object key replaces it (and the other way round). It returns `{key}`, the key object's id or null, with `guide`, the key guide's index, when a guide is the key. @@ -2252,16 +2252,30 @@ left or top edge of the first object, in the order of those edges, on the artboa right or bottom edge of the last object on the artboard's right or bottom edge, with equal gaps between the objects. With `to: "selection"` or `"key"`, or without `to`, `object.distribute` and `object.distributeSpacing` without `spacing` keep the first and last objects where they are, and `object.distribute` without `spacing` moves nothing with fewer than -three objects. With `spacing`, `object.distributeSpacing` does not use `to`, and `object.distribute` does not use it -while the selection has a key object. With anchor points selected (Direct Selection), both commands space the points +three objects. With `spacing` and a key object, `object.distribute` and `object.distributeSpacing` do not use `to`; +with a key guide, `to` decides whether the guide is the key. With anchor points selected (Direct Selection), both commands space the points among themselves and do not use `to` or `artboard`. `object.distribute {spacing}` (the Distribute Objects buttons while the selection has a key object, `select.key`) keeps the key object where it is, with the compound path or selected group that contains it. It places the objects' lines (left edges, centers or right edges; top edges, centers or bottom edges) `spacing` points apart, outward from the key object's line, in their order on that line. With `spacing: 0`, every object ends on the key object's line. Two objects -are enough. Without a key object, or with anchor points selected, `object.distribute` does not use `spacing`. A -`spacing` that is not a number is an error. +are enough. Without a key object or key guide, or with anchor points selected, `object.distribute` does not use +`spacing`. A `spacing` that is not a number is an error. + +`object.distribute` and `object.distributeSpacing` space the selected ruler guides across the axis (vertical guides +for the horizontal options, horizontal guides for the vertical ones) with the selected objects, as items with no size +(#1597). A guide's left edge, center and right edge are all its line, so the three options of an axis give the same +result for it. Guides along the axis stay where they are. Two or more guides selected without objects are enough, and +the Align panel's buttons are enabled for them; with `to: "artboard"`, the outermost items go to the artboard's edges. + +With a key guide (`select.key {guide}`, #2143), `object.distribute {spacing}` and `object.distributeSpacing {spacing}` +(the Align panel's Distribute buttons send the Distribute Spacing value then too) keep the guide where it is and +space the other items from it, as from a key object with no size: `object.distribute` places their lines `spacing` +points apart, outward from the guide's line, and `object.distributeSpacing` leaves gaps of `spacing` points, the guide +included. With `spacing: 0`, `object.distribute` puts every line on the guide. A key guide along the axis leaves +everything where it is, as `object.align` does. `to: "selection"` or `"artboard"` distributes without the key guide +as the key, as with `object.align`, and without `spacing` a key guide is spaced as any selected guide. The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align`, `object.distribute`, `object.distributeSpacing` and `select.allOnArtboard` calls it runs without `artboard`, as it does diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dadf41a8a..a148caa73 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-20 · **Change:** minor (the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144; interactive Mirror Repeat axis; Background Export progress dialog with Cancel, #2564; scores unchanged; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144; interactive Mirror Repeat axis; Background Export progress dialog with Cancel, #2564; scores unchanged; 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; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -74,7 +74,9 @@ tools, never checked side by side) isn't captured row by row. | Click/Shift-click anchors; marquee toggles with Shift | done | #483 | | Align selected anchors to their bounds or the artboard; repeating an alignment that moves nothing keeps Undo, Redo and saved state | done | #1180 follow-up; engine regressions include rounding-only moves and unchanged handles; #1641 (fixes #1636): Align and Distribute act on the anchors selected with Direct Selection, the points of one path aligning to each other and Distribute spacing them | | Align selected anchors to a key anchor | missing | #1636 | -| A ruler guide as the key object: a click on a guide selected with objects (or Align to Key Object with one object and a guide) makes it the key, drawn thicker; Align lines the objects (or selected anchors) up on it and the guide stays put | done | #2143; `select.key {guide}`; a vertical guide sets x only, a horizontal one y; Distribute Spacing doesn't space from a key guide | +| A ruler guide as the key object: a click on a guide selected with objects (or Align to Key Object with one object and a guide) makes it the key, drawn thicker; Align lines the objects (or selected anchors) up on it and the guide stays put | done | #2143; `select.key {guide}`; a vertical guide sets x only, a horizontal one y | +| Distribute Objects and Distribute Spacing with a spacing from a key guide: the guide stays put and the other items are spaced the Distribute Spacing value from it, as from a key object with no size | done | Key guide from #3165; a vertical guide spaces the horizontal options, a horizontal one the vertical ones, and a guide along the axis leaves everything | +| Distribute ruler guides: the Distribute Objects and Distribute Spacing buttons space the selected guides across the axis with the selected objects, as items with no size; two or more guides selected alone are enough | done | #1597 in part; guides alone: select them, choose Align to Artboard and click Horizontal (Vertical) Distribute Center for equal columns (rows) ([public forum answer, 2017](https://community.adobe.com/questions-652/illustrator-equal-guides-822924)); with objects: a guide on each artboard edge, included in the selection, keeps the outermost lines there ([public forum answer, 2025](https://www.signs101.com/threads/is-there-a-way-to-distribute-shapes-like-this-in-illustrator.179556/)); a guide has no width, so the three options of an axis are the same for it; guides along the axis stay | | Ruler guides align to a key object: a click on an object selected with guides makes it the key (one object is enough); Align moves the selected guides to its edge or centre (vertical guides to left, centre or right, horizontal ones to top, centre or bottom) and the key stays put | done | #2143; aligning to the selection or the artboard leaves guides alone | | A marquee selects the objects whose paths it touches (anchors or segments): drawn inside a filled shape, over a background, it leaves that shape (Preview and Outline) | done | #1103 | | Click a segment independently of its endpoint anchors; only its direction handles show | done | #1278: hollow endpoint squares, selected edge emphasized; headless egui checked | @@ -267,6 +269,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Distribute Objects and Distribute Spacing space the selected ruler guides across the axis with the objects, as items with no size, and two or more guides selected alone (#1597 in part); with a key guide and a spacing, the guide stays put and the other items are spaced from it (key guide from #3165); the Distribute Spacing value is enabled for a key guide; scores unchanged | | 2026-10-20 | minor | The selection tools (Selection, Direct Selection, Group Selection) take a clipping path on its outline, within the selection tolerance, before the art it clips, so its segments can be dragged where the art covers the mask's edge; Select Behind lists the same, and the eyedropper and the tools that act on what is drawn are unchanged. Doc and tool regression tests. Scores unchanged. | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | | 2026-10-20 | minor | Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; scores and hours unchanged. |