diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 2737c59b5..33676e7e9 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Pencil", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", has_doc, freehand ), @@ -709,11 +709,40 @@ fn freehand(s: &mut Session, p: &Value) -> Result { if pts.len() < 2 { return Ok(json!({ "id": id.0 })); } + // A stroke that ends on an open end of another path connects the two: (that path, its last + // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). + let connect = match p.get("connect").filter(|v| !v.is_null()) { + Some(cn) => { + let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + if other == id { + return Err(bad(C, "connect needs a path other than the one extended")); + } + let (opd, _) = path_of(&s.doc()?.doc, other)?; + let osi = + opd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "connect path has no open end"))?; + let mut y = opd.subpaths[osi].clone(); + let at_end = match str_param(cn, "end") { + Some("start") => false, + Some("end") => true, + _ => return Err(bad(C, "connect end must be \"start\" or \"end\"")), + }; + if at_end { + y.reverse(); + } + Some((other, osi, y, at_end)) + } + None => None, + }; let close_tol = (tol * 4.0).max(6.0); - let closing = sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); + let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); if closing && let Some(l) = pts.last_mut() { *l = first; } + if let Some((_, _, y, _)) = &connect + && let (Some(l), Some(y0)) = (pts.last_mut(), y.anchors.first()) + { + *l = y0.p; + } let fit = stroke_subpath(&pts, fit_tol, false); let mut add = fit.anchors; if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) { @@ -734,12 +763,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result { for a in &mut sp.anchors { *a = Anchor::with_handles(a.p, a.h_in, a.h_out); } - // The path's pressure (in the direction it is continued in) followed by the stroke's. - let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone()); - let joined = (old_pressure.is_some() || pressure.is_some()).then(|| { + let drawn_len = subpath_len(&sp); + // The other path's anchors follow on from the one the stroke ends on, which takes its + // outgoing handle. + let mut other_len = 0.0; + if let Some((_, _, y, _)) = &connect + && let Some((y0, rest)) = y.anchors.split_first() + { + if let Some(last) = sp.anchors.last_mut() { + *last = Anchor::with_handles(y0.p, last.h_in + (y0.p - last.p), y0.h_out); + } + sp.anchors.extend_from_slice(rest); + other_len = subpath_len(y); + } + // The path's pressure (in the direction it is continued in) followed by the stroke's, and + // by the pressure of the path it connects to. + let pressure_of = |n: NodeId| s.doc().ok()?.doc.node(n).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone()); + let old_pressure = pressure_of(id); + let other_pressure = connect.as_ref().and_then(|(o, _, _, at_end)| pressure_of(*o).map(|p| if *at_end { p.reversed() } else { p })); + let joined = (old_pressure.is_some() || pressure.is_some() || other_pressure.is_some()).then(|| { let old = old_pressure.map(|p| if at_start { p.reversed() } else { p }); - let total = subpath_len(&sp); - PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len) + let drawn = PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), drawn_len - old_len); + if connect.is_some() { PressureProfile::extended(drawn.as_ref(), drawn_len, other_pressure.as_ref(), other_len) } else { drawn } }); s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { let reverse = at_start && !sp.closed; @@ -747,6 +792,31 @@ fn freehand(s: &mut Session, p: &Value) -> Result { sp.reverse(); } path_mut(d, id)?.subpaths[si] = sp; + if let Some((other, osi, ..)) = &connect { + let o = path_mut(d, *other)?; + if *osi < o.subpaths.len() { + o.subpaths.remove(*osi); + } + if o.is_empty() { + d.remove(*other)?; + sel.remove(*other); + } + // Direct-selected anchors and segments of the subpaths after the one removed move + // down one; those of the one removed go, and a path left with no selected anchors + // is no longer selected. + let shift = |refs: &mut BTreeSet<(usize, usize)>| { + *refs = refs.iter().filter(|r| r.0 != *osi).map(|&(sub, i)| if sub > *osi { (sub - 1, i) } else { (sub, i) }).collect(); + }; + if let Some(refs) = sel.anchors.get_mut(other) { + shift(refs); + } + if let Some(refs) = sel.segments.get_mut(other) { + shift(refs); + } + if sel.anchors.get(other).is_some_and(BTreeSet::is_empty) { + sel.remove(*other); + } + } if let Some(joined) = joined && let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 45dc9c074..2acd55621 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -178,6 +178,90 @@ fn pencil_continues_selected_open_path() { assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0)); } +/// Connect two paths: with two open paths selected, a Pencil stroke from an end of one to an end +/// of the other joins them into one path, the first one's, in one undo step. A path that is not +/// selected is not joined. +#[test] +fn pencil_connects_two_selected_paths_from_an_end_of_one_to_an_end_of_the_other() { + let stroke = [(201.0, 101.0), (230.0, 130.0), (270.0, 180.0), (299.0, 199.0)]; + // The stroke ends on the second path's start, then on its end. + for (b_from, b_to) in [((300.0, 200.0), (400.0, 200.0)), ((400.0, 200.0), (300.0, 200.0))] { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, b_from.0, b_from.1, b_to.0, b_to.1); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.select_tool("pencil", view()).unwrap(); + assert_eq!(gesture(&mut s, &stroke, Mods::default()), 1, "{b_from:?}: one undo step"); + let ps = paths(&s); + assert_eq!(ps.len(), 1, "{b_from:?}: the second path is joined on"); + assert_eq!(ps[0].0, a); + let sp = &ps[0].1.subpaths[0]; + assert!(!sp.closed); + assert_eq!(sp.anchors[0].p, Point::new(100.0, 100.0)); + assert_eq!(sp.anchors[sp.anchors.len() - 1].p, Point::new(400.0, 200.0), "{b_from:?}"); + assert_eq!(sp.anchors.iter().filter(|x| x.p == Point::new(300.0, 200.0)).count(), 1, "{b_from:?}: one anchor where they meet"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + } + // The second path is not selected: the first is continued and the second left as it was. + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 200.0, 400.0, 200.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + drag(&mut s, "pencil", &stroke, Mods::default()); + assert_eq!(paths(&s).len(), 2); + assert_eq!(path(&s, b).anchor_count(), 2); + assert!(path(&s, a).anchor_count() >= 3, "continued"); + assert!(path(&s, a).subpaths[0].anchors.iter().all(|x| x.p != Point::new(400.0, 200.0))); +} + +/// `path.freehand`'s connect takes the other path's last open subpath and leaves the rest of it, +/// whose direct-selected anchors follow their subpaths; it refuses the path it extends and an end +/// other than "start" or "end". +#[test] +fn pencil_connect_keeps_the_rest_of_a_compound_path_and_refuses_the_extended_path() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let d = "M300 200 L400 200 M300 400 L400 400 M0 500 L50 500 L50 550 Z"; + let bc = NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap()); + let points = json!([[200, 100], [250, 250], [400, 400]]); + for end in [json!("middle"), json!(1), json!(null)] { + let bad = json!({"points": points, "extend": {"id": a.0, "end": "end"}, "connect": {"id": bc.0, "end": end}}); + assert!(s.execute("path.freehand", &bad).is_err(), "{bad}"); + } + assert_eq!((path(&s, a).anchor_count(), path(&s, bc).subpaths.len()), (2, 3), "refused: nothing changed"); + s.execute("select.anchors", &json!({"id": bc.0, "anchors": [[0, 1], [1, 0], [2, 2]], "mode": "set"})).unwrap(); + let connect = json!({"points": points, "extend": {"id": a.0, "end": "end"}, "connect": {"id": bc.0, "end": "end"}, "keepSelected": false}); + s.execute("path.freehand", &connect).unwrap(); + let left = path(&s, bc); + assert_eq!(left.subpaths.len(), 2, "the other subpaths stay"); + assert_eq!(left.subpaths[0].anchors[0].p, Point::new(300.0, 200.0)); + let anchors: Vec<_> = s.doc().unwrap().selection.anchors.get(&bc).into_iter().flatten().copied().collect(); + assert_eq!(anchors, vec![(0, 1), (1, 2)], "the joined subpath's anchor goes, the closed one's moves down"); + let sp = &path(&s, a).subpaths[0]; + assert_eq!(sp.anchors[sp.anchors.len() - 1].p, Point::new(300.0, 400.0)); + assert!(sp.anchors.iter().any(|x| x.p == Point::new(400.0, 400.0))); + assert!(s.execute("path.freehand", &json!({"points": points, "extend": {"id": a.0}, "connect": {"id": a.0}})).is_err()); +} + +/// With Keep selected off, a compound path whose direct-selected anchors all lie on the subpath +/// that connect joins on is no longer selected, and Delete then has nothing to delete. +#[test] +fn pencil_connect_deselects_a_path_whose_selected_anchors_were_all_joined_on() { + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let d = "M300 200 L400 200 M300 400 L400 400 M0 500 L50 500 L50 550 Z"; + let bc = NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap()); + s.execute("select.anchors", &json!({"id": bc.0, "anchors": [[1, 0], [1, 1]], "mode": "set"})).unwrap(); + let points = json!([[200, 100], [250, 250], [400, 400]]); + let connect = json!({"points": points, "extend": {"id": a.0, "end": "end"}, "connect": {"id": bc.0, "end": "end"}, "keepSelected": false}); + s.execute("path.freehand", &connect).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert!(!sel.contains(bc) && !sel.anchors.contains_key(&bc) && !sel.segments.contains_key(&bc), "{sel:?}"); + assert!(s.execute("edit.clear", &json!({})).is_err(), "nothing selected"); + let left = path(&s, bc); + assert_eq!((left.subpaths.len(), left.anchor_count()), (2, 5), "{left:?}"); +} + /// Edit Selected Paths in the middle of a path (#2850): a Pencil stroke that starts and ends on a /// selected path replaces the section between, drawn either way; the path stays one path with /// its ends, and is one undo step. diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 299a579e2..443abcfc8 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -36,6 +36,9 @@ pub struct GestureTool { near_start: bool, /// Pencil/Paintbrush continuing a selected open path: (path, continue from its start). extend: Option<(NodeId, bool)>, + /// A Pencil stroke continuing a selected open path: the other paths selected at the press, + /// whose open ends it connects to. + connect_to: Vec, /// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected /// Paths' distance in points); the stroke redraws the section it ends on (#2850). redraw: Option<(NodeId, f64)>, @@ -107,6 +110,7 @@ impl GestureTool { began: false, near_start: false, extend: None, + connect_to: vec![], redraw: None, fidelity, size: 10.0, @@ -232,12 +236,17 @@ impl GestureTool { /// The selected open path whose end is near `p` (Edit Selected Paths, off at 0 pixels), and /// that end. fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool, Point)> { + self.find_end(cx, p, &cx.selection.objects) + } + + /// [`Self::find_extend`] among `ids`. + fn find_end(&self, cx: &ToolContext, p: Point, ids: &[NodeId]) -> Option<(NodeId, bool, Point)> { if self.edit_within <= 0.0 { return None; } let tol = cx.tol(self.edit_within); let editable = cx.doc.editable(); - for id in &cx.selection.objects { + for id in ids { let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue }; if !editable(*id) { continue; @@ -316,7 +325,14 @@ impl GestureTool { let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end)); // A straight line has two points: closing it would draw it back over itself. let closed = !self.straight && ((m.alt && !self.alt_smooth) || (self.extend.is_none() && near)); - vec![Action::Preview("path.freehand".into(), self.freehand_params(closed)), Action::Commit] + let mut params = self.freehand_params(closed); + // A Pencil stroke that continues a selected path and ends on an open end of another + // path selected at the press connects the two. + let connect = pts.last().and_then(|&end| self.find_end(cx, end, &self.connect_to)); + if let Some((other, at_start, _)) = connect { + params["connect"] = json!({"id": other.0, "end": if at_start { "start" } else { "end" }}); + } + vec![Action::Preview("path.freehand".into(), params), Action::Commit] } "blobBrush" => vec![Action::Exec( "path.blob".into(), @@ -367,6 +383,11 @@ impl Tool for GestureTool { self.unconstrained = self.straight && !ev.mods.shift; let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None }; self.extend = extend.map(|(id, at_start, _)| (id, at_start)); + // The stroke's previews select the path it continues, so the others are kept now. + self.connect_to = match self.extend { + Some((id, _)) if self.id == "pencil" => cx.selection.objects.iter().copied().filter(|o| *o != id).collect(), + _ => vec![], + }; self.redraw = if self.draws_path() && !self.smoothing && self.extend.is_none() { self.find_redraw(cx, p) } else { None }; // A path continued starts at its end, one redrawn on it; a new one where it snaps. // A straight line is constrained from the end it continues, so it starts there. @@ -475,6 +496,7 @@ impl Tool for GestureTool { self.unconstrained = false; self.clear(); self.extend = None; + self.connect_to.clear(); self.redraw = None; out } @@ -1344,6 +1366,48 @@ mod tests { } } + /// A Pencil stroke from an end of a selected open path to an end of another selected open path + /// connects the two; it does not when the other path is not selected, and the Paintbrush does + /// not connect. + #[test] + fn pencil_connects_a_stroke_from_one_selected_path_to_an_end_of_another() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), + vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + let a = add(&mut d, (300.0, 300.0), (400.0, 300.0)); + let b = add(&mut d, (500.0, 400.0), (600.0, 400.0)); + let p = paint(); + let stroke = [(400.0, 300.0), (450.0, 350.0), (499.0, 401.0)]; + let mut both = Selection::default(); + both.set([a, b]); + let mut one = Selection::default(); + one.set([a]); + // The previews select the path continued: after the press only it is selected. + let last = |tool: &str, at_press: &Selection| { + let mut t = GestureTool::new(tool); + t.pointer(&cx(&d, at_press, &p), &PointerEvent::new(PointerKind::Down, stroke[0].0, stroke[0].1)); + let after = cx(&d, &one, &p); + t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[1].0, stroke[1].1)); + t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[2].0, stroke[2].1)); + let acts = t.pointer(&after, &PointerEvent::new(PointerKind::Up, stroke[2].0, stroke[2].1)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + v.clone() + }; + let v = last("pencil", &both); + assert_eq!((v["extend"].clone(), v["connect"].clone()), (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}))); + assert!(last("paintbrush", &both).get("connect").is_none(), "the Paintbrush"); + let v = last("pencil", &one); + assert!(v.get("extend").is_some() && v.get("connect").is_none(), "{v}"); + } + /// Close paths when ends are within 0 turns closing off: a stroke that ends exactly where it /// started stays open; at 15 px it is closed. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c8d9a4af2..457857325 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -680,6 +680,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In the Pencil and Paintbrush Tool Options, Edit selected paths within 0 px turns editing off, so a stroke that starts exactly on an end of a selected open path draws a new path; Close paths when ends are within 0 px turns closing off, so a stroke that ends exactly where it started stays open. Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default; while it is off, the path a stroke draws or continues is not selected afterwards and the rest of the selection stays as it was (`path.freehand` `keepSelected`). + With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html) describes (`path.freehand` `connect`). Double-clicking the Shape Builder tool opens Shape Builder Tool Options ([Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html)): with Consider Open Filled Paths as Closed off, a filled open path cuts regions as an unfilled one does; Pick Color From Color Swatches fills merged shapes with the current fill, the Left and Right arrow keys step it through the document's color swatches, and Cursor Swatch Preview shows the current fill and the swatches before and after it above the pointer; Selection Straight Line merges what the line from a drag's first point to its last crosses ([Setting Illustrator's Shape Builder Options](https://web.archive.org/web/20260615204453/https://creativepro.com/setting-illustrators-shape-builder-options/)); with Highlight Fill off, merge mode shades no regions (`shapeBuilder.merge` `openFilledAsClosed` and `pickColorFrom`). With In Merge Mode, Clicking Stroke Splits the Path on in the same dialog, a click on a stroke in merge mode splits its path into the run of stroke under the pointer and the rest of the path, as the [Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html) page describes; the run goes from the anchor or crossing point before the pointer to the one after it, and the pointer shows scissors over it. Highlight Stroke When Editable draws that run in the color chosen in its Color list (Red by default), and with Pick Color From Color Swatches the run takes the current fill as its stroke (`shapeBuilder.merge` `splitStroke`). With Pick Color From Artwork, a merged shape takes the appearance, opacity and blending mode of the object where the drag starts, else of the object where it ends, else of the front-most selected object, as the [Shape Builder combine page](https://web.archive.org/web/20251006211643/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/combine-shapes-with-shape-builder-tool.html) describes. A click in an area that no selected object covers, such as a hole other shapes enclose, takes the front-most selected object's look (`shapeBuilder.merge` `points`: the first point is where the drag starts, and the last point is where it ends). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cb4afe3da..747d3cf83 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -91,7 +91,7 @@ tools, never checked side by side) isn't captured row by row. | 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; 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 | 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) 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); 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) 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); 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 | | 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 | partial | `path.join` accepts `to: corner|smooth` on the merged anchor; Average and Join are still separate commands; [gap 1](gaps.md) | | Smooth tool smooths only the stretch of path under the drag | done | #1512 |