Blend spine: Shift-click picks several spine points, which drag together (#5285)

Direct Selection kept only one picked point of a blend's spine. Shift-click
now adds a point of the same spine or takes it away, and dragging any picked
point moves them all. object.blend.spine.moveAnchor takes `with`, the other
points to move by as much, with the key objects on them, in one undo step.
This commit is contained in:
Don Omondi
2026-11-06 07:23:19 +07:00
committed by bflatastic
parent a402a48a3a
commit dfe628e906
31 changed files with 187 additions and 9 deletions
+24 -3
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeSet;
use super::common::{
DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of,
spacing_param, starts_param, wrap,
@@ -180,6 +182,17 @@ fn finite_point(p: &Value, cmd: &str) -> Result<Point> {
if q.x.is_finite() && q.y.is_finite() && q.x.abs() < 1e9 && q.y.abs() < 1e9 { Ok(q) } else { Err(bad(cmd, "x and y must be finite")) }
}
/// Most spine points `with` names.
const MAX_SPINE_POINTS: usize = 10_000;
/// The optional `with` list of spine point indexes (empty when absent).
fn spine_points_param(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let Some(v) = p.get("with").filter(|v| !v.is_null()) else { return Ok(vec![]) };
let wrong = || bad(cmd, "`with` must be a list of at most 10000 spine point indexes");
let list = v.as_array().filter(|l| l.len() <= MAX_SPINE_POINTS).ok_or_else(wrong)?;
list.iter().map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(wrong)).collect()
}
pub(super) fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.moveAnchor";
let index = index_param(p, "anchor", C)?;
@@ -191,6 +204,10 @@ pub(super) fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) => return Err(bad(C, "handle must be in or out")),
};
let independent = switch_or(p, "independent", false, C)?;
let with = spine_points_param(p, C)?;
if handle.is_some() && !with.is_empty() {
return Err(bad(C, "`with` moves points, not a handle"));
}
edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| {
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
if let Some(out) = handle {
@@ -198,9 +215,13 @@ pub(super) fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(());
}
let delta = q - a.p;
a.translate(delta);
// The key objects on the point move with it.
for (k, _) in anchors.iter().enumerate().filter(|(_, a)| **a == index) {
// The other points selected with it (#5285) move as far.
let moved: BTreeSet<usize> = with.iter().copied().chain([index]).collect();
for &i in &moved {
sp.anchors.get_mut(i).ok_or_else(|| bad(C, "no such spine point"))?.translate(delta);
}
// The key objects on the points move with them.
for (k, _) in anchors.iter().enumerate().filter(|(_, a)| moved.contains(*a)) {
if let Some(key) = keys.get_mut(k) {
Arc::make_mut(key).transform(Affine::translate(delta), false);
}
+1 -1
View File
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Spine Point",
[],
None,
"{id? (default: the selected blend), anchor: index, x, y, handle?: in|out, independent?: bool} move a point of the blend's spine (its handles along), or with `handle` place that handle's end at (x, y) (a smooth point keeps the other handle in line unless `independent`). A key object sitting on the point moves with it. The first edit turns the straight spine into a path",
"{id? (default: the selected blend), anchor: index, x, y, with?: [index…], handle?: in|out, independent?: bool} move a point of the blend's spine (its handles along), and the points `with` names by as much, or with `handle` place that handle's end at (x, y) (a smooth point keeps the other handle in line unless `independent`). A key object sitting on a moved point moves with it. The first edit turns the straight spine into a path",
has_doc,
spine_move_anchor
),
+28
View File
@@ -66,6 +66,34 @@ fn a_spine_point_added_and_moved_bends_the_blend() {
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6);
}
/// #5285: `with` moves other spine points by as much as the one dragged, and the key objects on
/// them along, in one undo step; it can't go with a handle or name a point the spine lacks.
#[test]
fn spine_points_move_together() {
let mut s = session();
let (a, b, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"x": 55, "y": 5})).unwrap();
let before = spec(&s, g);
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": [9]})).is_err());
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": [2], "handle": "in"})).is_err());
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": "2"})).is_err());
assert_eq!(spec(&s, g), before, "refused requests change nothing");
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": [2, 1]})).unwrap();
let sp = spec(&s, g).spine.unwrap();
let pts: Vec<Point> = sp.subpaths[0].anchors.iter().map(|x| x.p).collect();
let want = [Point::new(5.0, 5.0), Point::new(55.0, 45.0), Point::new(105.0, 45.0)];
assert!(pts.len() == 3 && pts.iter().zip(want).all(|(p, w)| p.distance(w) < 1e-9), "{pts:?}");
let keys = match node(&s, g).kind {
NodeKind::Blend { children, .. } => children,
_ => panic!("not a blend"),
};
let at = |id: NodeId| keys.iter().find(|k| k.id == id).map(|k| center(k)).unwrap();
assert!(at(b).distance(Point::new(105.0, 45.0)) < 1e-9, "the key on the point went along: {:?}", at(b));
assert!(at(a).distance(Point::new(5.0, 5.0)) < 1e-9, "the other stayed: {:?}", at(a));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, g), before, "one undo step");
}
/// #2544: an Anchor Point drag on a blend spine must create two symmetric
/// handles, not bend the adjacent segment and leave one handle behind.
#[test]
+38 -4
View File
@@ -25,8 +25,9 @@
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
//! A Cmd/Ctrl+Shift double-click with either one releases the ruler guide or guide path under the
//! pointer ([`release_guide_at`]).
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
//! Direct Selection also edits a blend's spine: drag its points (Shift-click picks several, which
//! drag together; a key object on a point moves with it) and, once a point is clicked, its
//! handles; and the points of selected gradient meshes
//! and mesh envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several,
//! a click on a gradient mesh's line picks its two ends and one inside a patch the four points
//! around it, a drag of picked points moves them all, the arrow keys nudge them, and a solid fill
@@ -182,6 +183,9 @@ pub struct DirectSelectionTool {
state: State,
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
spine: Option<(NodeId, usize)>,
/// Other points of that spine picked with it (Shift-click adds or takes one away); dragging
/// a picked point moves them all (#5285).
spine_more: Vec<usize>,
mesh: MeshEdit,
/// Smart guides of the handle, anchors or objects being dragged.
guides: Vec<Overlay>,
@@ -237,6 +241,7 @@ impl DirectSelectionTool {
group,
state: State::Idle,
spine: None,
spine_more: vec![],
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
@@ -773,7 +778,8 @@ impl Tool for DirectSelectionTool {
self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out };
return vec![Action::Begin("Reshape Spine".into())];
}
self.spine = None;
let held = self.spine.take().filter(|(id, _)| cx.selection.contains(*id));
let more = std::mem::take(&mut self.spine_more);
self.snapped = false;
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
self.state = State::Mesh;
@@ -789,7 +795,24 @@ impl Tool for DirectSelectionTool {
return vec![Action::Begin("Reshape".into())];
}
if let Some((id, anchor, from)) = hit_spine_point(cx, p, point) {
// The points of this spine picked before: Shift-click adds the point or takes
// it away; a plain click on one of them keeps them all, to drag together.
let mut picked: Vec<usize> =
held.filter(|(h, _)| *h == id).map(|(_, a)| more.into_iter().chain([a]).collect()).unwrap_or_default();
if ev.mods.shift
&& let Some(k) = picked.iter().position(|a| *a == anchor)
{
picked.remove(k);
self.spine = picked.pop().map(|a| (id, a));
self.spine_more = picked;
return vec![];
}
if !ev.mods.shift && !picked.contains(&anchor) {
picked.clear();
}
picked.retain(|a| *a != anchor);
self.spine = Some((id, anchor));
self.spine_more = picked;
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
@@ -873,7 +896,12 @@ impl Tool for DirectSelectionTool {
self.state = State::SpinePoint { id, anchor, from, start, began: true };
}
let q = from + move_delta(start, p, ev.mods.shift, cx.constrain_angle);
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
let mut params = json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y});
let with: Vec<usize> = self.spine_more.iter().copied().filter(|a| spine_anchor(cx, (id, *a)).is_some()).collect();
if !with.is_empty() {
params["with"] = json!(with);
}
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), params));
out
}
(PointerKind::Drag, State::SpineSegment { id, seg, t, start, began }) => {
@@ -1139,6 +1167,12 @@ impl DirectSelectionTool {
out.push(Overlay::Handle { p: h, color });
}
out.push(Overlay::Anchor { p: a.p, color, filled: true, size: 5.0 });
out.extend(self.spine_more.iter().filter_map(|i| spine_anchor(cx, (sel.0, *i))).map(|m| Overlay::Anchor {
p: m.p,
color,
filled: true,
size: 5.0,
}));
out
}
}
+39
View File
@@ -182,6 +182,45 @@ fn dragging_a_spine_point_moves_it() {
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
}
/// #5285: Shift-click picks several points of a blend's spine, as of a path; dragging one of them
/// moves them all (`with`), and Shift-click on a picked point takes it away again.
#[test]
fn shift_click_picks_several_spine_points_which_drag_together() {
let (mut d, g, _) = blend_doc();
let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(160.0, 310.0), Point::new(210.0, 310.0)], false);
let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 2]);
let mut s = Selection::default();
s.set([g]);
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Mods::default() };
let filled = |t: &DirectSelectionTool| t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Anchor { filled: true, .. })).count();
let mut t = DirectSelectionTool::new(false);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 310.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.0, 310.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 210.0, 310.0).with_mods(shift));
assert_eq!(filled(&t), 2, "both picked points show");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 210.0, 330.0));
assert_eq!(
a.last(),
Some(&Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 2, "x": 210.0, "y": 330.0, "with": [1]})))
);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 210.0, 330.0)), vec![Action::Commit]);
// A plain press on a picked point keeps both.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 310.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 300.0));
assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["anchor"] == 1 && v["with"] == json!([2])), "{a:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.0, 300.0));
// Shift-click takes one away; a plain click on another point picks it alone.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 210.0, 310.0).with_mods(shift));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 210.0, 310.0).with_mods(shift));
assert_eq!(filled(&t), 1);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 330.0));
assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["anchor"] == 0 && v.get("with").is_none()), "{a:?}");
}
/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection
/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's.
#[test]
+2
View File
@@ -4196,6 +4196,8 @@ axis Both И двете
@msg no such saved selection няма такава запазена селекция
@msg no such segment няма такъв сегмент
@msg no such spine point няма такава точка от оста
@msg `with` must be a list of at most 10000 spine point indexes `with` трябва да е списък с най-много 10000 индекса на точки от оста
@msg `with` moves points, not a handle `with` мести точки, не дръжка
@msg no such spine segment няма такъв сегмент от оста
@msg no such subpath няма такъв подпът
@msg no such user library няма такава потребителска библиотека
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4591,6 +4591,8 @@ imageTrace Outline Obris
@msg no such saved selection nema takvog sačuvanog odabira
@msg no such segment nema takvog segmenta
@msg no such spine point nema takve tačke kičme
@msg `with` must be a list of at most 10000 spine point indexes `with` mora biti lista od najviše 10000 indeksa tačaka kičme
@msg `with` moves points, not a handle `with` pomjera tačke, ne ručicu
@msg no such spine segment nema takvog segmenta kičme
@msg no such subpath nema takve podputanje
@msg no such user library nema takve korisničke biblioteke
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5328,6 +5328,8 @@ digits Malayalam Malaiàlam
@msg no such saved selection no existeix aquesta selecció desada
@msg no such segment no existeix aquest segment
@msg no such spine point no existeix aquest punt de l’espina
@msg `with` must be a list of at most 10000 spine point indexes `with` ha de ser una llista d’un màxim de 10000 índexs de punts de l’espina
@msg `with` moves points, not a handle `with` mou punts, no una nansa
@msg no such spine segment no existeix aquest segment de l’espina
@msg no such subpath no existeix aquest subtraçat
@msg no such user library no existeix aquesta biblioteca d’usuari
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4639,6 +4639,8 @@ imageTrace Outline Obrys
@msg no such saved selection takový uložený výběr neexistuje
@msg no such segment takový segment neexistuje
@msg no such spine point takový bod páteře neexistuje
@msg `with` must be a list of at most 10000 spine point indexes `with` musí být seznam nejvýše 10000 indexů bodů páteře
@msg `with` moves points, not a handle `with` posouvá body, ne táhlo
@msg no such spine segment takový segment páteře neexistuje
@msg no such subpath taková dílčí cesta neexistuje
@msg no such user library taková uživatelská knihovna neexistuje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4248,6 +4248,8 @@ theme Appearance Erscheinungsbild
@msg no such saved selection keine solche gespeicherte Auswahl
@msg no such segment kein solches Segment
@msg no such spine point kein solcher Achsenpunkt
@msg `with` must be a list of at most 10000 spine point indexes `with` muss eine Liste von höchstens 10000 Achsenpunkt-Indizes sein
@msg `with` moves points, not a handle `with` verschiebt Punkte, keinen Griff
@msg no such spine segment kein solches Achsensegment
@msg no such subpath kein solcher Teilpfad
@msg no such user library keine solche Benutzerbibliothek
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4287,6 +4287,8 @@ digits Malayalam Μαλαγιάλαμ
@msg no such saved selection δεν υπάρχει τέτοια αποθηκευμένη επιλογή
@msg no such segment δεν υπάρχει τέτοιο τμήμα
@msg no such spine point δεν υπάρχει τέτοιο σημείο κεντρικής καμπύλης
@msg `with` must be a list of at most 10000 spine point indexes το `with` πρέπει να είναι λίστα με έως 10000 δείκτες σημείων κεντρικής καμπύλης
@msg `with` moves points, not a handle το `with` μετακινεί σημεία, όχι λαβή
@msg no such spine segment δεν υπάρχει τέτοιο τμήμα κεντρικής καμπύλης
@msg no such subpath δεν υπάρχει τέτοια υποδιαδρομή
@msg no such user library δεν υπάρχει τέτοια βιβλιοθήκη χρήστη
Can't render this file because it contains an unexpected character in line 163 and column 5.
+2
View File
@@ -4332,6 +4332,8 @@ axis Both Ambos
@msg no such saved selection no existe esa selección guardada
@msg no such segment no existe ese segmento
@msg no such spine point no existe ese punto del eje
@msg `with` must be a list of at most 10000 spine point indexes `with` debe ser una lista de como máximo 10000 índices de puntos del eje
@msg `with` moves points, not a handle `with` mueve puntos, no un manejador
@msg no such spine segment no existe ese segmento del eje
@msg no such subpath no existe ese subtrazado
@msg no such user library no existe esa biblioteca de usuario
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4547,6 +4547,8 @@ imageTrace Outline Ääriviiva
@msg no such saved selection tallennettua valintaa ei ole
@msg no such segment segmenttiä ei ole
@msg no such spine point selkärangan pistettä ei ole
@msg `with` must be a list of at most 10000 spine point indexes `with`-arvon on oltava luettelo, jossa on enintään 10000 selkärangan pisteen indeksiä
@msg `with` moves points, not a handle `with` siirtää pisteitä, ei kahvaa
@msg no such spine segment selkärangan segmenttiä ei ole
@msg no such subpath alipolkua ei ole
@msg no such user library käyttäjäkirjastoa ei ole
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4322,6 +4322,8 @@ axis Both Les deux
@msg no such saved selection sélection enregistrée inexistante
@msg no such segment segment inexistant
@msg no such spine point point de la dorsale inexistant
@msg `with` must be a list of at most 10000 spine point indexes `with` doit être une liste d’au plus 10000 indices de points de la dorsale
@msg `with` moves points, not a handle `with` déplace des points, pas une poignée
@msg no such spine segment segment de la dorsale inexistant
@msg no such subpath sous-tracé inexistant
@msg no such user library bibliothèque utilisateur inexistante
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5009,6 +5009,8 @@ imageTrace Outline Körvonal
@msg no such saved selection nincs ilyen mentett kijelölés
@msg no such segment nincs ilyen szakasz
@msg no such spine point nincs ilyen gerincpont
@msg `with` must be a list of at most 10000 spine point indexes `with`: legfeljebb 10000 gerincpont-indexből álló lista kell
@msg `with` moves points, not a handle `with` pontokat mozgat, nem fogantyút
@msg no such spine segment nincs ilyen gerincszakasz
@msg no such subpath nincs ilyen részútvonal
@msg no such user library nincs ilyen felhasználói könyvtár
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4374,6 +4374,8 @@ axis Both Entrambi
@msg no such saved selection selezione salvata inesistente
@msg no such segment segmento inesistente
@msg no such spine point punto della dorsale inesistente
@msg `with` must be a list of at most 10000 spine point indexes `with` deve essere un elenco di al massimo 10000 indici di punti della dorsale
@msg `with` moves points, not a handle `with` sposta punti, non una maniglia
@msg no such spine segment segmento della dorsale inesistente
@msg no such subpath sottotracciato inesistente
@msg no such user library libreria utente inesistente
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4153,6 +4153,8 @@ swatch Bright Violet ビビッドバイオレット
@msg no such anchor 指定されたアンカーポイントが見つかりません
@msg no such segment 指定されたセグメントが見つかりません
@msg no such spine point 指定されたスパインポイントが見つかりません
@msg `with` must be a list of at most 10000 spine point indexes `with` には最大 10000 個のスパインポイントのインデックスのリストを指定してください
@msg `with` moves points, not a handle `with` はポイントを移動します。ハンドルには使えません
@msg no such spine segment 指定されたスパインセグメントが見つかりません
@msg no such subpath 指定されたサブパスが見つかりません
@msg object {id} is not a path オブジェクト {id} はパスではありません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5529,6 +5529,8 @@ imageTrace Outline კონტური
@msg missing number `{key}` აკლია რიცხვი `{key}`
@msg missing integer `{key}` აკლია მთელი რიცხვი `{key}`
@msg no such spine point ხერხემლის ასეთი წერტილი არ არსებობს
@msg `with` must be a list of at most 10000 spine point indexes `with` უნდა იყოს ხერხემლის წერტილების არაუმეტეს 10000 ინდექსის სია
@msg `with` moves points, not a handle `with` წერტილებს გადაადგილებს და არა სახელურს
@msg no such spine segment ხერხემლის ასეთი სეგმენტი არ არსებობს
@msg not a blend ეს გადასვლა არ არის
@msg only http, https and mailto URLs open in the browser, not “{url}” ბრაუზერში მხოლოდ http, https და mailto URL-ები იხსნება და არა „{url}“
Can't render this file because it is too large.
+2
View File
@@ -4080,6 +4080,8 @@ theme Appearance 외관
@msg no such anchor 해당 앵커가 없습니다
@msg no such segment 해당 세그먼트가 없습니다
@msg no such spine point 해당 스파인 포인트가 없습니다
@msg `with` must be a list of at most 10000 spine point indexes `with`는 스파인 포인트 인덱스 최대 10000개의 목록이어야 합니다
@msg `with` moves points, not a handle `with`는 포인트를 이동하며 핸들에는 쓸 수 없습니다
@msg no such spine segment 해당 스파인 세그먼트가 없습니다
@msg no such subpath 해당 하위 패스가 없습니다
@msg object {id} is not a path 개체 {id}은(는) 패스가 아닙니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5585,6 +5585,8 @@ swatch Bright Violet Felviolet
@msg missing number `{key}` getal `{key}` ontbreekt
@msg missing integer `{key}` geheel getal `{key}` ontbreekt
@msg no such spine point dat aspunt bestaat niet
@msg `with` must be a list of at most 10000 spine point indexes `with` moet een lijst van hoogstens 10000 aspuntindexen zijn
@msg `with` moves points, not a handle `with` verplaatst punten, geen handgreep
@msg no such spine segment dat assegment bestaat niet
@msg not a blend geen vermenging
@msg select a blend or give `id` selecteer een vermenging of geef `id` op
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4462,6 +4462,8 @@ imageTrace Outline Kontur
@msg no such saved selection brak takiego zapisanego zaznaczenia
@msg no such segment brak takiego segmentu
@msg no such spine point brak takiego punktu grzbietu
@msg `with` must be a list of at most 10000 spine point indexes `with` musi być listą co najwyżej 10000 indeksów punktów grzbietu
@msg `with` moves points, not a handle `with` przesuwa punkty, nie uchwyt
@msg no such spine segment brak takiego segmentu grzbietu
@msg no such subpath brak takiej ścieżki podrzędnej
@msg no such user library brak takiej biblioteki użytkownika
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3912,6 +3912,8 @@ axis Both Ambos
@msg no such saved selection seleção salva inexistente
@msg no such segment segmento inexistente
@msg no such spine point ponto da coluna inexistente
@msg `with` must be a list of at most 10000 spine point indexes `with` deve ser uma lista de no máximo 10000 índices de pontos da coluna
@msg `with` moves points, not a handle `with` move pontos, não uma alça
@msg no such spine segment segmento da coluna inexistente
@msg no such subpath subtraçado inexistente
@msg no such user library biblioteca do usuário inexistente
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4369,6 +4369,8 @@ axis Both Оба
@msg no such saved selection такого сохранённого выделения нет
@msg no such segment такого сегмента нет
@msg no such spine point такой точки остова нет
@msg `with` must be a list of at most 10000 spine point indexes `with` должен быть списком не более чем из 10000 индексов точек остова
@msg `with` moves points, not a handle `with` перемещает точки, а не маркер
@msg no such spine segment такого сегмента остова нет
@msg no such subpath такого вложенного контура нет
@msg no such user library такой пользовательской библиотеки нет
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4237,6 +4237,8 @@ axis Both Båda
@msg no such saved selection den sparade markeringen finns inte
@msg no such segment segmentet finns inte
@msg no such spine point ryggradspunkten finns inte
@msg `with` must be a list of at most 10000 spine point indexes `with` måste vara en lista med högst 10000 ryggradspunktsindex
@msg `with` moves points, not a handle `with` flyttar punkter, inte ett handtag
@msg no such spine segment ryggradssegmentet finns inte
@msg no such subpath delbanan finns inte
@msg no such user library användarbiblioteket finns inte
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6
View File
@@ -1823,6 +1823,12 @@ fn blend_spine_param_errors_say_what_is_wrong() {
("object.blend.spine.moveAnchor", json!({"anchor": 0, "x": 1e12, "y": 0}), "x and y must be finite"),
("object.blend.spine.moveAnchor", json!({"anchor": 0, "x": 0, "y": 0, "handle": "up"}), "handle must be in or out"),
("object.blend.spine.moveAnchor", json!({"anchor": 5, "x": 0, "y": 0}), "no such spine point"),
(
"object.blend.spine.moveAnchor",
json!({"anchor": 0, "x": 0, "y": 0, "with": "1"}),
"`with` must be a list of at most 10000 spine point indexes",
),
("object.blend.spine.moveAnchor", json!({"anchor": 0, "x": 0, "y": 0, "with": [1], "handle": "in"}), "`with` moves points, not a handle"),
("object.blend.spine.moveAnchor", json!({"id": other, "anchor": 0, "x": 0, "y": 0}), "not a blend"),
("object.blend.spine.reshapeSegment", json!({"segment": 0, "dx": 1e12}), "t, dx and dy must be finite"),
("object.blend.spine.reshapeSegment", json!({"segment": 5, "dy": 10}), "no such spine segment"),
+2
View File
@@ -5432,6 +5432,8 @@ theme Appearance Görünüm
@msg mask art ends outside a mask dictionary maske çizimi bir maske sözlüğünün dışında bitiyor
@msg missing number `{key}` `{key}` sayısı eksik
@msg no such spine point böyle bir omurga noktası yok
@msg `with` must be a list of at most 10000 spine point indexes `with` en fazla 10000 omurga noktası dizininden oluşan bir liste olmalıdır
@msg `with` moves points, not a handle `with` noktaları taşır, tutamacı değil
@msg no such spine segment böyle bir omurga segmenti yok
@msg not a blend bir karışım değil
@msg select a blend or give `id` bir karışım seçin veya `id` belirtin
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -4358,6 +4358,8 @@ axis Both Обидві
@msg no such saved selection такого збереженого виділення немає
@msg no such segment такого сегменту немає
@msg no such spine point такої точки осі немає
@msg `with` must be a list of at most 10000 spine point indexes `with` має бути списком щонайбільше з 10000 індексів точок осі
@msg `with` moves points, not a handle `with` переміщує точки, а не маркер
@msg no such spine segment такого сегмента осі немає
@msg no such subpath такого вкладеного контуру немає
@msg no such user library такої користувацької бібліотеки немає
Can't render this file because it contains an unexpected character in line 177 and column 5.
+2
View File
@@ -4661,6 +4661,8 @@ theme Appearance Giao diện
@msg no such saved selection không có vùng chọn đã lưu như vậy
@msg no such segment không có đoạn như vậy
@msg no such spine point không có điểm trục như vậy
@msg `with` must be a list of at most 10000 spine point indexes `with` phải là danh sách tối đa 10000 chỉ số điểm trục
@msg `with` moves points, not a handle `with` di chuyển các điểm, không phải tay nắm
@msg no such spine segment không có đoạn trục như vậy
@msg no such subpath không có nhánh con như vậy
@msg no such user library không có thư viện người dùng như vậy
Can't render this file because it contains an unexpected character in line 199 and column 5.
+2
View File
@@ -1883,6 +1883,8 @@ file-filter Templates 模板
@msg the blend has no spine 混合对象没有脊线
@msg the spine has no segments 脊线没有线段
@msg no such spine point 没有此脊线点
@msg `with` must be a list of at most 10000 spine point indexes `with` 必须是最多 10000 个脊线点索引的列表
@msg `with` moves points, not a handle `with` 移动的是点,不是手柄
@msg no such spine segment 没有此脊线线段
@msg a key object sits on that point 该点上有关键对象
@msg a spine keeps at least two points 脊线至少保留两个点
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2856,6 +2856,8 @@ file-filter Templates 範本
@msg the blend has no spine 漸變物件沒有脊線
@msg the spine has no segments 脊線沒有線段
@msg no such spine point 沒有這個脊線點
@msg `with` must be a list of at most 10000 spine point indexes `with` 必須是最多 10000 個脊線點索引的清單
@msg `with` moves points, not a handle `with` 移動的是點,不是控制把手
@msg no such spine segment 沒有這個脊線線段
@msg a key object sits on that point 該點上有關鍵物件
@msg a spine keeps at least two points 脊線至少要保留兩個點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -38,6 +38,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged |
| 2026-11-05 | minor | Direct Selection: Cmd/Ctrl held lends the Selection tool, whose bounding box shows while the key is down over objects selected whole; its handles scale and rotate, and a Cmd drag moves the whole object with its direct-selected anchors kept (#3901); scores and estimates unchanged |
| 2026-11-05 | minor | Join tool: scrubbing over the ends of two crossing open paths that run past the crossing trims those ends and joins the paths there with a corner, instead of drawing a segment between the ends (#5844); scores and estimates unchanged |
| 2026-11-05 | minor | Effect dialogs: a swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged |
@@ -135,6 +136,7 @@ tools, never checked side by side) isn't captured row by row.
| Layers panel lists a blend's spine (#4820) | done | an open blend shows a `<Path>` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) |
| Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` |
| Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 |
| Several blend spine points picked and dragged together (#5285) | done | with Direct Selection, Shift-click picks more points of a selected blend's spine or takes one away, as on a path; dragging any picked point moves them all, and the key objects on them, in one undo step (`object.blend.spine.moveAnchor {with}`); a marquee doesn't pick spine points yet |
| Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | #2267: a click on a handle end retracts that handle and leaves the anchor a corner with the other one; Shift held at the press on an anchor moves it, handles and all (Shift kept down: at 45° steps) instead of pulling out handles |
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Fidelity goes down to 0.01 pt in the dialog, the tool and `path.freehand` (the Smooth tool keeps 0.05); its slider runs across the Tool Options row on a logarithmic rail from 0.01 to 20 pt, the field shows hundredths, and the slider's and the field's arrow keys step hundredths up to 0.1 pt, tenths up to 1 pt and whole points above (Shift ×10) (#3328); Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path |
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path; a Pencil stroke from an end of one selected open path to an end of another connects the two into one path |