Paths: Shift keeps a reshaped segment's handles perpendicular and equal in length

Shift pressed while dragging a segment had no effect on the curve:
Direct Selection's curved-segment and spine-segment drags, the Anchor
Point tool and the Pen with Alt sent the pointer offset alone, and the
segment bent freely. The reference app keeps the handles in the
perpendicular direction and of equal length while Shift is held.

path.reshapeSegment and object.blend.spine.reshapeSegment take
perpendicular (default false, which keeps today's behavior). With it,
both of the segment's handles stand at right angles to the line between
its anchors, on the same side and at the same length, and the point at
t lies as far from that line as the moved point does. A smooth anchor
at either end stays smooth, as it does in a drag without perpendicular,
and a segment whose anchors coincide bends as it does without
perpendicular. With Direct Selection, Shift pressed once a curved
segment's drag has started sends perpendicular; Shift held at the press
still toggles the path's selection, as before. Direct Selection's
spine-segment drag and the Anchor Point tool's segment drag send Shift
as perpendicular too, so the Pen with Alt does as well. A straight
segment dragged with Direct Selection already moves at 45° steps with
Shift.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 14:46:20 +07:00
committed by bflatastic
parent 0e26b258eb
commit f3072fc748
10 changed files with 195 additions and 13 deletions
+4 -2
View File
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Segment",
[],
None,
"{id, subpath, segment, t, dx, dy} bend a segment so the point at t moves by (dx, dy)",
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
has_doc,
reshape_segment
),
@@ -889,10 +889,12 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
if !(dv.x.is_finite() && dv.y.is_finite()) {
return Err(bad(C, "invalid delta"));
}
let perpendicular = bool_or(p, "perpendicular", false);
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if !sp.bend_segment(seg, t, dv) {
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
if !bent {
return Err(EngineError::Other("no such segment".into()));
}
Ok(())
+6 -2
View File
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Spine Segment",
[],
None,
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)",
has_doc,
spine_reshape_segment
),
@@ -657,7 +657,11 @@ fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
let perpendicular = bool_or(p, "perpendicular", false);
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
})?;
ok()
}
+15
View File
@@ -204,3 +204,18 @@ fn a_straight_spine_segment_bends_into_a_curve() {
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
}
}
/// With `perpendicular` (Shift) the spine segment's handles stand at right angles to it, at one
/// length: its middle goes straight down, wherever the drag goes sideways.
#[test]
fn a_spine_segment_bends_with_perpendicular_handles() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 10, "dy": 40, "perpendicular": true})).unwrap();
let sp = spec(&s, g).spine.unwrap().subpaths[0].clone();
let (a, b) = (sp.anchors[0], sp.anchors[1]);
let (ha, hb) = (a.h_out - a.p, b.h_in - b.p);
assert!(ha.x.abs() < 1e-9 && hb.x.abs() < 1e-9 && (ha.y - hb.y).abs() < 1e-9, "{sp:?}");
use vectorcraft_geom::kurbo::ParamCurve;
assert!(sp.segment(0).eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{sp:?}");
}
+64
View File
@@ -1384,6 +1384,70 @@ fn reshaping_a_segment_keeps_smooth_anchors_smooth() {
assert!(near(sp.anchors[0].h_out, Point::new(100.0, 260.0)) && !sp.anchors[0].has_in(), "the corner end: {:?}", sp.anchors[0]);
}
/// Both handles of segment `seg` stand at right angles to the line between its anchors, at one
/// length: that length, positive on the right of the line as it runs from the first anchor (y
/// down).
fn perpendicular_handles(sp: &vectorcraft_geom::SubPath, seg: usize) -> f64 {
let n = sp.anchors.len();
let (a, b) = (sp.anchors[seg], sp.anchors[(seg + 1) % n]);
let chord = b.p - a.p;
let normal = kurbo::Vec2::new(-chord.y, chord.x) / chord.hypot();
let (ha, hb) = (a.h_out - a.p, b.h_in - b.p);
assert!(ha.dot(chord).abs() < 1e-6 && hb.dot(chord).abs() < 1e-6, "at right angles: {a:?} {b:?}");
assert!((ha.dot(normal) - hb.dot(normal)).abs() < 1e-6, "one length: {a:?} {b:?}");
ha.dot(normal)
}
#[test]
fn shift_reshapes_a_segment_with_perpendicular_handles_of_one_length() {
// `perpendicular` (Shift): both handles of the segment stand at right angles to the line
// between its anchors at one length, on the side the point at t goes to, and that point lies
// as far from the line as the moved point does.
use kurbo::ParamCurve;
let mut s = session();
let reshape = |s: &mut Session, id: NodeId, seg: usize, t: f64, dx: f64, dy: f64| {
s.execute("path.reshapeSegment", &json!({"id": id.0, "segment": seg, "t": t, "dx": dx, "dy": dy, "perpendicular": true})).unwrap();
};
// A straight segment: the point at 0.5 goes 30 pt up and does not follow dx sideways.
let straight = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300}, {"x": 300, "y": 300}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": straight})).unwrap()["id"].as_u64().unwrap());
reshape(&mut s, id, 0, 0.5, 15.0, -30.0);
let sp = &path(&s, id).subpaths[0];
assert!((perpendicular_handles(sp, 0) + 40.0).abs() < 1e-6, "{sp:?}");
assert!(near(sp.segment(0).eval(0.5), Point::new(150.0, 270.0)), "{:?}", sp.segment(0));
assert!(!sp.anchors[1].has_out() && sp.segment_is_line(1), "the next segment stays straight");
// A curved segment: its handles turn upright, and its point at 0.25 goes 10 pt further up.
let curved = json!([{"x": 100, "y": 300, "out": [130, 280]}, {"x": 200, "y": 300, "in": [190, 250]}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": curved})).unwrap()["id"].as_u64().unwrap());
let before = path(&s, id).subpaths[0].segment(0).eval(0.25);
reshape(&mut s, id, 0, 0.25, 0.0, -10.0);
let sp = &path(&s, id).subpaths[0];
assert!(perpendicular_handles(sp, 0) < 0.0, "above the line: {sp:?}");
assert!((sp.segment(0).eval(0.25).y - (before.y - 10.0)).abs() < 1e-6, "{:?}", sp.segment(0));
// Pulled past the line, the handles go to its other side.
reshape(&mut s, id, 0, 0.5, 0.0, 80.0);
assert!(perpendicular_handles(&path(&s, id).subpaths[0], 0) > 0.0, "below the line");
}
#[test]
fn shift_reshapes_the_closing_segment_of_a_rotated_square_and_undoes() {
// A square turned 45°: the Pen with Alt and Shift held drags its last segment, the one that
// closes it, outward and to one side; one undo step puts it back.
let mut s = session();
let diamond = json!([{"x": 200, "y": 100}, {"x": 300, "y": 200}, {"x": 200, "y": 300}, {"x": 100, "y": 200}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": diamond, "closed": true})).unwrap()["id"].as_u64().unwrap());
let original = path(&s, id);
s.select_tool("pen", view()).unwrap();
let alt_shift = Mods { alt: true, shift: true, ..Mods::default() };
assert_eq!(gesture(&mut s, &[(150.0, 150.0), (140.0, 145.0), (130.0, 140.0)], alt_shift), 1);
let sp = &path(&s, id).subpaths[0];
let l = perpendicular_handles(sp, 3);
assert!(l < 0.0 && sp.anchors[3].h_out.x < 100.0 && sp.anchors[0].h_in.y < 100.0, "outward: {sp:?}");
assert!(!sp.anchors[3].has_in() && !sp.anchors[0].has_out(), "the corners' other handles stay put: {sp:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), original);
}
#[test]
fn pen_drag_from_the_first_anchor_closes_the_path() {
// #501: closing a path by dragging out of its first anchor flickered between a close and a new
+27
View File
@@ -269,6 +269,33 @@ impl SubPath {
}
true
}
/// Bend segment `seg` as a Shift-drag does: both of its handles stand at right angles to the
/// line between its anchors, on the same side and at the same length, so that its point at `t`
/// lies as far from that line as the old point at `t` moved by `dv` does. A smooth anchor stays
/// smooth, as with [`Self::bend_segment`]. A segment whose anchors coincide bends as
/// [`Self::bend_segment`] bends it. False when there is no such segment.
pub fn bend_segment_perpendicular(&mut self, seg: usize, t: f64, dv: kurbo::Vec2) -> bool {
let n = self.anchors.len();
if seg >= self.segment_count() || n == 0 {
return false;
}
let c = self.segment(seg);
let chord = c.p3 - c.p0;
let len = chord.hypot();
if len <= EPS {
return self.bend_segment(seg, t, dv);
}
let normal = kurbo::Vec2::new(-chord.y, chord.x) / len;
// Handles of length l along the normal put the point at t 3t(1-t)·l from the line.
let v = normal * ((c.eval(t) + dv - c.p0).dot(normal) / (3.0 * t * (1.0 - t)));
if let Some(a) = self.anchors.get_mut(seg % n) {
a.set_handle(true, a.p + v, false);
}
if let Some(b) = self.anchors.get_mut((seg + 1) % n) {
b.set_handle(false, b.p + v, false);
}
true
}
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
+40 -2
View File
@@ -619,7 +619,7 @@ impl Tool for DirectSelectionTool {
let d = p - start;
out.push(Action::Preview(
"path.reshapeSegment".into(),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": d.x, "dy": d.y, "perpendicular": ev.mods.shift}),
));
out
}
@@ -704,7 +704,7 @@ impl Tool for DirectSelectionTool {
let d = p - start;
out.push(Action::Preview(
"object.blend.spine.reshapeSegment".into(),
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.y, "perpendicular": ev.mods.shift}),
));
out
}
@@ -1135,6 +1135,44 @@ mod tests {
assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]);
}
/// Shift held while a curved segment is dragged sends `perpendicular`. The test drags a path's
/// segment with Direct Selection, and a blend spine's segment with Direct Selection and with the
/// Anchor Point tool.
#[test]
fn shift_reshapes_a_segment_perpendicular() {
let shift = Mods { shift: true, ..Mods::default() };
let p = paint();
let drag = |t: &mut dyn crate::Tool, cx: &ToolContext, from: (f64, f64), mods: Mods| {
t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1));
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, from.0 + 10.0, from.1 - 20.0).with_mods(mods));
t.pointer(cx, &PointerEvent::new(PointerKind::Up, from.0 + 10.0, from.1 - 20.0).with_mods(mods));
a
};
let sent = |a: &[Action], cmd: &str| match a.last() {
Some(Action::Preview(c, v)) if c == cmd => v["perpendicular"].as_bool(),
_ => None,
};
let (d, _) = arch_doc();
let s = Selection::default();
let arch = cx(&d, &s, &p);
let (mut b, g, _) = blend_doc();
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false);
sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0));
let Some(NodeKind::Blend { spec, .. }) = b.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]);
let mut bs = Selection::default();
bs.set([g]);
let spine = cx(&b, &bs, &p);
for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] {
let a = drag(&mut DirectSelectionTool::new(false), &arch, (150.0, 262.5), mods);
assert_eq!(sent(&a, "path.reshapeSegment"), Some(perpendicular), "{a:?}");
let a = drag(&mut DirectSelectionTool::new(false), &spine, (160.0, 330.0), mods);
assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}");
let a = drag(crate::create("anchorPoint").as_mut(), &spine, (160.0, 330.0), mods);
assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}");
}
}
#[test]
fn direct_and_group_selection_pick_blend_keys() {
let (d, g, k1) = blend_doc();
+24 -3
View File
@@ -5,7 +5,8 @@
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; click a
//! 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).
//! straight or curved (#1111; Shift: its handles at right angles to the line between its anchors,
//! at one length).
//! Scissors: click a path to split it there.
use serde_json::json;
@@ -156,11 +157,11 @@ impl Tool for AnchorTool {
}
State::Reshape { id, seg, t, start, spine: true, .. } => Action::Preview(
"object.blend.spine.reshapeSegment".into(),
json!({"id": id.0, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
json!({"id": id.0, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y, "perpendicular": ev.mods.shift}),
),
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
"path.reshapeSegment".into(),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y, "perpendicular": ev.mods.shift}),
),
};
out.push(preview);
@@ -318,6 +319,26 @@ mod tests {
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0));
}
/// Shift held while a segment is dragged keeps its handles at right angles to the line between
/// its anchors (`perpendicular`).
#[test]
fn shift_reshapes_a_segment_perpendicular() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Mods::default() };
for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] {
let mut t = AnchorTool::new("anchorPoint");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 200.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 230.0).with_mods(mods));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["perpendicular"] == perpendicular),
"{a:?}"
);
}
}
/// #494: the Anchor Point tool picks within Selection & Anchor Display → Tolerance, and drags
/// only the handles shown.
#[test]
+7 -1
View File
@@ -1272,10 +1272,16 @@ mod tests {
assert_eq!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape");
assert_eq!(a[0], Action::Begin("Reshape".into()));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0),
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0 && v["perpendicular"] == false),
"{a:?}"
);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]);
// Shift held too keeps the handles at right angles to the line between the anchors.
let alt_shift = Mods { shift: true, ..alt };
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.0, 262.0).with_mods(alt_shift));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["perpendicular"] == true), "{a:?}");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.0, 262.0)), vec![Action::Commit]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 400.0).with_mods(alt));
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
}
+6 -3
View File
@@ -1752,8 +1752,11 @@ Selected End Points) joins the ends again; `path.join {ids: [a, b], ends: ["last
(the Pen's join, #776: drawing on from one open path, a click on another's end makes them one path). `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt
held over a selected path's handle, anchor or segment works as the Anchor Point tool. `path.reshapeSegment {id,
subpath?, segment, t, dx, dy}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's
subpath?, segment, t, dx, dy, perpendicular?}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's
point at `t` by `dx`, `dy`: a smooth anchor at either end stays smooth, its other handle turning with the moved one.
With `perpendicular: true` (Shift held during the drag) both of the segment's handles stand at right angles to the
line between its anchors, on the same side and at the same length, and the point at `t` lies as far from that line
as the point moved by `dx`, `dy` does; a segment whose anchors coincide bends as it does without `perpendicular`.
A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on
macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen,
@@ -3785,8 +3788,8 @@ among the objects takes the others in as more key objects, keeping its options,
A blend's spine is the straight lines between its key centres until it is edited. `object.blend.info` lists it
(`spine.anchors`, `keyAnchors`: the point each key sits on). `object.blend.spine.addAnchor {x, y}` adds a point where
the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a point (a key on it moves along; with
`handle: "in" | "out"` it places that handle), `object.blend.spine.reshapeSegment {segment, t, dx, dy}` bends a segment
so its point at `t` moves by (dx, dy), turning a straight spine into a curve, and `object.blend.spine.removeAnchor
`handle: "in" | "out"` it places that handle), `object.blend.spine.reshapeSegment {segment, t, dx, dy, perpendicular?}` bends a segment
so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`perpendicular` works as in `path.reshapeSegment`), and `object.blend.spine.removeAnchor
{anchor}` deletes a point no key sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release
(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result):
+2
View File
@@ -497,6 +497,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). The link that keeps Transform Each's two scales equal is not built.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663).
- **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.
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`).
- **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.
- **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.
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Average and Join in one step, with a corner or smooth choice for the joined anchor, is missing ([gap 1](gaps.md)).