Smooth tool: smoothing over a closed path's first anchor keeps its start

When a drag reached the first anchor of a closed path, the smoothed path
started at the first anchor past the drag, and a drag over the whole
path could start it at a corner the refit kept. Dashes and type on the
path then started somewhere else.

The smoothed path now starts at its point nearest where the first anchor
was: at an anchor there, or at a new anchor split into the segment
there, which leaves the outline as it is. Of points equally near, the
one earliest along the path before smoothing, from its first anchor,
is used. A drag that does not reach the first anchor leaves it first,
as before.

Part of #2224.
This commit is contained in:
dotCooCoo
2026-11-13 11:49:35 -08:00
parent 08b6cfd3e8
commit fb476ca64d
3 changed files with 266 additions and 2 deletions
+174 -2
View File
@@ -37,6 +37,87 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec<Anchor> {
out
}
/// Distance (points) from an anchor within which a smoothed closed path starts at that anchor
/// instead of at a new one split in beside it.
const START_SNAP: f64 = 1e-3;
/// Distance (points) within which two points of a smoothed closed path count as equally near its
/// old start.
const START_TIE: f64 = 1e-6;
/// Where `p` lies along the closed `orig` from its first anchor: the segment index and parameter of
/// the point of `orig` nearest `p`, the first such point in its order. Sorts after every finite place
/// when there is none.
fn place_along(orig: &SubPath, p: Point) -> (usize, f64) {
let mut best: Option<(f64, usize, f64)> = None;
for j in 0..orig.anchors.len() {
let hit = orig.segment(j).nearest(p, 1e-9);
if hit.distance_sq.is_finite() && hit.t.is_finite() && best.is_none_or(|(d, ..)| hit.distance_sq < d) {
best = Some((hit.distance_sq, j, hit.t));
}
}
best.map_or((usize::MAX, f64::INFINITY), |(_, j, t)| (j, t))
}
/// The closed `sp` started at the point on it nearest `start`: at the anchor there, or at a new
/// anchor split into the segment there, which leaves the outline as it is. Of points equally near
/// `start`, the one earliest along `orig` (the path before smoothing) from its first anchor is used,
/// then the first in `sp`'s order. An open `sp`, or one with no segment or no finite nearest point,
/// is returned as it is.
fn start_nearest(mut sp: SubPath, start: Point, orig: &SubPath) -> SubPath {
let n = sp.anchors.len();
if !sp.closed || n < 2 {
return sp;
}
// (distance, place along `orig` once a tie needs it, segment of `sp`, parameter)
type Best = (f64, Option<(usize, f64)>, usize, f64);
let mut best: Option<Best> = None;
for i in 0..n {
let c = sp.segment(i);
let hit = c.nearest(start, 1e-9);
if !hit.distance_sq.is_finite() || !hit.t.is_finite() {
continue;
}
let d = hit.distance_sq.sqrt();
match &mut best {
None => best = Some((d, None, i, hit.t)),
Some((bd, bplace, bi, bt)) if (d - *bd).abs() <= START_TIE => {
let held = *bplace.get_or_insert_with(|| place_along(orig, sp.segment(*bi).eval(*bt)));
let place = place_along(orig, c.eval(hit.t));
if place.0 < held.0 || (place.0 == held.0 && place.1 < held.1) {
best = Some((d, Some(place), i, hit.t));
}
}
Some((bd, ..)) if d < *bd => best = Some((d, None, i, hit.t)),
Some(_) => {}
}
}
let Some((_, _, i, t)) = best else { return sp };
let c = sp.segment(i);
let at = c.eval(t);
let first = if at.distance(c.p0) <= START_SNAP {
i
} else if at.distance(c.p3) <= START_SNAP {
(i + 1) % n
} else {
let mid = if sp.segment_is_line(i) {
Anchor::corner(at)
} else {
let (l, r) = (c.subsegment(0.0..t), c.subsegment(t..1.0));
sp.anchors[i].h_out = l.p1;
sp.anchors[(i + 1) % n].h_in = r.p2;
Anchor::with_handles(at, l.p2, r.p1)
};
sp.anchors.insert(i + 1, mid);
i + 1
};
sp.anchors.rotate_left(first);
sp
}
/// `sp` with the anchors in the drag's reach (`near`) smoothed and refitted, `None` when none are
/// in reach. A closed path whose first anchor is in reach starts at the point of the refitted path
/// nearest that anchor's place (VectorCraft's own rule).
fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option<SubPath> {
let n = sp.anchors.len();
let marked: Vec<bool> = sp.anchors.iter().map(|a| near(a.p)).collect();
@@ -44,10 +125,11 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f
return None;
}
if sp.closed {
let start = sp.anchors[0].p;
let Some(k) = marked.iter().position(|m| !m) else {
// Everything is in range: smooth the whole closed path, across its start too.
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma));
return f.subpaths.into_iter().next();
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
};
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
let mut open = sp.clone();
@@ -62,7 +144,9 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
// With k = 0 the first anchor is out of reach and still starts the result.
let res = SubPath::new(res, true);
return Some(if k == 0 { res } else { start_nearest(res, start, sp) });
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false))
}
@@ -414,3 +498,91 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "id": ida.0 }))
}
#[cfg(test)]
mod tests {
use super::*;
/// A closed ring of four smooth anchors round (100, 100) with radius 50.
fn ring() -> SubPath {
let k = 0.5523 * 50.0;
let a = |x: f64, y: f64, dx: f64, dy: f64| Anchor::with_handles(Point::new(x, y), Point::new(x - dx, y - dy), Point::new(x + dx, y + dy));
SubPath::new(vec![a(150.0, 100.0, 0.0, k), a(100.0, 150.0, -k, 0.0), a(50.0, 100.0, 0.0, -k), a(100.0, 50.0, k, 0.0)], true)
}
/// A start between two anchors of a curve splits that curve there into two that trace it, and the
/// new anchor comes first.
#[test]
fn start_nearest_splits_a_curve_where_it_passes_the_start() {
let sp = ring();
let old = sp.segment(0);
let start = old.eval(0.3) + (old.eval(0.3) - Point::new(100.0, 100.0)) * 0.1;
let out = start_nearest(sp.clone(), start, &sp);
assert!(out.closed && out.anchors.len() == 5, "{out:?}");
let t = old.nearest(start, 1e-9).t;
assert!(out.anchors[0].p.distance(old.eval(t)) < 1e-9, "{out:?}");
let (head, rest) = (out.segment(4), out.segment(0));
for k in 0..=8 {
let u = f64::from(k) / 8.0;
assert!(head.eval(u).distance(old.eval(t * u)) < 1e-9);
assert!(rest.eval(u).distance(old.eval(t + (1.0 - t) * u)) < 1e-9);
}
for i in 1..4 {
assert_eq!(out.segment(i), sp.segment(i), "segment {i} stays");
}
}
/// A start within [`START_SNAP`] of an anchor starts the ring at that anchor, with no anchor added.
#[test]
fn start_nearest_starts_at_an_anchor_near_the_start() {
let sp = ring();
let out = start_nearest(sp.clone(), Point::new(50.0, 100.0 + START_SNAP / 2.0), &sp);
let mut want = sp.anchors.clone();
want.rotate_left(2);
assert_eq!(out.anchors, want);
}
/// A start on a straight side of a triangle adds a corner point there without handles, and the
/// sides stay straight.
#[test]
fn start_nearest_splits_a_line_with_a_corner_point() {
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(50.0, 80.0)], true);
let out = start_nearest(sp.clone(), Point::new(40.0, -5.0), &sp);
let want: Vec<Point> = [(40.0, 0.0), (100.0, 0.0), (50.0, 80.0), (0.0, 0.0)].map(|(x, y)| Point::new(x, y)).to_vec();
assert!(out.anchors.len() == 4 && out.anchors.iter().zip(&want).all(|(a, w)| a.p.distance(*w) < 1e-9), "{out:?}");
assert!((0..4).all(|i| out.segment_is_line(i)), "{out:?}");
}
/// A ring whose last anchor lies on its first keeps its first anchor first, and gets no third
/// anchor at that place.
#[test]
fn start_nearest_keeps_the_first_of_two_anchors_on_one_place() {
let p = [(0.0, 0.0), (100.0, 0.0), (50.0, 80.0), (0.0, 0.0)].map(|(x, y)| Point::new(x, y));
let sp = SubPath::polyline(&p, true);
assert_eq!(start_nearest(sp.clone(), p[0], &sp), sp);
}
/// Of points equally near the start, the one earliest along the path before smoothing from its
/// first anchor comes first, whatever the smoothed path's order. The center of a square is equally
/// near the middle of each side. The square before smoothing starts at (-50, -50), so the middle
/// of its first side, (0, -50), comes first, though the smoothed copy starts at (50, 50).
#[test]
fn start_nearest_breaks_a_tie_by_the_order_before_smoothing() {
let p = [(-50.0, -50.0), (50.0, -50.0), (50.0, 50.0), (-50.0, 50.0)].map(|(x, y)| Point::new(x, y));
let orig = SubPath::polyline(&p, true);
let mut sp = orig.clone();
sp.anchors.rotate_left(2);
let out = start_nearest(sp, Point::new(0.0, 0.0), &orig);
assert!(out.closed && out.anchors.len() == 5, "{out:?}");
assert!(out.anchors[0].p.distance(Point::new(0.0, -50.0)) < 1e-9, "{out:?}");
assert!(out.anchors[1].p.distance(p[1]) < 1e-9, "{out:?}");
}
/// An open path, and a start that is not a number, leave the path as it is.
#[test]
fn start_nearest_leaves_an_open_path_or_a_start_without_a_place() {
let open = SubPath { closed: false, ..ring() };
assert_eq!(start_nearest(open.clone(), Point::new(100.0, 50.0), &ring()), open);
assert_eq!(start_nearest(ring(), Point::new(f64::NAN, 0.0), &ring()), ring());
}
}
+91
View File
@@ -1606,6 +1606,97 @@ fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() {
assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt");
}
/// Checks that the closed path `p` starts at its point nearest `old`, within `reach` of it: its first
/// anchor is no farther from `old` than any other point along the path, and its outline starts there.
fn assert_starts_nearest(p: &PathData, old: Point, reach: f64) {
let sp = &p.subpaths[0];
assert!(sp.closed, "the path stays closed");
let first = sp.anchors[0].p;
let nearest = smooth_test_samples(p).iter().map(|q| q.distance(old)).fold(f64::INFINITY, f64::min);
let d = first.distance(old);
assert!(d <= nearest + 1e-6 && d <= reach, "the path starts {d} pt from its old start, its nearest point is {nearest} pt away: {sp:?}");
let mut bp = kurbo::BezPath::new();
sp.to_bezpath_into(&mut bp);
assert_eq!(bp.elements().first(), Some(&kurbo::PathEl::MoveTo(first)));
}
/// A drag over a closed path's first anchor and the anchors either side of it smooths the bump there,
/// and the path starts at the point of the smoothed path nearest the old first anchor. The anchors out
/// of reach keep their places, and undo brings the path back.
#[test]
fn smoothing_over_a_closed_paths_first_anchor_keeps_its_start() {
let mut s = session();
let (c, r) = (Point::new(300.0, 300.0), 100.0);
let at = |deg: f64, rr: f64| c + rr * kurbo::Vec2::from_angle(deg.to_radians());
let anchors: Vec<_> = (0..12)
.map(|i| {
let q = at(30.0 * f64::from(i), if i == 0 { r + 8.0 } else { r });
json!({"x": q.x, "y": q.y})
})
.collect();
let id = smooth_test_path(&mut s, &anchors, true);
let before = path(&s, id);
let old = before.subpaths[0].anchors[0].p;
// Along the circle from -40° to 40°, over anchors 11, 0 and 1.
let drag: Vec<Point> = (-20..=20).map(|k| at(2.0 * f64::from(k), r)).collect();
smooth_pass(&mut s, &drag);
let after = path(&s, id);
assert_ne!(after, before, "the bump is smoothed");
assert_starts_nearest(&after, old, 12.0);
let kept = &after.subpaths[0].anchors;
for a in &before.subpaths[0].anchors[2..=10] {
assert!(kept.iter().any(|k| k.p == a.p), "{:?} is out of reach and stays: {kept:?}", a.p);
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), before);
}
/// A drag over the whole of a closed path with a spike that turns back on itself, which the refit
/// keeps as a corner, leaves the path starting at its point nearest the old first anchor.
#[test]
fn smoothing_a_whole_closed_path_with_a_spike_keeps_its_start() {
let mut s = session();
let mut seed = 5u64;
let (c, r) = (Point::new(300.0, 300.0), 80.0);
let at = |a: f64, rr: f64| c + rr * kurbo::Vec2::from_angle(a);
use std::f64::consts::{PI, TAU};
let mut pts: Vec<Point> = (0..64).map(|i| at(f64::from(i) / 64.0 * TAU, r + 2.5 * smooth_noise(&mut seed))).collect();
// Out from the anchor at 180° to a tip 40 pt beyond the circle and back.
pts.splice(33..33, [at(PI, r + 40.0), at(PI + 0.002, r)]);
let anchors: Vec<_> = pts.iter().map(|q| json!({"x": q.x, "y": q.y})).collect();
let id = smooth_test_path(&mut s, &anchors, true);
let before = path(&s, id);
let mut drag: Vec<Point> = (0..=128).map(|i| at(f64::from(i) / 128.0 * TAU, r)).collect();
drag.splice(65..65, (0..=16).chain((0..=16).rev()).map(|k| at(PI, r + 2.5 * f64::from(k))));
smooth_pass(&mut s, &drag);
let after = path(&s, id);
assert_ne!(after, before, "the path is smoothed");
assert_starts_nearest(&after, pts[0], 12.0);
}
/// A drag that reaches a closed path but not its first anchor leaves that anchor first, where it was.
#[test]
fn smoothing_a_closed_path_away_from_its_first_anchor_keeps_it_first() {
let mut s = session();
let (c, r) = (Point::new(300.0, 300.0), 100.0);
let at = |deg: f64, rr: f64| c + rr * kurbo::Vec2::from_angle(deg.to_radians());
let anchors: Vec<_> = (0..12)
.map(|i| {
let q = at(30.0 * f64::from(i), if i == 6 { r + 8.0 } else { r });
json!({"x": q.x, "y": q.y})
})
.collect();
let id = smooth_test_path(&mut s, &anchors, true);
let before = path(&s, id);
// Along the circle from 140° to 220°, over anchors 5, 6 and 7.
let drag: Vec<Point> = (70..=110).map(|k| at(2.0 * f64::from(k), r)).collect();
smooth_pass(&mut s, &drag);
let after = path(&s, id);
assert_ne!(after, before, "the bump is smoothed");
assert!(after.subpaths[0].closed);
assert_eq!(after.subpaths[0].anchors[0], before.subpaths[0].anchors[0]);
}
#[test]
fn join_tool_joins_two_lines() {
let mut s = session();
+1
View File
@@ -1118,6 +1118,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points.
`path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`).
Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way.
When a Smooth tool drag reaches the first anchor of a closed path, the smoothed path starts at its point nearest where that anchor was: at an anchor there, or at a new anchor split into the curve without changing its outline. Dashes and type on the path start from that point. A drag that does not reach the first anchor leaves it first. This is VectorCraft's own rule (engine tests `smoothing_over_a_closed_paths_first_anchor_keeps_its_start` and `smoothing_a_whole_closed_path_with_a_spike_keeps_its_start`).
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 on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`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`).