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
@@ -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();