collective

Browse / github.com/storytold/vectorcraft / patch

Pencil: a stroke eases into ends snapped to the grid instead of jumping to them (#2958)

Pencil: a stroke eases into ends snapped to the grid instead of jumping to them (#2958) ## What happened The Pencil snaps the start and end of a new stroke (to Smart Guides, points, pixels, and with Snap to Grid on, the nearest grid point; `GestureTool::pointer` in `crates/tools/src/draw2/gesture.rs`). Only the first and last samples moved. With Snap to Grid the nearest grid point can be several points away (half a 9 pt grid cell diagonally is 6.4 pt), so the path jumped there and back, leaving a hook at each end. Reproduced on Windows 11 with current `main` through the control channel: a smooth stroke from (103.5, 104.5) to (296.5, 196.5) fits 4 anchors with Snap to Gr

Status
open
Score
0.00
Adopters
0 other operators
Author
00xJS (imported from pull request #3013, open)
Base
f6c716839e44c207f5511e806319baf85ebcf642
Size
+194 −12 in 1 file
Review
pending
Shared
2026-10-28
Id
ca9ba40832596c455717d2c2

How it went

No other operator has reported using it yet.

Diff

raw · crates/tools/src/draw2/gesture.rs

diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs--- a/crates/tools/src/draw2/gesture.rs+++ b/crates/tools/src/draw2/gesture.rs@@ -14,7 +14,7 @@  use serde_json::{Value, json}; use vectorcraft_doc::NodeId;-use vectorcraft_geom::Point;+use vectorcraft_geom::{Point, Vec2};  use super::{FEEDBACK, points_json, polyline}; use crate::guides::DrawSnap;@@ -62,6 +62,34 @@ pub struct GestureTool {     pub close_within: f64,     /// Smart Guides for the Pencil's ends.     snap: DrawSnap,+    /// Where the Pencil's stroke began, before its start snapped, and how far snapping moved it:+    /// the samples near the start take part of that move ([`ease`]).+    start_shift: (Point, Vec2),+    /// Where the pointer was for the last sample taken, before easing moved it: the steps between+    /// samples, and whether the release adds one, are measured from there.+    last_raw: Point,+    /// Where the pointer was for each of `points` (the Pencil's ends bend the stroke from these).+    raw: Vec<Point>,+}++/// How far, in multiples of the move, the samples near a snapped end follow it (#2958).+const EASE_SPAN: f64 = 4.0;++/// `p` moved by part of `shift`: all of it at `from`, none from [`EASE_SPAN`] times the shift's+/// length away, easing between. A stroke end that snaps (to the grid, a point or a guide) then+/// bends the stroke smoothly to it instead of jumping there and leaving a hook (#2958).+fn ease(p: Point, from: Point, shift: Vec2) -> Point {+    p + shift * weight(p, from, shift)+}++/// How much of `shift` a sample at `p` takes when the end at `from` moved by it ([`ease`]): 1 at+/// `from`, 0 from [`EASE_SPAN`] times the move away.+fn weight(p: Point, from: Point, shift: Vec2) -> f64 {+    let len = shift.hypot();+    if !(len > 1e-9 && len.is_finite()) {+        return 0.0;+    }+    (1.0 - (p.distance(from) / (EASE_SPAN * len)).min(1.0)).powi(2) }  impl GestureTool {@@ -99,6 +127,9 @@ impl GestureTool {             edit_within: 12.0,             close_within: 15.0,             snap: DrawSnap::default(),+            start_shift: (Point::ZERO, Vec2::ZERO),+            last_raw: Point::ZERO,+            raw: vec![],         }     } @@ -161,6 +192,7 @@ impl GestureTool {     fn clear(&mut self) {         self.points.clear();         self.pressures.clear();+        self.raw.clear();     }      /// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere@@ -314,8 +346,11 @@ impl Tool for GestureTool {                 } else {                     p                 };+                self.start_shift = (p, start - p);+                self.last_raw = p;                 self.clear();                 self.push(start, ev.pressure);+                self.raw.push(p);                 vec![]             }             PointerKind::Drag => {@@ -337,13 +372,19 @@ impl Tool for GestureTool {                     if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) {                         // Back at the press point there is no line: the one shown goes.                         self.points.truncate(1);+                        self.raw.truncate(1);                         return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };                     }                     self.points.truncate(1);-                } else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {+                    self.raw.truncate(1);+                } else if !self.points.is_empty() && self.last_raw.distance(p) < cx.tol(1.0) {                     return vec![];                 }-                self.push(p, ev.pressure);+                let (from, shift) = self.start_shift;+                self.last_raw = p;+                self.raw.push(p);+                // A straight line's end is the pointer: only a freehand stroke eases into its start.+                self.push(if self.straight { p } else { ease(p, from, shift) }, ev.pressure);                 if !self.draws_path() || self.smoothing {                     return vec![];                 }@@ -370,15 +411,42 @@ impl Tool for GestureTool {                     } else {                         cancel = self.began;                     }-                } else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {-                    self.push(end, ev.pressure);-                } else if self.snaps()-                    && !self.smoothing-                    && self.points.len() > 1-                    && let Some(last) = self.points.last_mut()-                {-                    // The last step was where the button went up: it moves where that snaps.-                    *last = end;+                } else {+                    // Was the last step away from where the button went up (before the samples bend)?+                    let away = !self.points.is_empty() && self.last_raw.distance(p) >= cx.tol(1.0);+                    // The samples before a snapped end bend to it too. Near both ends (a short stroke,+                    // or one whose ends snap together) a sample takes a share of each move that adds up+                    // to at most the whole, so none overshoots past either end (#3013 review).+                    // A smoothing pass (Alt, which may come mid-drag) works on where the hand went.+                    // Only the Pencil's ends snap; the other tools' points stay as they were placed+                    // (the Blob Brush's Shift moves earlier ones onto its line).+                    if self.snaps() {+                        let (end_shift, (from, start_shift)) =+                            if self.smoothing { (Vec2::ZERO, (p, Vec2::ZERO)) } else { (end - p, self.start_shift) };+                        for (q, r) in self.points.iter_mut().zip(&self.raw).skip(1) {+                            let (mut ws, mut we) = (weight(*r, from, start_shift), weight(*r, p, end_shift));+                            if ws + we > 1.0 {+                                (ws, we) = (ws / (ws + we), we / (ws + we));+                            }+                            *q = *r + start_shift * ws + end_shift * we;+                        }+                        // A smoothing pass that began after a snapped press starts where the press was.+                        if self.smoothing+                            && let (Some(q), Some(r)) = (self.points.first_mut(), self.raw.first())+                        {+                            *q = *r;+                        }+                    }+                    if away {+                        self.push(end, ev.pressure);+                    } else if self.snaps()+                        && !self.smoothing+                        && self.points.len() > 1+                        && let Some(last) = self.points.last_mut()+                    {+                        // The last step was where the button went up: it moves where that snaps.+                        *last = end;+                    }                 }                 let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) };                 self.active = false;@@ -551,6 +619,75 @@ mod tests {         assert_eq!(v["points"][0], json!([102.0, 98.0]));     } +    /// #2958: with Snap to Grid the Pencil's ends still land on grid points, but the stroke bends+    /// to them over a few times the distance they moved instead of jumping there: no hook (the+    /// samples keep running forward), and samples away from the ends stay where the hand went.+    #[test]+    fn pencil_strokes_ease_into_ends_snapped_to_the_grid() {+        let (d, _) = doc_with_rect();+        let s = Selection::default();+        let p = paint();+        let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };+        let step = cx.grid_step();+        // A straight stroke sampled every 2 pt, both ends between grid points.+        let (x0, x1, y) = (step * 10.0 + step * 0.45, step * 30.0 + step * 0.45, step * 10.0 + step * 0.4);+        let pts: Vec<(f64, f64)> = (0..).map(|i| x0 + 2.0 * f64::from(i)).take_while(|x| *x <= x1).map(|x| (x, y)).collect();+        let mut t = GestureTool::new("pencil");+        let acts = drag(&mut t, &cx, &pts, Mods::default());+        let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };+        let out: Vec<(f64, f64)> = v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect();+        let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;+        let (first, last) = (out[0], out[out.len() - 1]);+        assert!(on_grid(first.0) && on_grid(first.1) && on_grid(last.0) && on_grid(last.1), "ends on the grid: {first:?} {last:?}");+        assert!(out.windows(2).all(|w| w[1].0 > w[0].0), "no hook: the stroke keeps running forward {out:?}");+        // Halfway along, the hand's points as drawn.+        let mid = out[out.len() / 2];+        assert!((mid.1 - y).abs() < 1e-9, "{mid:?}");+    }++    /// #3013 review: a stroke shorter than the start's easing has its last sample moved by it; a+    /// release where that sample was drawn still adds no step back to the hand's position.+    #[test]+    fn a_short_eased_stroke_ends_without_a_step_back() {+        let (mut d, _) = doc_with_rect();+        d.grid = vectorcraft_doc::GridPrefs { spacing: 100.0, subdivisions: 1 };+        let s = Selection::default();+        let p = paint();+        let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };+        let mut t = GestureTool::new("pencil");+        // Pressed at x 51 (snaps to 100), dragged and released at 200, on a gridline.+        let acts = drag(&mut t, &cx, &[(51.0, 100.0), (200.0, 100.0), (200.0, 100.0)], Mods::default());+        let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };+        let xs: Vec<f64> = v["points"].as_array().unwrap().iter().map(|q| q[0].as_f64().unwrap()).collect();+        assert!(xs.windows(2).all(|w| w[1] > w[0]), "no step back: {xs:?}");+        assert_eq!((xs.first().copied(), xs.last().copied()), (Some(100.0), Some(200.0)), "{xs:?}");+    }++    /// #3013 review: a short stroke whose start and end snap to the same grid point keeps every+    /// sample, and none is moved by both snaps at once: each moves by at most the larger of them.+    #[test]+    fn a_stroke_whose_ends_snap_together_keeps_its_samples_without_overshoot() {+        let (mut d, _) = doc_with_rect();+        d.grid = vectorcraft_doc::GridPrefs { spacing: 100.0, subdivisions: 1 };+        let s = Selection::default();+        let p = paint();+        let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };+        // A small loop around (106, 106): both ends snap to the grid point (100, 100).+        let raw: Vec<(f64, f64)> =+            (0..=24).map(|i| f64::from(i) * std::f64::consts::TAU / 24.0).map(|a| (106.0 + 4.0 * a.cos(), 106.0 + 4.0 * a.sin())).collect();+        let mut t = GestureTool::new("pencil");+        let acts = drag(&mut t, &cx, &raw, Mods::default());+        let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };+        let out: Vec<(f64, f64)> = v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect();+        assert!(out.len() >= raw.len() - 1, "samples kept: {} of {}", out.len(), raw.len());+        assert_eq!(out[0], (100.0, 100.0));+        // Both ends moved from the same place to (100, 100): no sample moves further than that.+        let most = (raw[0].0 - 100.0).hypot(raw[0].1 - 100.0) + 1e-9;+        for (q, r) in out.iter().zip(&raw).skip(1) {+            assert!((q.0 - r.0).hypot(q.1 - r.1) <= most, "{q:?} from {r:?}");+        }+    }+     /// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and     /// the Pencil send plain points.     #[test]@@ -795,6 +932,28 @@ mod tests {         assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}");     } +    /// A Shift line with Snap to Grid starts on the grid and stays a two-point line from there,+    /// however long the drag: the snapped ends' easing (#2958) is for freehand strokes only.+    #[test]+    fn a_shift_line_with_snap_to_grid_stays_straight_from_the_grid() {+        let (mut d, _) = doc_with_rect();+        d.grid = vectorcraft_doc::GridPrefs { spacing: 100.0, subdivisions: 1 };+        let s = Selection::default();+        let p = paint();+        let mut cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };+        cx.smart_guides = false;+        let shift = Mods { shift: true, ..Default::default() };+        let mut t = GestureTool::new("pencil");+        let acts = drag_held(&mut t, &cx, &[(205.0, 196.0), (230.0, 197.0), (260.0, 199.0), (330.0, 203.0)], shift);+        let last = acts.last().unwrap();+        let Some(Action::Preview(_, v)) = last.first() else { panic!("{acts:?}") };+        let pts = v["points"].as_array().unwrap();+        assert_eq!((pts.len(), &pts[0]), (2, &json!([200.0, 200.0])), "{v}");+        // Horizontal, as long as the drag from the snapped press.+        let end = (pts[1][0].as_f64().unwrap(), pts[1][1].as_f64().unwrap());+        assert!((end.1 - 200.0).abs() < 1e-9 && (end.0 - 200.0 - 130f64.hypot(3.0)).abs() < 1e-9, "{end:?}");+    }+     /// #3799: a Shift line started on an end of a selected open path continues the path from that     /// end, constrained from the end itself.     #[test]@@ -975,6 +1134,29 @@ mod tests {         assert_eq!(b.options()["size"], 11.0);     } +    /// #3013 review: Alt pressed after a press whose start snapped to the grid turns the stroke into+    /// a smoothing pass that starts where the press was, not at the snapped point.+    #[test]+    fn a_smoothing_pass_after_a_snapped_press_starts_at_the_press() {+        let (mut d, id) = doc_with_rect();+        d.grid = vectorcraft_doc::GridPrefs { spacing: 100.0, subdivisions: 1 };+        let mut s = Selection::default();+        s.set([id]);+        let p = paint();+        let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };+        let mut t = GestureTool::new("pencil");+        t.set_option("altSmooth", &json!(true));+        let alt = Mods { alt: true, ..Default::default() };+        t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 51.0, 151.0));+        for x in [150.0, 250.0] {+            t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, 151.0).with_mods(alt));+        }+        let last = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 151.0).with_mods(alt));+        let Some(Action::Exec(c, v)) = last.first() else { panic!("{last:?}") };+        assert_eq!(c, "path.smoothRegion");+        assert_eq!(v["points"][0], json!([51.0, 151.0]), "{v}");+    }+     #[test]     fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() {         let (d, id) = doc_with_rect();