Browse / github.com/storytold/vectorcraft / patch
G11: Pencil redraw when stroke ends away from path (#3793)
Pencil: redraw from a selected shape drops out when the stroke ends away from it (Edit Selected Paths) Follow-up to #2850. Tested on 0.15.0 and on a build of current main (0.16.0), macOS: redraw works well when the stroke starts and ends on the selected path. When a stroke starts on a selected path but ends further than the "Edit selected paths within" distance set in Pencil Tool Options, redraw drops out: a separate new path is drawn and the selected path is left untouched (screenshots: the selected shape, then the result). The live preview also flips between "redraw" and "new path" as the pen crosses that distance. This blocks a common drawing move: drawing something outwards from a shape,
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nrq6993-a11y (imported from pull request #4186, open)
- Base
e2a8280134a7c3c9b4e117ee668b9b3e7eb3e315- Size
- +98 −23 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
0b2556072094929bf83f7169
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/draw2.rs crates/engine/src/tests_draw2.rs
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "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\"}, 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",+ "{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 between where the stroke starts and ends when both are within `within` of one subpath (the shorter way round a closed one), or opens a closed path and appends the stroke when only the start is on the path (#3793)", has_doc, freehand ),@@ -585,6 +585,75 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp) } +/// The anchor nearest to `q` on `sp`: the one there, else a new one, and whether it was added.+fn place_on_subpath(sp: &mut SubPath, q: Point) -> Option<(usize, bool)> {+ let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;+ let n = sp.anchors.len();+ Some(if t <= 1e-6 {+ (seg % n, false)+ } else if t >= 1.0 - 1e-6 {+ ((seg + 1) % n, false)+ } else {+ (sp.insert_anchor(seg, t), true)+ })+}++/// Append a stroke after `anchors`, snapping its start to the last anchor (#3793).+fn append_stroke(anchors: &mut Vec<Anchor>, pts: &[Point], tol: Option<f64>) -> bool {+ if pts.len() < 2 {+ return false;+ }+ let mut stroke = pts.to_vec();+ if let Some(a) = anchors.last() {+ stroke[0] = a.p;+ }+ let fit = stroke_subpath(&stroke, tol, false).anchors;+ let (Some(f0), Some(last)) = (fit.first(), anchors.last_mut()) else { return false };+ *last = Anchor::with_handles(last.p, last.h_in, f0.h_out);+ anchors.extend_from_slice(fit.get(1..).unwrap_or_default());+ true+}++/// Edit Selected Paths (#3793): a stroke that starts on a subpath within `within` but ends away+/// opens a closed path at the start and appends the stroke, or on an open path keeps the side the+/// stroke heads away from and appends the stroke.+fn redraw_extend(pd: &PathData, pts: &[Point], tol: Option<f64>, within: f64) -> Option<(usize, SubPath)> {+ let first = *pts.first()?;+ let (si, _, _, _, d_a) = pd.nearest(first)?;+ if d_a > within || pts.len() < 2 {+ return None;+ }+ let last = *pts.last()?;+ let (sj, _, _, _, d_b) = pd.nearest(last)?;+ if si != sj || d_b <= within {+ return None;+ }+ let mut sp = pd.subpaths.get(si)?.clone();+ if sp.anchors.len() < 2 {+ return None;+ }+ let (from, _) = place_on_subpath(&mut sp, first)?;+ let n = sp.anchors.len();+ let stroke_dir = pts[1] - pts[0];+ let mut anchors = if sp.closed {+ (0..n).map(|k| sp.anchors[(from + k) % n]).collect()+ } else if from > 0 && from + 1 < n {+ let forward = sp.anchors[from + 1].p - sp.anchors[from].p;+ let back = sp.anchors[from].p - sp.anchors[from - 1].p;+ if stroke_dir.dot(forward) >= stroke_dir.dot(back) { sp.anchors[..=from].to_vec() } else { sp.anchors[from..].to_vec() }+ } else if from == 0 {+ let along = sp.anchors[1].p - sp.anchors[0].p;+ if stroke_dir.dot(along) >= 0.0 { vec![sp.anchors[0]] } else { sp.anchors.clone() }+ } else {+ let along = sp.anchors[n - 1].p - sp.anchors[n - 2].p;+ if stroke_dir.dot(along) >= 0.0 { sp.anchors.clone() } else { vec![sp.anchors[n - 1]] }+ };+ if !append_stroke(&mut anchors, pts, tol) {+ return None;+ }+ Some((si, SubPath::new(anchors, false)))+}+ /// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points /// joined by straight segments (`path.freehand` with `fit: false`). fn stroke_subpath(pts: &[Point], tol: Option<f64>, closed: bool) -> SubPath {@@ -611,22 +680,8 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option<f64>, within: f64) - if sp.anchors.len() < 2 { return None; }- // The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each- // point is looked up on the subpath as it is by then: splitting a straight segment makes two- // new ones, which a `t` along the old one doesn't measure.- let place = |sp: &mut SubPath, q: Point| {- let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;- let n = sp.anchors.len();- Some(if t <= 1e-6 {- (seg % n, false)- } else if t >= 1.0 - 1e-6 {- ((seg + 1) % n, false)- } else {- (sp.insert_anchor(seg, t), true)- })- };- let (mut from, _) = place(&mut sp, first)?;- let (to, added) = place(&mut sp, last)?;+ let (mut from, _) = place_on_subpath(&mut sp, first)?;+ let (to, added) = place_on_subpath(&mut sp, last)?; if added && to <= from { from += 1; }@@ -840,7 +895,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> { let (pd, _) = path_of(&s.doc()?.doc, id)?; // shortcut: a stroke pressure profile is left as it was, measured along the old path; fit // it to the new length if Calligraphic pressure strokes get redrawn.- if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) {+ if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within).or_else(|| redraw_extend(&pd, &pts, fit_tol, within)) { s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; // A member path of a compound path is redrawn in it: the compound path is what is@@ -292,8 +292,26 @@ fn pencil_redraws_the_section_of_a_selected_path_between_its_ends() { } } +/// An open path: a stroke that starts in the middle and ends away keeps the side it heads away+/// from and appends the stroke (#3793).+#[test]+fn pencil_redraw_extend_from_open_path_when_stroke_ends_away() {+ let mut s = session();+ let id = line(&mut s, 0.0, 100.0, 300.0, 100.0);+ s.execute("select.set", &json!({"ids": [id.0]})).unwrap();+ s.execute("path.freehand", &json!({"points": [[150, 100], [150, 200], [180, 220]], "redraw": {"id": id.0, "within": 4}})).unwrap();+ assert_eq!(paths(&s).len(), 1);+ let sp = &path(&s, id).subpaths[0];+ assert!(!sp.closed);+ let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();+ assert!(near(ps[0], Point::new(0.0, 100.0)), "keeps the left side: {ps:?}");+ let bbox = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath());+ assert!(bbox.y1 > 200.0, "stroke extends downward: {bbox}");+ assert!(!ps.iter().any(|p| (p.x - 300.0).abs() < 1.0 && (p.y - 100.0).abs() < 1.0), "right end dropped: {ps:?}");+}+ /// On a closed path the stroke replaces the shorter way between where it starts and ends, and the-/// path stays closed; a stroke that doesn't end on the path draws a new one.+/// path stays closed; a stroke that starts on the path but ends away opens it and appends the stroke. #[test] fn pencil_redraws_the_shorter_way_round_a_closed_path() { let mut s = session();@@ -310,11 +328,13 @@ fn pencil_redraws_the_shorter_way_round_a_closed_path() { } let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0; assert!(top < -20.0, "the dip: top {top}");- // Not ending on the path: a new path, the rectangle as it was.- let before = path(&s, id);+ // Starting on the path but ending away: one open path with the outline and the stroke (#3793). s.execute("path.freehand", &json!({"points": [[50, 100], [80, 150], [120, 160]], "redraw": {"id": id.0, "within": 4}})).unwrap();- assert_eq!(paths(&s).len(), 2);- assert_eq!(path(&s, id), before);+ assert_eq!(paths(&s).len(), 1);+ let sp = &path(&s, id).subpaths[0];+ assert!(!sp.closed);+ let bbox = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath());+ assert!(bbox.y1 > 155.0, "stroke extends below the rectangle: {bbox}"); assert!(s.execute("path.freehand", &json!({"points": [[0, 0], [1, 1]], "redraw": {"id": id.0, "within": -1}})).is_err()); }