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