Anchor Point tool: Shift-drag on an anchor moves it, handles and all

With the Anchor Point tool, a drag from an anchor pulls out smooth handles.
Shift held at the press now moves the anchor instead, its handles keeping
their offsets; Shift kept down during the drag keeps the move at 45° steps
(the constrain angle), released it frees it. A Shift-click that doesn't
move leaves the anchor as it was. A selected blend's spine points follow
the same rule (object.blend.spine.moveAnchor).

The move goes through a new engine command, path.moveAnchor
{id, subpath?, anchor, x, y}, since path.moveAnchors only acts on the
direct-selected anchors and the Anchor Point tool keeps no anchor selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sungjea kim
2026-10-19 22:19:53 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent c07945b934
commit 515569470c
7 changed files with 166 additions and 7 deletions
+25
View File
@@ -42,6 +42,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_handle
),
cmd!(
"path.moveAnchor",
"Move Anchor Point",
[],
None,
"{id, subpath?, anchor, x, y} move one anchor to (x, y), its handles with it",
has_doc,
move_anchor
),
cmd!(
"path.setAnchors",
"Set Anchors",
@@ -230,6 +239,22 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
/// tool's Shift-drag).
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))? as usize;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
s.edit("Move", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
*a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p));
Ok(())
})?;
ok()
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
+18
View File
@@ -983,6 +983,24 @@ fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
}
#[test]
fn move_anchor_takes_its_handles_along_and_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 0, "x": 140, "y": 250});
refuses_an_index_that_is_not_whole(&mut s, "path.moveAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.moveAnchor", &p, id);
// The anchor lands on (x, y) and its handles keep their offsets from it.
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "which": "out", "x": 30, "y": 40})).unwrap();
let a = path(&s, id).subpaths[0].anchors[0];
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 0, "x": a.p.x + 15.0, "y": a.p.y - 5.0})).unwrap();
let b = path(&s, id).subpaths[0].anchors[0];
assert_eq!(b.p, a.p + vectorcraft_geom::Vec2::new(15.0, -5.0));
assert_eq!(b.h_out - b.p, a.h_out - a.p);
assert_eq!(b.h_in - b.p, a.h_in - a.p);
assert!(s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 99, "x": 0, "y": 0})).is_err());
}
#[test]
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
+2
View File
@@ -219,6 +219,8 @@ fn structured_junk() {
("path.insertAnchor", json!({"id": 2, "subpath": 0, "segment": 99, "t": 5})),
("path.insertAnchor", json!({"id": 2, "subpath": 7, "segment": 0, "t": -1})),
("path.setHandle", json!({"id": 2, "subpath": 0, "anchor": 100, "which": "in", "x": 0, "y": 0})),
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 100, "x": 0, "y": 0})),
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 0, "x": 1e308, "y": -1e308})),
("path.removeAnchor", json!({"id": 2, "subpath": 0, "anchor": 0})),
("path.split", json!({"id": 2, "subpath": 0, "segment": 1000, "t": 0.5})),
("path.reshapeSegment", json!({"id": 2, "subpath": 0, "segment": 0, "t": 2.0, "dx": 1e308, "dy": 0})),
+117 -3
View File
@@ -6,7 +6,9 @@
//! handle end → retract that handle only (#2267); drag a handle → move it independently (Shift: at
//! 45° steps round its anchor); drag a segment → reshape the curve, a selected blend's spine's too,
//! straight or curved (#1111; Shift: its handles at right angles to the line between its anchors,
//! at one length). Blend spine points and handles follow the same conversions (#2544).
//! at one length). Shift held at the press on an anchor moves it instead, handles and all (Shift
//! still held: at 45° steps from where it was). Blend spine points and handles follow the same
//! conversions (#2544).
//! Scissors: click a path to split it there.
use serde_json::json;
@@ -37,6 +39,23 @@ enum State {
start: Point,
began: bool,
},
/// Shift-dragging an anchor (`from`, where it was): it moves, handles and all.
Move {
id: NodeId,
si: usize,
ai: usize,
from: Point,
start: Point,
began: bool,
},
/// Shift-dragging a selected blend's spine point: it moves, handles and all.
SpineMove {
id: NodeId,
ai: usize,
from: Point,
start: Point,
began: bool,
},
/// Dragging a selected blend's spine point into a symmetric smooth point.
SpineConvert {
id: NodeId,
@@ -124,6 +143,7 @@ impl AnchorTool {
};
let rank = |state| match state {
State::Idle => None,
State::Move { id, .. } | State::SpineMove { id, .. } => Some((cx.selection.contains(id), cx.doc.index_path(id))),
State::Convert { id, .. }
| State::Handle { id, .. }
| State::SpineConvert { id, .. }
@@ -170,6 +190,35 @@ impl AnchorTool {
out
}
/// The press on an anchor (or a selected blend's spine point) with Shift held moves it
/// instead of converting it. Any other press is kept.
fn moving(cx: &ToolContext, st: State) -> State {
match st {
State::Convert { id, si, ai, start, .. } => {
match cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai).map(|a| a.p)) {
Some(from) => State::Move { id, si, ai, from, start, began: false },
None => st,
}
}
State::SpineConvert { id, ai, start, .. } => {
match crate::direct::spines(cx)
.into_iter()
.find(|(blend, _)| *blend == id)
.and_then(|(_, path)| path.subpaths.first()?.anchors.get(ai).map(|a| a.p))
{
Some(from) => State::SpineMove { id, ai, from, start, began: false },
None => st,
}
}
_ => st,
}
}
/// How far a Shift-dragged anchor has gone: Shift still held keeps it at 45° steps.
fn move_delta(cx: &ToolContext, start: Point, p: Point, shift: bool) -> vectorcraft_geom::Vec2 {
if shift { constrain_angle_from(p - start, 45.0, cx.constrain_angle) } else { p - start }
}
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
hit_segment(cx, p, cx.pick_tol()).map(insert_anchor).into_iter().collect()
}
@@ -216,6 +265,9 @@ impl Tool for AnchorTool {
}
("anchorPoint", PointerKind::Down) => {
self.state = Self::press_target(cx, p);
if ev.mods.shift {
self.state = Self::moving(cx, self.state);
}
if matches!(self.state, State::Handle { .. }) {
self.snap = HandleSnap::default();
}
@@ -226,6 +278,8 @@ impl Tool for AnchorTool {
let (began, far) = match self.state {
State::Idle => return out,
State::Convert { began, start, .. }
| State::Move { began, start, .. }
| State::SpineMove { began, start, .. }
| State::Handle { began, start, .. }
| State::SpineConvert { began, start, .. }
| State::SpineHandle { began, start, .. }
@@ -236,10 +290,16 @@ impl Tool for AnchorTool {
return out;
}
out.push(Action::Begin(
if matches!(self.state, State::Convert { .. } | State::SpineConvert { .. }) { "Convert Anchor Point" } else { "Reshape" }
.into(),
match self.state {
State::Convert { .. } | State::SpineConvert { .. } => "Convert Anchor Point",
State::Move { .. } | State::SpineMove { .. } => "Move",
_ => "Reshape",
}
.into(),
));
if let State::Convert { began, .. }
| State::Move { began, .. }
| State::SpineMove { began, .. }
| State::Handle { began, .. }
| State::SpineConvert { began, .. }
| State::SpineHandle { began, .. }
@@ -254,6 +314,14 @@ impl Tool for AnchorTool {
"path.convertAnchor".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
),
State::Move { id, si, ai, from, start, .. } => {
let q = from + Self::move_delta(cx, start, p, ev.mods.shift);
Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "x": q.x, "y": q.y}))
}
State::SpineMove { id, ai, from, start, .. } => {
let q = from + Self::move_delta(cx, start, p, ev.mods.shift);
Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": ai, "x": q.x, "y": q.y}))
}
State::SpineConvert { id, ai, .. } => Action::Preview(
"object.blend.spine.convertAnchor".into(),
json!({"id": id.0, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
@@ -304,6 +372,8 @@ impl Tool for AnchorTool {
let st = std::mem::replace(&mut self.state, State::Idle);
match st {
State::Convert { began: true, .. }
| State::Move { began: true, .. }
| State::SpineMove { began: true, .. }
| State::Handle { began: true, .. }
| State::SpineConvert { began: true, .. }
| State::SpineHandle { began: true, .. }
@@ -367,6 +437,8 @@ impl Tool for AnchorTool {
let st = std::mem::replace(&mut self.state, State::Idle);
match st {
State::Convert { began: true, .. }
| State::Move { began: true, .. }
| State::SpineMove { began: true, .. }
| State::Handle { began: true, .. }
| State::SpineConvert { began: true, .. }
| State::SpineHandle { began: true, .. }
@@ -396,6 +468,9 @@ impl Tool for AnchorTool {
}
_ => match self.state {
State::Reshape { .. } => Cursor::ReshapeSegment,
// A Shift-drag moving an anchor shows the filled arrow, as Direct Selection's does.
State::Move { began: true, .. } | State::SpineMove { began: true, .. } => Cursor::Arrow,
State::Move { .. } | State::SpineMove { .. } => Cursor::AnchorPoint,
State::Idle => press_cursor(cx, p),
State::Convert { .. } | State::Handle { .. } | State::SpineConvert { .. } | State::SpineHandle { .. } => Cursor::AnchorPoint,
},
@@ -856,4 +931,43 @@ mod tests {
assert!(!a.has_out());
assert_eq!(a.h_in, Point::new(160.0, 280.0));
}
/// Shift held at the press on an anchor moves it, handles and all, instead of pulling out
/// handles; Shift still held keeps the move at 45° steps, released frees it. A Shift-click
/// that doesn't move leaves the anchor as it was.
#[test]
fn anchor_point_shift_drag_moves_the_anchor() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0));
let path = vectorcraft_geom::PathData::single(sp);
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Mods::default() };
let moved = |x: f64, y: f64| Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "x": x, "y": y}));
let mut t = AnchorTool::new("anchorPoint");
// Pressed a pixel off the anchor: it keeps that offset to the pointer.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 201.0, 301.0).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 231.0, 341.0));
assert_eq!(a, vec![Action::Begin("Move".into()), moved(230.0, 340.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 241.0, 306.0).with_mods(shift));
assert!(
matches!(&a[..], [Action::Preview(c, v)] if c == "path.moveAnchor" && v["y"].as_f64().is_some_and(|y| (y - 300.0).abs() < 1e-9)),
"{a:?}"
);
assert_eq!(t.cursor(&cx, Point::new(241.0, 306.0), shift), Cursor::Arrow);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 241.0, 306.0)), vec![Action::Commit]);
// Without Shift the same press still pulls out handles.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0));
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 340.0));
// A Shift-click that doesn't move does nothing (no conversion to a corner).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift));
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0).with_mods(shift)).is_empty());
}
}
+1 -1
View File
@@ -2003,7 +2003,7 @@ path by its control points when one of them has an x or y that is infinite or la
(about 1.1e307), so an edit that leaves such a path selected, `path.create` included, fails with "result would exceed
the canvas (coordinates out of range)".
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`,
`path.setHandle` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that
`path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that
is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such
an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded.
+2 -2
View File
@@ -542,10 +542,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character.
A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included).
With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies).
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0.
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0.
`path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size.
- **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path.
With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`).
With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. With the Anchor Point tool, Shift held at the press on an anchor (or a selected blend's spine point) moves it, handles and all, instead of pulling out handles; Shift kept down keeps the move at 45° steps, and a Shift-click that doesn't move leaves the anchor as it was (`path.moveAnchor`). A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512).
View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.
- **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html).
+1 -1
View File
@@ -59,7 +59,7 @@ tools, never checked side by side) isn't captured row by row.
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
| 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 |
| 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 |
| 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 | partial | Alt to Smooth is missing; 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) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679) |
| Join tool scrubbing, Average, Join | done | Join merges the direct-selected anchors lying on one another along a path, as after Average, into one (#1533) |
| Average and Join in one step, with a corner or smooth choice for the joined anchor | missing | `path.join` and `path.average` are separate commands, and Join has no corner/smooth choice; [gap 1](gaps.md) |