From be32d6be892ed1bba69309f9dde6176c5c5b6929 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:14:03 -0800 Subject: [PATCH] Align: a Shift-clicked anchor becomes the key anchor that Align and Distribute keep in place With Direct Selection, the point a Shift-click adds to two or more selected anchor points is the key anchor. It is drawn as a larger filled square in a frame, and the Align panel and the Control bar show the key choice chosen as Align to Key Anchor. The Align buttons move the other points to its x or y, and Distribute with a spacing places them from it. A plain click still selects one point alone. select.key takes {id, anchor} to set it, select.key {} cancels it, and document.inspect reports it as keyAnchor. It goes when it is deselected, deleted or left as the only selected point. --- crates/doc/src/selection.rs | 61 +++++++++- crates/engine/src/cmd/object/align.rs | 44 +++++-- crates/engine/src/cmd/object/commands.rs | 6 +- crates/engine/src/cmd/select.rs | 59 ++++++++- crates/engine/src/inspect.rs | 2 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/keyanchor.rs | 149 +++++++++++++++++++++++ crates/ui-egui/src/canvas/overlays.rs | 13 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/gu.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/align.rs | 27 ++-- crates/ui-egui/src/panels/align/tests.rs | 26 ++++ docs/mcp.md | 9 ++ docs/ui-parity.md | 2 +- 40 files changed, 397 insertions(+), 30 deletions(-) create mode 100644 crates/engine/src/tests/keyanchor.rs diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 4ef6446247..541e7801c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -49,6 +49,13 @@ pub struct Selection { /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] pub key_guide: Option, + /// The key anchor for Align: one of two or more anchor points selected with Direct Selection, + /// the last one a Shift-click added (path, anchor in [`Self::anchors`]). Align and Distribute + /// move the other selected points relative to it, and it stays in place. Never set with `key` + /// or `key_guide`; [`Self::prune`] clears it when the point is no longer a selected anchor, or + /// fewer than two points are selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_anchor: Option<(NodeId, AnchorRef)>, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -98,6 +105,7 @@ impl Selection { self.slices.clear(); self.guides.clear(); self.key_guide = None; + self.key_anchor = None; self.symbol_part = None; self.on_path_member = None; } @@ -113,9 +121,15 @@ impl Selection { self.set([instance]); self.symbol_part = Some(SymbolPart { instance, path }); } - /// Is there a key for Align: an object or a guide? + /// Is there a key for Align: an object, a guide or an anchor point? pub fn has_key(&self) -> bool { - self.key.is_some() || self.key_guide.is_some() + self.key.is_some() || self.key_guide.is_some() || self.key_anchor.is_some() + } + /// Where the key anchor ([`Self::key_anchor`]) is. `None` without one, or when the point is + /// gone from its path. + pub fn key_anchor_point(&self, doc: &Document) -> Option { + let (id, (si, ai)) = self.key_anchor?; + Some(doc.node(id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p) } pub fn set(&mut self, ids: impl IntoIterator) { self.clear(); @@ -143,6 +157,9 @@ impl Selection { if self.key == Some(id) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| k == id) { + self.key_anchor = None; + } } /// [`Self::remove`] for each of `ids`, going through the selected objects once. pub fn remove_all(&mut self, ids: impl IntoIterator) { @@ -162,6 +179,9 @@ impl Selection { if self.key.is_some_and(|k| ids.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| ids.contains(&k)) { + self.key_anchor = None; + } } /// Select slices `ids` (see [`Selection::slices`]) and nothing else. pub fn set_slices(&mut self, ids: impl IntoIterator) { @@ -271,6 +291,40 @@ impl Selection { if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { self.key_guide = None; } + // A key anchor needs its point on its path and selected as an anchor point (not as a + // selected segment's end), with another point selected too: another anchor, or a path + // selected whole (Align and Distribute act on path points only). It takes the place of a + // key object or key guide. + let path_of = |o: &NodeId| found.get(o).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()); + let whole_path = |o: &NodeId| !self.anchors.contains_key(o) && path_of(o).is_some_and(|path| path.anchors().next().is_some()); + // Selected anchor points that are on their paths, counted up to two. + let points = || { + self.anchors + .iter() + .flat_map(|(o, set)| { + let path = path_of(o); + set.iter().filter(move |&&(si, ai)| path.is_some_and(|p| p.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len()))) + }) + .take(2) + .count() + }; + let anchor_kept = self.key_anchor.is_some_and(|(id, (si, ai))| { + self.objects.contains(&id) + && !self.segments.contains_key(&id) + && self.anchors.get(&id).is_some_and(|set| set.contains(&(si, ai))) + && found + .get(&id) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.path_data()) + .is_some_and(|path| path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) + && (points() >= 2 || self.objects.iter().any(whole_path)) + }); + if anchor_kept { + self.key = None; + self.key_guide = None; + } else { + self.key_anchor = None; + } // A selected part needs its instance selected alone, with no guides, its symbol dynamic // and the object still in the symbol's art as a path, compound path or type, where neither // it nor a group or layer around it is hidden, locked or a template. @@ -348,6 +402,9 @@ impl Selection { if self.key.is_some_and(|k| gone.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| gone.contains(&k)) { + self.key_anchor = None; + } } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected /// (the art of its sublayers too), anything else is selected itself. diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index c3a2898571..d55fdda7b2 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -292,8 +292,22 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> .collect()) } +/// The key anchor ([`vectorcraft_doc::Selection::key_anchor`]) when Align and Distribute act on the +/// selected anchor points and align to the key ([`align_to`]): the path and the point. +fn key_anchor(s: &Session, p: &Value) -> Option<(NodeId, (usize, usize))> { + (align_to(s, p) == Some("key")).then(|| s.doc().ok()?.selection.key_anchor).flatten() +} + +/// Where the key anchor of [`key_anchor`] is. +fn key_anchor_point(s: &Session, p: &Value) -> Option { + key_anchor(s, p)?; + let st = s.doc().ok()?; + st.selection.key_anchor_point(&st.doc) +} + /// [`align`] of the selected anchor points (whole paths among them give all theirs): each point -/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard. +/// moves to the edge or centre of the points' bounds, of the artboard with Align To Artboard, or to +/// the key anchor when aligning to it. fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result { let targets = crate::cmd::path::anchor_targets(s)?; let d = &s.doc()?.doc; @@ -304,9 +318,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 - // (or to a key guide). - let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(bounds))?; + // Aligning to the key, the points go to the key anchor, which stays where it is. Without one, + // a key object aligns them to their bounds as Selection does (a key guide lines them up on it). + let own = key_anchor_point(s, p).map_or(bounds, |k| Rect::from_points(k, k)); + let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(own))?; let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); let target = |q: Point| { let x = match h { @@ -410,9 +425,10 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: /// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them /// give all theirs): the points between the first and the last along the axis are spaced evenly, -/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis -/// gives the same result. -fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option) -> Result { +/// or `gap` apart from the first. With a gap and a key anchor `key` among them, the key anchor stays +/// where it is and the others are placed `gap` apart outward from it, in their order along the +/// axis. A point has no size, so every Distribute option of an axis gives the same result. +fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option, key: Option<(NodeId, (usize, usize))>) -> Result { let targets = crate::cmd::path::anchor_targets(s)?; let d = &s.doc()?.doc; let along = |q: Point| if horiz { q.x } else { q.y }; @@ -431,8 +447,11 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option = - pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect(); + pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, at_from + step * (i as f64 - from as f64) - at)).collect(); if moves.iter().all(|m| m.3.abs() <= 1e-9) { return ok(); } @@ -466,11 +485,13 @@ pub(super) fn distribute(s: &mut Session, p: &Value) -> Result { let m = measure(s, p, "object.distribute")?; let board = distribute_to(s, p, &ids, m, "object.distribute")?; let spacing = spacing(p, "object.distribute")?; - // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them. + // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them; + // with a key anchor, a spacing places them from it. if !s.doc()?.selection.anchors.is_empty() { + let key = key_anchor(s, p); return match direction(p, "object.distribute")? { (None, None) => Err(bad("object.distribute", "give horizontal or vertical")), - (h, _) => distribute_anchors(s, "Distribute", h.is_some(), None), + (h, _) => distribute_anchors(s, "Distribute", h.is_some(), spacing.filter(|_| key.is_some()), key), }; } let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { @@ -543,7 +564,8 @@ pub(super) fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let board = distribute_to(s, p, &ids, m, "object.distributeSpacing")?; let spacing = spacing(p, "object.distributeSpacing")?; if !s.doc()?.selection.anchors.is_empty() { - return distribute_anchors(s, "Distribute Spacing", horiz, spacing); + let key = key_anchor(s, p); + return distribute_anchors(s, "Distribute Spacing", horiz, spacing, key); } // The selected guides across the axis are spaced with the objects, as items with no size (#1597). let mut items = spaced_items(s, &ids, m, horiz)?; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index b8c4199623..e89c8fd208 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -260,7 +260,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; 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; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), 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; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), 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, to the artboard (#1180), or, aligning to the key with a key anchor (select.key {id, anchor}, the last point a Shift-click added), to the key anchor, which stays put", has_selection_or_guides, align ), @@ -269,7 +269,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{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)", + "{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 and no key anchor), 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); with spacing and a key anchor (select.key {id, anchor}), the key anchor stays put and the others are placed spacing apart, outward from it, in their order along the axis", can_distribute, distribute ), @@ -278,7 +278,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), 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)", + "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), 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), or with a key anchor (select.key {id, anchor}) `spacing` apart outward from the key anchor, which stays put", can_distribute, distribute_spacing ), diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index ac9ca365f4..9aa304e96e 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -81,11 +81,19 @@ 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 or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; 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) and no ruler guide, 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)", + "{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 or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; 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), anchor?: [subpath, anchor] with `id` the path (an anchor point selected with Direct Selection beside at least one other point becomes the key anchor: Align and Distribute move the other points relative to it and it stays put; select.anchors add or toggle of one point makes it the key anchor, as a Shift-click does)} 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) and no ruler guide, or a key guide with no object selected or taken out of the selection, or a key anchor that is deselected, deleted or left as the only selected point; any other id, guide or anchor is an error that leaves the key as it was → {key, guide?, keyAnchor?} (the resulting key object, or null; guide: the key guide, left out without one; keyAnchor: {id, anchor}, left out without one)", has_doc, key ), - cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors), + cmd!( + "select.anchors", + "Select Anchors", + [], + None, + "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}; add or toggle that selects one point makes it the key anchor while another point is selected too (see select.key)", + has_doc, + anchors + ), cmd!( "select.segments", "Select Segments", @@ -610,6 +618,9 @@ fn key(s: &mut Session, p: &Value) -> Result { } None => None, }; + if let Some(v) = p.get("anchor").filter(|v| !v.is_null()) { + return key_anchor(s, id, guide, v); + } if let Some(g) = guide { let sel = &s.doc()?.selection; if id.is_some() { @@ -624,6 +635,7 @@ fn key(s: &mut Session, p: &Value) -> Result { } s.select(|_, sel| { sel.key = None; + sel.key_anchor = None; sel.key_guide = Some(gid); })?; return key_result(s); @@ -640,17 +652,46 @@ fn key(s: &mut Session, p: &Value) -> Result { s.select(|_, sel| { sel.key = id; sel.key_guide = None; + sel.key_anchor = None; })?; key_result(s) } -/// `{key}`, with `guide` when a guide is the key. +/// `select.key {id, anchor: [subpath, anchor]}`: anchor point `anchor` of path `id`, selected with +/// Direct Selection beside at least one other point, becomes the key anchor +/// ([`vectorcraft_doc::Selection::key_anchor`]). +fn key_anchor(s: &mut Session, id: Option, guide: Option, v: &Value) -> Result { + const C: &str = "select.key"; + let r = match checked_refs(Some(&Value::Array(vec![v.clone()])), C)?.as_slice() { + [r] => *r, + _ => return Err(bad(C, "`anchor` must be a [subpath, anchor] pair")), + }; + if guide.is_some() { + return Err(bad(C, "give `anchor` or `guide`, not both")); + } + let id = id.ok_or_else(|| bad(C, "`anchor` needs `id`, the path the point is on"))?; + let sel = &s.doc()?.selection; + if sel.segments.contains_key(&id) || !sel.anchors.get(&id).is_some_and(|set| set.contains(&r)) { + return Err(bad(C, format!("anchor [{}, {}] of object {id} is not a selected anchor point", r.0, r.1))); + } + s.select(|_, sel| sel.key_anchor = Some((id, r)))?; + if s.doc()?.selection.key_anchor.is_none() { + return Err(bad(C, "select another anchor point to align to the key anchor")); + } + key_result(s) +} + +/// `{key}`, with `guide` when a guide is the key and `keyAnchor` (`{id, anchor}`) when an anchor +/// point is. fn key_result(s: &Session) -> Result { let st = s.doc()?; let mut out = json!({ "key": st.selection.key }); if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) { out["guide"] = json!(g); } + if let Some((id, (si, ai))) = st.selection.key_anchor { + out["keyAnchor"] = json!({ "id": id.0, "anchor": [si, ai] }); + } Ok(out) } @@ -783,15 +824,23 @@ fn anchors(s: &mut Session, p: &Value) -> Result { if whole && let Some(path) = d.node(id).and_then(|n| n.path_data()) { e.extend(path.anchors().map(|(s, i, _)| (s, i))); } + // One point that add or toggle selects becomes the key anchor (a Shift-click with Direct + // Selection); `prune` drops it unless another point is selected too. + let single = refs.len() == 1; + let mut newest = None; for r in refs { if mode == "toggle" && e.contains(&r) { e.remove(&r); - } else { - e.insert(r); + } else if e.insert(r) && single { + newest = Some(r); } } if e.is_empty() { sel.remove(id); + } else if mode != "set" + && let Some(r) = newest + { + sel.key_anchor = Some((id, r)); } })?; ok() diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 146583778f..323d5d0918 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -308,6 +308,8 @@ fn document_base(s: &Session) -> Value { "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.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()), + // The anchor point that is the key instead (`select.key {id, anchor}`): {id, anchor}. + "keyAnchor": st.selection.key_anchor.map(|(id, (si, ai))| json!({"id": id.0, "anchor": [si, ai]})), "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/tests.rs b/crates/engine/src/tests.rs index f89cc98ff8..74b0260235 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -86,6 +86,7 @@ mod inline; mod interactivity; mod intertwine; mod journal; +mod keyanchor; mod keyguide; mod knockout; mod labspots; diff --git a/crates/engine/src/tests/keyanchor.rs b/crates/engine/src/tests/keyanchor.rs new file mode 100644 index 0000000000..afa321ff73 --- /dev/null +++ b/crates/engine/src/tests/keyanchor.rs @@ -0,0 +1,149 @@ +//! #1636: the key anchor. With Direct Selection, the point a Shift-click adds to two or more +//! selected anchor points is the key anchor; Align moves the other points to it, Distribute with a +//! spacing places them from it, and it stays in place. + +use serde_json::json; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path(s: &mut Session, d: &str) -> NodeId { + NodeId(s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap()) +} + +fn pts(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() +} + +fn key_anchor(s: &Session) -> Option<(NodeId, (usize, usize))> { + s.doc().unwrap().selection.key_anchor +} + +/// A Direct Selection 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_shift_click_makes_the_key_anchor_and_a_plain_click_narrows_the_selection() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + click(&mut s, 100.0, 40.0, false); + assert_eq!(key_anchor(&s), None, "one point: no key anchor"); + click(&mut s, 150.0, 160.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 2))), "the point the Shift-click added"); + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 1))), "the last point added"); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["keyAnchor"], json!({"id": id.0, "anchor": [0, 1]})); + // A Shift-click on the key anchor deselects it, and there is no key anchor. + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), None); + click(&mut s, 200.0, 60.0, true); + // A plain click on one of the selected points selects that point alone. + click(&mut s, 100.0, 40.0, false); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.anchors.get(&id).map(|a| a.iter().copied().collect::>()), Some(vec![(0, 0)])); + assert_eq!(sel.key_anchor, None); +} + +#[test] +fn align_moves_the_other_points_to_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 L60 120 Z"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a set selects no key anchor"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((id, (0, 2)))); + s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 40.0), (150.0, 60.0), (150.0, 160.0), (60.0, 120.0)], "to the key anchor's x"); + s.execute("object.align", &json!({"vertical": "bottom"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 160.0), (150.0, 160.0), (150.0, 160.0), (60.0, 120.0)], "the key anchor stays"); + let steps = super::history(&s); + s.execute("object.align", &json!({"vertical": "top"})).unwrap(); + assert_eq!(super::history(&s), steps, "nothing moved: no undo step"); + // `to: "selection"` aligns to the points' bounds instead. + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 3]], "mode": "add"})).unwrap(); + s.execute("object.align", &json!({"horizontal": "left", "to": "selection"})).unwrap(); + assert_eq!(pts(&s, id).iter().map(|p| p.0).collect::>(), [60.0; 4]); +} + +#[test] +fn distribute_with_a_spacing_places_the_points_from_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M10 10 L20 50 L100 30 L60 90"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 20})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 10.0), (80.0, 50.0), (100.0, 30.0), (60.0, 90.0)], "20 apart, outward from the key anchor"); + s.execute("object.distribute", &json!({"vertical": "center", "spacing": 5})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 25.0), (80.0, 35.0), (100.0, 30.0), (60.0, 90.0)], "in their order along y, from y 30"); + // Without a spacing, Distribute spaces the points between the first and the last. + s.execute("object.distribute", &json!({"horizontal": "center"})).unwrap(); + assert_eq!(pts(&s, id)[1].0, 80.0); +} + +#[test] +fn the_key_anchor_goes_when_it_can_no_longer_be_one() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + let b = path(&mut s, "M300 300 L400 300"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((b, (0, 1))), "a point of another path"); + assert!(s.doc().unwrap().selection.has_key()); + let r = s.execute("select.key", &json!({})).unwrap(); + assert_eq!((r["key"].clone(), r.get("keyAnchor")), (json!(null), None), "select.key {{}} cancels it"); + assert_eq!(key_anchor(&s), None); + let r = s.execute("select.key", &json!({"id": b.0, "anchor": [0, 1]})).unwrap(); + assert_eq!(r["keyAnchor"], json!({"id": b.0, "anchor": [0, 1]})); + // Deleting the key anchor's path leaves no key anchor. + s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(key_anchor(&s), None); + // A key object replaces a key anchor. + let c = path(&mut s, "M500 500 L600 500"); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((a, (0, 1))), "beside a path selected whole"); + s.execute("select.key", &json!({"id": c.0})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.key, sel.key_anchor), (Some(c), None)); + // Type selected whole has no points to align, so one anchor beside it is no key anchor. + let t = NodeId(s.execute("text.create", &json!({"x": 40, "y": 400, "size": 12, "text": "Key"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None); +} + +#[test] +fn select_key_rejects_an_anchor_that_cannot_be_the_key() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let err = |s: &mut Session, p: serde_json::Value| s.execute("select.key", &p).expect_err(&p.to_string()).to_string(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 9]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a point the path does not have"); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point on the path"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 2]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 9]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0]})).contains("expected [subpath, anchor]")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, -1]})).contains("non-negative integer")); + assert!(err(&mut s, json!({"anchor": [0, 1]})).contains("needs `id`")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 1], "guide": 0})).contains("not both")); + // A selected segment's ends are not selected anchor points. + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("is not a selected anchor point")); + assert_eq!(key_anchor(&s), None, "the key stays as it was"); +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 6e222cc405..139d0adc7d 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -86,6 +86,11 @@ impl<'a> HandleLook<'a> { } } +/// How many points bigger than the other selected anchors the key anchor's square is drawn. +const KEY_ANCHOR_GROW: f32 = 2.0; +/// The gap between the key anchor's square and the frame drawn around it. +const KEY_ANCHOR_GAP: f32 = 2.0; + pub(super) fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { let r = egui::Rect::from_center_size(c, vec2(size, size)); if filled { @@ -188,6 +193,14 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); } marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects }); + // The key anchor (Align to Key Anchor): a filled square bigger than the other anchors, in + // a frame as thick as a key object's outline. + if let Some(((k, _), q)) = st.selection.key_anchor.zip(st.selection.key_anchor_point(&st.doc)) { + let (c, color, size) = (xf.to_screen(q), c32(st.doc.layer_color(k)), anchor(direct) + KEY_ANCHOR_GROW); + anchor_square(p, c, color, true, size); + let frame = egui::Rect::from_center_size(c, vec2(size + 2.0 * KEY_ANCHOR_GAP, size + 2.0 * KEY_ANCHOR_GAP)); + p.rect_stroke(frame, 0.0, Stroke::new(KEY_OUTLINE, color), StrokeKind::Outside); + } } // The spine of each selected blend (or of the blend a selected key object belongs to). let mut spines = vec![]; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 948b295e17..34dcaf4a8b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -146,6 +146,7 @@ Align to Artboard Подравняване спрямо работната област Align to Glyph Bounds Подравняване спрямо границите на глифа Align to Key Object Подравняване спрямо ключов обект + Align to Key Anchor Подравняване спрямо ключова опорна точка Align to Page Подравняване спрямо страницата Align to Path Подравняване спрямо пътя Align to Selection Подравняване спрямо селекцията diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f8d6fa263b..003e0b036b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1491,6 +1491,7 @@ lighting Light Svjetlo Align to Artboard Poravnaj s radnom pločom Align to Glyph Bounds Poravnaj s granicama glifa Align to Key Object Poravnaj s ključnim objektom + Align to Key Anchor Poravnaj s ključnom sidrenom tačkom Align to Selection Poravnaj prema odabiru Align To: Poravnaj s: Cancel Key Object Otkaži ključni objekat diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3ed3ca8331..7162092c1a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -242,6 +242,7 @@ Align to Artboard Alinea al tauler Align to Glyph Bounds Alinea als límits del glif Align to Key Object Alinea a l’objecte clau + Align to Key Anchor Alinea al punt d’ancoratge clau Align to Page Alinea a la pàgina Align to Path Alinea al traçat Align to Selection Alinea a la selecció diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 161e235c56..0b69dc14c5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1519,6 +1519,7 @@ lighting Light Světlo Align to Artboard Zarovnat k pracovní ploše Align to Glyph Bounds Zarovnat k hranicím glyfů Align to Key Object Zarovnat ke klíčovému objektu + Align to Key Anchor Zarovnat ke klíčovému kotevnímu bodu Align to Selection Zarovnat k výběru Align To: Zarovnat k: Cancel Key Object Zrušit klíčový objekt diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index fd0b7c042e..4d7702cb3d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2035,6 +2035,7 @@ lighting Light Lys Align to Artboard Juster til tegnebræt Align to Glyph Bounds Juster til glyfafgrænsning Align to Key Object Juster til nøgleobjekt + Align to Key Anchor Juster til nøgleankerpunkt Align to Selection Juster til markering Align To: Juster til: Cancel Key Object Annullér nøgleobjekt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2a5f32a5d9..f263a9f9b4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -135,6 +135,7 @@ Align to Artboard An Zeichenfläche ausrichten Align to Glyph Bounds An Glyphenbegrenzung ausrichten Align to Key Object An Basisobjekt ausrichten + Align to Key Anchor An Basisankerpunkt ausrichten Align to Page An Seite ausrichten Align to Path An Pfad ausrichten Align to Selection An Auswahl ausrichten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b1b7b4d039..caff68c490 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -140,6 +140,7 @@ Align to Artboard Στοίχιση ως προς τον πίνακα σχεδίασης Align to Glyph Bounds Στοίχιση στα όρια των γλυφών Align to Key Object Στοίχιση ως προς το βασικό αντικείμενο + Align to Key Anchor Στοίχιση ως προς το βασικό σημείο αγκύρωσης Align to Page Στοίχιση ως προς τη σελίδα Align to Path Στοίχιση ως προς τη διαδρομή Align to Selection Στοίχιση ως προς την επιλογή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b3c675f7b..fafa644a60 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -160,6 +160,7 @@ Align to Artboard Alinear con la mesa de trabajo Align to Glyph Bounds Alinear con los límites del glifo Align to Key Object Alinear con el objeto clave + Align to Key Anchor Alinear con el punto de ancla clave Align to Page Alinear con la página Align to Path Alinear con el trazado Align to Selection Alinear con la selección diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e77a617c52..d4ba43a2b7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1493,6 +1493,7 @@ lighting Light Valo Align to Artboard Tasaa piirtoalustaan Align to Glyph Bounds Tasaa merkin rajoihin Align to Key Object Tasaa avainobjektiin + Align to Key Anchor Tasaa avainankkuripisteeseen Align to Selection Tasaa valintaan Align To: Tasaa: Cancel Key Object Peruuta avainobjekti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2762d2a954..cb489d39c0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -161,6 +161,7 @@ Align to Artboard Aligner sur le plan de travail Align to Glyph Bounds Aligner sur les limites du glyphe Align to Key Object Aligner sur l’objet clé + Align to Key Anchor Aligner sur le point d’ancrage clé Align to Page Aligner sur la page Align to Path Aligner sur le tracé Align to Selection Aligner sur la sélection diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 1c96900b2b..74f043de20 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -188,6 +188,7 @@ Align to Artboard આર્ટબોર્ડ પર સંરેખિત કરો Align to Glyph Bounds ગ્લિફ સીમા પર સંરેખિત કરો Align to Key Object કી ઑબ્જેક્ટ પર સંરેખિત કરો + Align to Key Anchor કી એન્કર બિંદુ પર સંરેખિત કરો Align to Page પેજ પર સંરેખિત કરો Align to Path પાથ પર સંરેખિત કરો Align to Pixel Grid પિક્સેલ ગ્રિડ પર સંરેખિત કરો diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 548f961f51..e05fdd3262 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -283,6 +283,7 @@ Align to Corners and Ends कोनों और सिरों पर संरेखित करें Align to Glyph Bounds ग्लिफ़ की सीमाओं पर संरेखित करें Align to Key Object मुख्य ऑब्जेक्ट पर संरेखित करें + Align to Key Anchor मुख्य एंकर पॉइंट पर संरेखित करें Align to Page पेज पर संरेखित करें Align to Path पाथ पर संरेखित करें Align to Selection सिलेक्शन पर संरेखित करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 200b45d1b1..148af36fc7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -229,6 +229,7 @@ Align to Artboard Igazítás a rajztáblához Align to Glyph Bounds Igazítás a glifák határaihoz Align to Key Object Igazítás a kulcsobjektumhoz + Align to Key Anchor Igazítás a kulcs-horgonyponthoz Align to Page Igazítás az oldalhoz Align to Path Igazítás az útvonalhoz Align to Selection Igazítás a kijelöléshez diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f3dcca3874..5cc0a03ed5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1426,6 +1426,7 @@ lighting Light Cahaya Align to Artboard Ratakan ke artboard Align to Glyph Bounds Ratakan ke batas glif Align to Key Object Ratakan ke objek kunci + Align to Key Anchor Ratakan ke titik jangkar kunci Align to Selection Ratakan ke seleksi Align To: Ratakan ke: Cancel Key Object Batalkan objek kunci diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 90ec79404c..34d36705db 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -160,6 +160,7 @@ Align to Artboard Allinea alla tavola da disegno Align to Glyph Bounds Allinea ai limiti del glifo Align to Key Object Allinea all'oggetto chiave + Align to Key Anchor Allinea al punto di ancoraggio chiave Align to Page Allinea alla pagina Align to Path Allinea al tracciato Align to Selection Allinea alla selezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d98acfa22d..b335b76df1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1076,6 +1076,7 @@ digits Malayalam マラヤーラム数字 Align to Artboard アートボードに整列 Align to Glyph Bounds 字形の境界に整列 Align to Key Object キーオブジェクトに整列 + Align to Key Anchor キーアンカーポイントに整列 Align to Page ページに沿う Align to Path パスに沿う Align to Selection 選択範囲に整列 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 794e5454a9..56d6a9048f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1365,6 +1365,7 @@ lighting Light სინათლე Align to Artboard სამუშაო დაფაზე სწორება Align to Glyph Bounds გლიფის საზღვრებზე სწორება Align to Key Object საკვანძო ობიექტზე სწორება + Align to Key Anchor საკვანძო საყრდენ წერტილზე სწორება Align to Selection მონიშნულზე სწორება Align To: სწორება: Cancel Key Object საკვანძო ობიექტის გაუქმება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bb8676df71..db692e1e7b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1066,6 +1066,7 @@ digits Malayalam 말라얄람어 Align to Artboard 아트보드에 정렬 Align to Glyph Bounds 글리프 경계에 정렬 Align to Key Object 키 개체에 정렬 + Align to Key Anchor 키 앵커 포인트에 정렬 Align to Page 페이지에 정렬 Align to Path 패스에 정렬 Align to Selection 선택 영역에 정렬 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a597a03fd0..775308d392 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1944,6 +1944,7 @@ lighting Light Licht Align to Artboard Uitlijnen op tekengebied Align to Glyph Bounds Uitlijnen op glyphbegrenzing Align to Key Object Uitlijnen op sleutelobject + Align to Key Anchor Uitlijnen op sleutelankerpunt Align to Selection Uitlijnen op selectie Align To: Uitlijnen op: Cancel Key Object Sleutelobject annuleren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 99ee2080b8..fdcec3b241 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1432,6 +1432,7 @@ lighting Light Światło Align to Artboard Wyrównaj do obszaru kompozycji Align to Glyph Bounds Wyrównaj do granic glifów Align to Key Object Wyrównaj do obiektu kluczowego + Align to Key Anchor Wyrównaj do kluczowego punktu kontrolnego Align to Selection Wyrównaj do zaznaczenia Align To: Wyrównaj do: Cancel Key Object Anuluj obiekt kluczowy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 677c8e460f..5d2bd7b254 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -164,6 +164,7 @@ Align to Artboard Alinhar à Mesa de Trabalho Align to Glyph Bounds Alinhar aos Limites do Glifo Align to Key Object Alinhar ao Objeto-Chave + Align to Key Anchor Alinhar ao Ponto de Ancoragem-Chave Align to Page Alinhar à Página Align to Path Alinhar ao Caminho Align to Selection Alinhar à Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 106343cddd..dfe6da25e8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -158,6 +158,7 @@ Align to Artboard Выровнять по монтажной области Align to Glyph Bounds Выровнять по границам глифа Align to Key Object Выровнять по ключевому объекту + Align to Key Anchor Выровнять по ключевой опорной точке Align to Page Выровнять по странице Align to Path Выровнять по контуру Align to Selection Выровнять по выделению diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cc854aa362..539ec3e2d9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -148,6 +148,7 @@ Align to Artboard Justera mot rityta Align to Glyph Bounds Justera mot glyfgränser Align to Key Object Justera mot nyckelobjekt + Align to Key Anchor Justera mot nyckelankarpunkt Align to Page Justera mot sida Align to Path Justera mot bana Align to Selection Justera mot markering diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f2888a30f0..110a4f55ef 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -145,6 +145,7 @@ Align to Artboard Çalışma Yüzeyine Hizala Align to Glyph Bounds Glif Sınırlarına Hizala Align to Key Object Anahtar Nesneye Hizala + Align to Key Anchor Anahtar Bağlantı Noktasına Hizala Align to Page Sayfaya Hizala Align to Path Yola Hizala Align to Selection Seçime Hizala diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 411b2c3baf..85f4f77c98 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -154,6 +154,7 @@ Align to Artboard Вирівняти по монтажній області Align to Glyph Bounds Вирівняти за межами гліфа Align to Key Object Вирівняти за ключовим об’єктом + Align to Key Anchor Вирівняти за ключовою опорною точкою Align to Page Вирівняти за сторінкою Align to Path Вирівняти за контуром Align to Selection Вирівняти за виділенням diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1460910197..dfe5dd2fcb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -175,6 +175,7 @@ Align to Artboard Căn theo bảng vẽ Align to Glyph Bounds Căn theo giới hạn glyph Align to Key Object Căn theo đối tượng chính + Align to Key Anchor Căn theo điểm neo chính Align to Page Căn theo trang Align to Path Căn theo đường dẫn Align to Selection Căn theo vùng chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3fe2f9e009..2bcfddac9e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -109,6 +109,7 @@ Align to Artboard 对齐画板 Align to Glyph Bounds 对齐到字形边界 Align to Key Object 对齐关键对象 + Align to Key Anchor 对齐关键锚点 Align to Page 对齐页面 Align to Path 对齐路径 Align to Selection 对齐选区 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab7f9d9dd2..2e0b6c2a3f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -118,6 +118,7 @@ Align to Artboard 對齊工作區域 Align to Glyph Bounds 對齊字符邊界 Align to Key Object 對齊關鍵物件 + Align to Key Anchor 對齊關鍵錨點 Align to Page 對齊頁面 Align to Path 對齊路徑 Align to Selection 對齊選取範圍 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 02c7969be2..e5cb5d07dc 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -297,9 +297,10 @@ fn topmost(app: &VectorcraftApp) -> Option { app.session.active().and_then(|st| st.selection.in_paint_order(&st.doc).last().copied()) } -/// What Align aligns to: the key object or the key guide (#2143) while there is one (a click on -/// an object of the selection makes it the key, and Align to Key Object makes the topmost one the -/// key, or a guide selected with a single object), else the stored Align To choice, Selection or +/// What Align aligns to: the key object, the key guide (#2143) or the key anchor while there is one +/// (a click on an object of the selection makes it the key, and Align to Key Object makes the +/// topmost one the key, or a guide selected with a single object; a Shift-click with Direct +/// Selection makes the point it adds the key anchor), else the stored Align To choice, Selection or /// Artboard. The Distribute Objects and Distribute Spacing buttons send it as their `to`. fn align_to(app: &VectorcraftApp) -> AlignTo { if has_key(app) { AlignTo::Key } else { app.ui.align_to } @@ -308,15 +309,23 @@ fn align_to(app: &VectorcraftApp) -> AlignTo { /// The Align To choices of the Align panel and of the Control bar's Align To menu, in their order. const ALIGN_TO: [AlignTo; 3] = [AlignTo::Selection, AlignTo::Key, AlignTo::Artboard]; -/// The icon and the translated name of Align To choice `v`. -fn align_to_icon_name(v: AlignTo) -> (&'static str, &'static str) { +/// The icon and the translated name of Align To choice `v`; `anchor`: the key is an anchor point +/// (Direct Selection), and the key choice is named Align to Key Anchor. +fn align_to_icon_name(v: AlignTo, anchor: bool) -> (&'static str, &'static str) { match v { AlignTo::Selection => ("dc-alignto-selection", tl!("Align to Selection")), + AlignTo::Key if anchor => ("dc-alignto-key", tl!("Align to Key Anchor")), AlignTo::Key => ("dc-alignto-key", tl!("Align to Key Object")), AlignTo::Artboard => ("dc-alignto-artboard", tl!("Align to Artboard")), } } +/// Whether the key is an anchor point: the last of two or more anchor points that a Shift-click +/// with Direct Selection added ([`vectorcraft_doc::Selection::key_anchor`]). +fn key_is_anchor(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| st.selection.key_anchor.is_some()) +} + /// The Align To choice that the Align panel and the Control bar show chosen: Artboard while one /// object is selected (the buttons then align it to the artboard), else [`align_to`]. With the /// Artboard tool chosen, it is the stored choice, Selection or Artboard. @@ -386,7 +395,8 @@ const ALIGN_TO_ARROW: f32 = 14.0; pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let t = crate::theme::Tokens::get(ui.ctx()); let shown = shown_align_to(app); - let (icon, name) = align_to_icon_name(shown); + let anchor = key_is_anchor(app); + let (icon, name) = align_to_icon_name(shown, anchor); let (rect, resp) = ui.allocate_exact_size(egui::vec2(align_to_menu_width(size), size), egui::Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, egui::CornerRadius::same(5), t.hover); @@ -403,7 +413,7 @@ pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let picked = egui::Popup::menu(&resp).show(|ui| { let mut picked = None; for v in ALIGN_TO { - if menu_item(ui, align_to_icon_name(v).1, can_choose(app, v), shown == v) { + if menu_item(ui, align_to_icon_name(v, anchor).1, can_choose(app, v), shown == v) { picked = Some(v); } } @@ -485,9 +495,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::subheader(ui, tl!("Align To:")); // With one object selected the buttons align it to the artboard, and Align To shows Artboard. let shown = shown_align_to(app); + let anchor = key_is_anchor(app); ui.horizontal(|ui| { for v in ALIGN_TO { - let (icon, tip) = align_to_icon_name(v); + let (icon, tip) = align_to_icon_name(v, anchor); if widgets::icon_button_enabled(ui, icon, tip, shown == v, can_choose(app, v), 28.0).clicked() { choose_align_to(app, v); } diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs index 385fd25fae..3e3d51f8d6 100644 --- a/crates/ui-egui/src/panels/align/tests.rs +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -672,6 +672,32 @@ fn anchor_points_of_one_path_align_to_each_other() { assert_eq!(xs(&app), [0.0, 0.0, 150.0], "to the artboard's left edge: {}", app.ui.status); } +/// #1636: with a key anchor, the Align panel shows the key choice chosen and named Align to Key +/// Anchor, its align button moves the other points to the key anchor, which stays, and Align to +/// Selection lets go of it. +#[test] +fn a_key_anchor_is_chosen_named_and_aligned_to() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); + let xs = |app: &VectorcraftApp| -> Vec { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect() + }; + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 2]], "mode": "toggle"})).unwrap(); + let ctx = egui::Context::default(); + let ([selection, key, _], left) = panel_buttons(&mut app, &ctx); + assert_eq!((align_to(&app), chosen_align_to(&mut app, &ctx)), (AlignTo::Key, vec![key]), "chosen automatically"); + let named = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&named, "Align to Key Anchor"), (Role::Button, Some(Toggled::True))); + click(&ctx, &mut app, left.center(), show); + assert_eq!(xs(&app), [150.0, 200.0, 150.0], "to the key anchor's x: {}", app.ui.status); + click(&ctx, &mut app, selection.center(), show); + assert!(!has_key(&app), "Align to Selection lets go of the key anchor"); + assert_eq!(app.ui.align_to, AlignTo::Selection); +} + /// #1636: the Distribute buttons work on the anchor points of one path (one object selected). #[test] fn distribute_buttons_space_the_anchor_points_of_one_path() { diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..ce22248575 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2706,6 +2706,15 @@ A click with the Selection tool on a guide selected with objects runs `select.ke 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`. + +`select.key {id, anchor: [subpath, anchor]}` makes an anchor point selected with Direct Selection the key anchor, while at least one other point (or a path selected whole) is selected too. +`select.anchors` with mode `add` or `toggle` and one point makes that point the key anchor when it selects it; a Shift-click on an anchor with Direct Selection runs it, and a plain click still selects that point alone. +Aligning to the key, `object.align` moves the other selected points to the key anchor's x or y and the key anchor stays put; a pixel-aligned path is then snapped to the pixel grid, as after any other edit. +`object.distribute {spacing}` and `object.distributeSpacing {spacing}` keep the key anchor in place and put the other points `spacing` apart, outward from it, in their order along the axis. +The key anchor goes when it is deselected or deleted, when it is the only point left selected, when `select.key {}` cancels it, and when a key object or key guide replaces it. +An anchor that is not a selected anchor point (a selected segment's end is not one) is an error, and the key stays as it was. +The result has `keyAnchor: {id, anchor}` with a key anchor, and `document.inspect` reports it as `keyAnchor`. +The canvas draws the key anchor as a larger filled square in a frame, and the Align panel and the Control bar then name the Align To choice Align to Key Anchor. 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 082e565bd2..9a0559230d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -203,7 +203,7 @@ tools, never checked side by side) isn't captured row by row. | Deleting segments or anchors that disconnect an ordinary path creates independent selected objects | done | #3380, deletion only: first piece retains its ID, styles and transforms carry over in sibling order, bounds-fitted gradients keep their original placement, and one Undo restores the original. Existing multi-subpath paths and compound members stay intact. `delete_path_pieces` covers transformed/styled rectangles, cubic handles and Undo/Redo. Endpoint highlighting remains open. | | 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 | +| Align selected anchors to a key anchor | done | #1636; a Shift-click with Direct Selection makes the point it adds the key anchor, drawn as a larger filled square in a frame, and Align To shows Align to Key Anchor; Align moves the other points to it and Distribute with a spacing places them from it; `select.key {id, anchor}` | | 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 |