From 263c36567f1a3bec25271121f50e09b942e3d409 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 19 Oct 2026 13:35:04 -0700 Subject: [PATCH] Fix #2143: a ruler guide can be the key object Align lines objects up on A click with the Selection tool on a guide selected with objects makes it the key (select.key {guide}), drawn thicker; a click on it again cancels it. Align to Key Object is on with one object and a selected guide and makes the guide the key. object.align lines the objects (or selected anchors) up on the guide's line and leaves the guide where it is: a vertical guide sets x only, a horizontal one y. The selection keeps key_guide beside key, never both; it goes with the guide or the objects, and follows the guide when earlier guides are deleted. Distribute Spacing doesn't space from a key guide. --- crates/doc/src/lib.rs | 1 + crates/doc/src/selection.rs | 17 ++++ crates/engine/src/cmd/object.rs | 39 +++++++-- crates/engine/src/cmd/select.rs | 42 ++++++++- crates/engine/src/inspect.rs | 2 + crates/engine/src/lib.rs | 3 + crates/engine/src/tests_keyguide.rs | 129 ++++++++++++++++++++++++++++ crates/tools/src/rulerguide.rs | 50 ++++++++++- crates/ui-egui/src/canvas.rs | 5 ++ crates/ui-egui/src/panels/align.rs | 60 ++++++++++--- docs/control-protocol.md | 2 +- docs/mcp.md | 9 +- docs/ui-parity.md | 4 +- 13 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/tests_keyguide.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c1a781dfe..12ecf8570 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1536,6 +1536,7 @@ impl Document { n += usize::from(*k); } sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); + sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); let before = self.guides.len(); let mut flags = kept.into_iter(); self.guides.retain(|_| flags.next().unwrap_or(true)); diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index ba8eb4b9f..3181aa572 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -43,6 +43,11 @@ pub struct Selection { /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub guides: Vec, + /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index + /// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set + /// with `key`; [`Self::prune`] clears it when no object is selected to align to it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_guide: Option, } impl Selection { @@ -67,6 +72,11 @@ impl Selection { self.target = None; self.slices.clear(); self.guides.clear(); + self.key_guide = None; + } + /// Is there a key for Align: an object or a guide? + pub fn has_key(&self) -> bool { + self.key.is_some() || self.key_guide.is_some() } pub fn set(&mut self, ids: impl IntoIterator) { self.clear(); @@ -114,6 +124,9 @@ impl Selection { pub fn toggle_guide(&mut self, i: usize) { if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); + if self.key_guide == Some(i) { + self.key_guide = None; + } } else { self.guides.push(i); } @@ -179,6 +192,10 @@ impl Selection { self.slices.retain(|id| doc.is_slice(*id)); // Guides on a hidden or locked layer can't stay selected. self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g))); + // A key guide needs objects to line up on it, and stays selected. + if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { + self.key_guide = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5ddf1ec32..231cafd7c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -243,7 +243,7 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), 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)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), 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)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", has_selection, align ), @@ -1270,10 +1270,32 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & ok() } -/// What `object.align` aligns to: `to`, else the key object when there is one, else the -/// selection. +/// What `object.align` aligns to: `to`, else the key object (or key guide) when there is one, +/// else the selection. fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { - str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key")) + str_param(p, "to").or_else(|| s.doc().ok()?.selection.has_key().then_some("key")) +} + +/// The key guide `object.align` lines the objects up on (aligning to the key with one set, +/// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical. +fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> { + let st = s.doc().ok()?; + let g = st.doc.guides.get(st.selection.key_guide?)?; + (align_to(s, p) == Some("key")).then_some((g.pos, g.vertical)) +} + +/// Where Align puts the objects, `r`, and the axes it moves them along (x, y): a key guide gives +/// the line across its axis only (a vertical guide fixes x, a horizontal one y), so the other +/// axis stays as it is. +fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: Option) -> Result<(Rect, bool, bool)> { + if let Some((pos, vertical)) = key_guide(s, p) { + return Ok((Rect::new(pos, pos, pos, pos), vertical, !vertical)); + } + let r = match own { + Some(b) if align_to(s, p) != Some("artboard") => b, + _ => reference_rect(s, p, ids, preview)?, + }; + Ok((r, true, true)) } fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { @@ -1328,7 +1350,8 @@ fn align(s: &mut Session, p: &Value) -> Result { if !s.doc()?.selection.anchors.is_empty() { return align_anchors(s, p, &ids, (h, v)); } - let r = reference_rect(s, p, &ids, preview)?; + let (r, along_x, along_y) = align_reference(s, p, &ids, preview, None)?; + let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); let key = key_root(s, &ids); let moves: Vec<(NodeId, Vec2)> = { let d = &s.doc()?.doc; @@ -1367,8 +1390,10 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else { return ok(); }; - // shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does. - let r = if align_to(s, p) == Some("artboard") { reference_rect(s, p, ids, false)? } else { bounds }; + // shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does + // (or to a key guide). + let (r, along_x, along_y) = align_reference(s, p, ids, false, Some(bounds))?; + let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); let target = |q: Point| { let x = match h { Some("left") => r.x0, diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index a6fb02140..cb7d06c02 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Cancel Key Object", [], None, - "{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: no id)} the key object Align aligns to and Distribute Spacing spaces from; without `id` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members); any other id is an error that leaves the key as it was → {key} (the resulting key object, or null)", + "{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members), or a key guide with no object selected or taken out of the selection; any other id or guide is an error that leaves the key as it was → {key, guide?} (the resulting key object, or null; guide: the key guide, left out without one)", has_doc, key ), @@ -368,6 +368,29 @@ fn selection_result(s: &Session) -> Result { fn key(s: &mut Session, p: &Value) -> Result { let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?; + let guide = match p.get("guide").filter(|v| !v.is_null()) { + Some(v) => { + Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("select.key", "`guide` must be a guide index (guide.list)"))?) + } + None => None, + }; + if let Some(g) = guide { + let sel = &s.doc()?.selection; + if id.is_some() { + return Err(bad("select.key", "give `id` or `guide`, not both")); + } + if !sel.guides.contains(&g) { + return Err(bad("select.key", format!("guide {g} is not selected"))); + } + if sel.is_empty() { + return Err(bad("select.key", "select objects to align to the guide")); + } + s.select(|_, sel| { + sel.key = None; + sel.key_guide = Some(g); + })?; + return key_result(s); + } if let Some(id) = id { let st = s.doc()?; // A selected top-level object is the only top-level object among the selected ones of @@ -377,8 +400,21 @@ fn key(s: &mut Session, p: &Value) -> Result { return Err(bad("select.key", format!("object {id} is not a selected top-level object"))); } } - s.select(|_, sel| sel.key = id)?; - Ok(json!({ "key": s.doc()?.selection.key })) + s.select(|_, sel| { + sel.key = id; + sel.key_guide = None; + })?; + key_result(s) +} + +/// `{key}`, with `guide` when a guide is the key. +fn key_result(s: &Session) -> Result { + let sel = &s.doc()?.selection; + let mut out = json!({ "key": sel.key }); + if let Some(g) = sel.key_guide { + out["guide"] = json!(g); + } + Ok(out) } /// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1850e5922..20db02bce 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -258,6 +258,8 @@ fn document_base(s: &Session) -> Value { "target": st.selection.target.map(|t| t.0), // The key object Align aligns to and Distribute Spacing spaces from (`select.key`). "key": st.selection.key.map(|k| k.0), + // The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`. + "keyGuide": st.selection.key_guide, "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58ba459f2..52839080e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -774,6 +774,7 @@ pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool { sel.remove(id); } sel.guides.clear(); + sel.key_guide = None; any } @@ -2149,6 +2150,8 @@ mod tests_intertwine; #[cfg(test)] mod tests_journal; #[cfg(test)] +mod tests_keyguide; +#[cfg(test)] mod tests_knockout; #[cfg(test)] mod tests_labspots; diff --git a/crates/engine/src/tests_keyguide.rs b/crates/engine/src/tests_keyguide.rs new file mode 100644 index 000000000..abcd4e160 --- /dev/null +++ b/crates/engine/src/tests_keyguide.rs @@ -0,0 +1,129 @@ +//! #2143: a ruler guide selected with objects can be the key object Align lines them up on. + +use serde_json::{Value, json}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn bounds(s: &Session, id: NodeId) -> vectorcraft_geom::Rect { + s.doc().unwrap().doc.bounds_of(&[id], false).unwrap() +} + +/// A Selection tool click at (`x`, `y`), with Shift when `shift`. +fn click(s: &mut Session, x: f64, y: f64, shift: bool) { + let (v, mods) = (ViewInfo::default(), Mods { shift, ..Default::default() }); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap(); + } +} + +#[test] +fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { + let mut s = session(); + let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 250.0)); + s.execute("guide.add", &json!({"vertical": true, "pos": 500})).unwrap(); + s.select_tool("selection", ViewInfo::default()).unwrap(); + // Click an object, Shift-click the guide, then click the guide again. + click(&mut s, 110.0, 110.0, false); + click(&mut s, 500.0, 400.0, true); + assert_eq!((s.doc().unwrap().selection.objects.clone(), s.doc().unwrap().selection.guides.clone()), (vec![a], vec![0])); + click(&mut s, 500.0, 400.0, false); + assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "the guide is the key"); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["keyGuide"], 0); + // Shift-click a third object: the guide stays the key. + click(&mut s, 310.0, 260.0, true); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); + let (ya, yb) = (bounds(&s, a).y0, bounds(&s, b).y0); + s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); + // Both centred on the guide's x; a vertical guide fixes no y, so they keep theirs. + for (id, y) in [(a, ya), (b, yb)] { + let r = bounds(&s, id); + assert!((r.center().x - 500.0).abs() < 1e-6 && (r.y0 - y).abs() < 1e-6, "{id:?}: {r:?}"); + } + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 500.0, "the guide stays put"); + s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); + assert!((bounds(&s, a).x0 - 500.0).abs() < 1e-6 && (bounds(&s, b).x0 - 500.0).abs() < 1e-6); + // A click on the key guide again: no key. + click(&mut s, 500.0, 400.0, false); + assert_eq!(s.doc().unwrap().selection.key_guide, None); +} + +#[test] +fn one_object_aligns_to_a_horizontal_key_guide() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!(s.execute("select.key", &json!({"guide": 0})).unwrap(), json!({"key": null, "guide": 0})); + let x = bounds(&s, a).x0; + s.execute("object.align", &json!({"vertical": "bottom", "horizontal": "right"})).unwrap(); + let r = bounds(&s, a); + assert!((r.y1 - 300.0).abs() < 1e-6 && (r.x0 - x).abs() < 1e-6, "{r:?}"); + // `to` still wins. + s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap(); + assert!(bounds(&s, a).x0.abs() < 1e-6); +} + +#[test] +fn select_key_takes_only_a_selected_guide_with_objects() { + let mut s = session(); + let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0)); + for pos in [500, 600] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let err = |s: &mut Session, p: Value| assert!(s.execute("select.key", &p).is_err(), "{p}"); + s.execute("guide.select", &json!({"indexes": [1]})).unwrap(); + err(&mut s, json!({"guide": 1})); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + err(&mut s, json!({"guide": 1})); + s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); + for bad in [json!({"guide": 7}), json!({"guide": -1}), json!({"guide": "x"}), json!({"guide": 1, "id": a.0})] { + err(&mut s, bad); + } + s.execute("select.key", &json!({"guide": 1})).unwrap(); + // An object key replaces the guide key, and the other way round. + s.execute("select.key", &json!({"id": b.0})).unwrap(); + assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (Some(b), None)); + s.execute("select.key", &json!({"guide": 1})).unwrap(); + assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (None, Some(1))); + // Deleting a guide before it keeps the key on the same guide. + s.execute("guide.remove", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 600.0); + // Taking the guide out of the selection, or every object, lets the key go. + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key_guide, None); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + s.execute("select.toggle", &json!({"id": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "one object is enough"); + s.execute("select.toggle", &json!({"id": b.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key_guide, None, "no object to align"); +} + +#[test] +fn selected_anchor_points_align_to_a_key_guide() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 400})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + s.execute("object.align", &json!({"horizontal": "center"})).unwrap(); + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(a).unwrap().kind else { panic!("path") }; + let p = path.subpaths[0].anchors[0].p; + assert_eq!(p.x, 400.0); + assert_eq!(path.subpaths[0].anchors[1].p.x, 140.0, "the other points stay"); +} diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index da32631a9..faa6ab9c9 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -1,7 +1,9 @@ //! Ruler guides on the canvas. Dragged out of a ruler with any tool ([`NewGuide`]), a guide is //! made where it is released over the canvas: with the Artboard tool, an artboard guide of the //! active artboard. For the selection tools ([`GuideEdit`]), a press on a guide selects it (Shift -//! adds it to the selected guides or, released without a drag, takes it out); dragging moves the +//! adds it to the selected guides or, released without a drag, takes it out; a click on a guide +//! selected with objects makes it the key object Align lines them up on, `select.key`, and a +//! click on the key guide again cancels it); dragging moves the //! selected guides (Alt copies them), and a marquee selects the guides it crosses along with the //! art ([`marquee_select`]). A dragged guide snaps ([`GuideSnap`]): with Shift to the //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' @@ -92,6 +94,9 @@ struct Drag { began: bool, /// Shift-pressed on a selected guide: released without a drag, it leaves the selection. deselect: bool, + /// Pressed (no Shift) on a guide selected with objects: released without a drag, it becomes + /// the key object Align lines them up on, or stops being it when it was. + key: bool, } /// Snapping for a dragged ruler guide, its targets gathered when the drag begins. @@ -246,6 +251,7 @@ impl GuideEdit { pointer: ev.pos, began: false, deselect: shift && selected, + key: !shift && selected && !cx.selection.is_empty(), }); self.snap = GuideSnap::default(); Some(match (selected, shift) { @@ -278,6 +284,11 @@ impl GuideEdit { vec![Action::Commit] } else if d.deselect { vec![Action::Exec("guide.select".into(), json!({ "indexes": [d.index], "toggle": true }))] + } else if d.key && cx.selection.key_guide == Some(d.index) { + // A click on the key guide again: no key. + vec![Action::Exec("select.key".into(), json!({}))] + } else if d.key { + vec![Action::Exec("select.key".into(), json!({ "guide": d.index }))] } else { vec![] } @@ -620,6 +631,43 @@ mod tests { assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": true}))); } + /// #2143: a click on a guide selected with objects makes it the key; on the key guide again, + /// no key. Alone (no objects), with Shift, or dragged, it doesn't. + #[test] + fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { + // The same document as doc_with_rect's, with the guides. + let (d, rect) = (guides_doc(), doc_with_rect().1); + let p = paint(); + let click = |s: &Selection, m: Mods| { + let c = cx(&d, s, &p); + let mut g = GuideEdit::default(); + let mut out = g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, m)).unwrap(); + out.extend(g.pointer(&c, &ev(PointerKind::Up, 302.0, 50.0, m)).unwrap()); + out + }; + let mut s = Selection::default(); + s.set([rect]); + s.toggle_guide(0); + let make = Action::Exec("select.key".into(), json!({"guide": 0})); + assert_eq!(click(&s, Mods::default()), vec![make]); + s.key_guide = Some(0); + assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); + s.set_guides([0]); + assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); + // Not selected yet: the first click selects it. + s.set([rect]); + assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); + // A drag moves it. + s.toggle_guide(0); + let c = cx(&d, &s, &p); + let mut g = GuideEdit::default(); + g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); + g.pointer(&c, &ev(PointerKind::Drag, 340.0, 50.0, Mods::default())); + assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 50.0, Mods::default())), Some(vec![Action::Commit])); + } + #[test] fn dropped_on_its_ruler_the_guide_is_deleted() { let d = guides_doc(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d919375af..f8f127071 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -502,6 +502,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Selected guides in the selection colour. let picked = LineLook { color: t.selection, ..look }; let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); + let key = app.session.active().and_then(|st| st.selection.key_guide); let view = view_bounds(&xf); // Guides on a hidden layer hide with it. for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { @@ -517,6 +518,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }; if selected.contains(&i) { picked } else { look }.guide(&painter, a, b); + // The key guide (Align to Key Object, #2143): thicker, as a key object's outline is. + if key == Some(i) { + painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color)); + } } } diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index c675bce54..942171193 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -37,10 +37,10 @@ pub fn deserialize_align_to<'de, D: serde::Deserializer<'de>>(d: D) -> Result AlignTo { - if n_selected == 1 { AlignTo::Artboard } else { to } + if n_selected == 1 && to != AlignTo::Key { AlignTo::Artboard } else { to } } /// `object.align` params: a single object aligns to the artboard (Illustrator does the same). @@ -112,8 +112,19 @@ pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { } } -/// Whether the selection has a key object. +/// Whether the selection has a key object or a key guide. fn has_key(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| st.selection.has_key()) +} + +/// The first selected ruler guide while objects are selected too: the one Align to Key Object +/// makes the key when there is no other object to be it. +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()) } @@ -136,7 +147,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // `spacing` is the Distribute Spacing value. The Distribute Objects and Distribute Spacing // buttons send it while there is a key object. let spacing: f64 = pstate::>(ui.ctx(), "align-spacing").unwrap_or(0.0); - let key = to == AlignTo::Key; + let key = to == AlignTo::Key && has_key_object(app); 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); @@ -206,17 +217,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (AlignTo::Key, "dc-alignto-key", tl!("Align to Key Object")), (AlignTo::Artboard, "dc-alignto-artboard", tl!("Align to Artboard")), ] { - // A key object is one of two or more selected objects. - let on = v != AlignTo::Key || n >= 2; + // A key object is one of two or more selected objects, or a guide selected + // with objects. + let on = v != AlignTo::Key || n >= 2 || has_key(app) || guide_for_key(app).is_some(); if widgets::icon_button_enabled(ui, icon, tl!(tip), shown == v, on, 28.0).clicked() { if v == AlignTo::Key { // The topmost selected object becomes the key object, unless // there is one already. The Align To choice stays as it was, and // Align To shows it again once the key is cleared. - if !has_key(app) - && let Some(id) = topmost(app) - { - app.run("select.key", json!({"id": id.0})).ok(); + // With one object, a guide selected with it becomes the key. + if !has_key(app) { + match (topmost(app).filter(|_| n >= 2), guide_for_key(app)) { + (Some(id), _) => app.run("select.key", json!({"id": id.0})).ok(), + (None, Some(g)) => app.run("select.key", json!({"guide": g})).ok(), + (None, None) => None, + }; } } else { app.ui.align_to = v; @@ -263,7 +278,28 @@ mod tests { assert_eq!(p["to"], "artboard"); let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3); assert_eq!(p["to"], "selection"); - assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "artboard"); + // One object and a key: the key is a guide (#2143), which it aligns to. + assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key"); + } + + /// #2143: with one object and a guide selected, Align to Key Object is on and makes the guide + /// the key; Align then lines the object up on it rather than on the artboard. + #[test] + fn align_to_key_object_makes_a_selected_guide_the_key() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("guide.select", json!({"indexes": [0], "toggle": true})).unwrap(); + let ctx = egui::Context::default(); + let ([_, key, _], left) = panel_buttons(&mut app, &ctx); + click(&ctx, &mut app, key.center(), show); + assert_eq!(app.session.active().unwrap().selection.key_guide, Some(0), "{}", app.ui.status); + assert_eq!(align_to(&app), AlignTo::Key); + click(&ctx, &mut app, left.center(), show); + assert_eq!(left_edge(&app, &a), 120.0, "on the guide, not the artboard: {}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 120.0); } /// A Shift-click that leaves the key object selected on its own lets the key go, and the align diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1fbb9b1aa..332ac0323 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -90,7 +90,7 @@ the two can still win that race. |---|---|---| | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`). A key of `params` that the command's params description doesn't name is an error naming it and the nearest key the command takes, before anything runs (`unknown parameter `widht`; did you mean `width`?`); see [Parameter keys](mcp.md#parameter-keys) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | -| `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | +| `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one; a key ruler guide as `keyGuide`), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | | `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | diff --git a/docs/mcp.md b/docs/mcp.md index cd2711e11..bced3e6a3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1084,8 +1084,13 @@ Any other id is an error naming that value, and the key is left unchanged. A null `id` is an error too. A click with the Selection tool on a selected top-level object, while two or more objects are selected, runs `select.key {id}`, and a click on the key object again runs `select.key {}`. On a single selected object, or on a group selected with only its own members, `select.key` sets no key. -It returns `{key}`, the key object's id or null. -`document.inspect` (the `inspect_document` tool) reports the key object as `key`, null without one. +`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. +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. +`document.inspect` (the `inspect_document` tool) reports the key object as `key`, null without one, and the key guide as `keyGuide`. The `ui.pointer` and `pointer_gesture` replies list it as `key` beside `selection`. The command palette lists `select.key` as Cancel Key Object and runs it without an id. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dd92003b4..ba96d7b46 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-19 · **Change:** minor (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-19 · **Change:** minor (a ruler guide can be the key object Align aligns to, #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,6 +74,7 @@ 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 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 | | Drag a curved segment to bend it, a straight one to move its anchors | done | | @@ -255,6 +256,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | A ruler guide selected with objects can be the key object (#2143): the Selection tool's click on it, or Align to Key Object with one object; drawn thicker; Align lines the objects up on it and the guide stays put; scores unchanged | | 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. | | 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. |