Smooth tool: each drag smooths the path under it, and repeated drags keep smoothing

Part of #2224.

A Smooth tool drag only refitted the anchors in its reach within
Fidelity, so a wobble wider than Fidelity stayed, and a second drag over
the same place changed nothing. Repeated refits also drifted away from
the path, and a drag over a whole closed path left a corner at its first
anchor.

- path.smoothRegion now smooths the stretch in reach along its length
  with a Gaussian whose spread is a third of the reach, then refits it
  within Fidelity. The run's outer end points and handles stay fixed.
- Each refit stays within twice the distance the smoothing moved the
  path (at least a tenth of Fidelity), so repeated drags do not drift
  (SimplifyOptions::smooth_moved_only).
- A whole closed path is smoothed across its start as a loop, so a
  bump at the first anchor is smoothed like any other and no corner is
  left. Object > Path > Smooth smooths a closed path across its start
  the same way.
This commit is contained in:
dotCooCoo
2026-11-13 04:44:12 -08:00
parent 1dbdbce91f
commit 349034dda2
5 changed files with 304 additions and 23 deletions
+28 -17
View File
@@ -8,10 +8,22 @@ use super::{
// ---------- Smooth ----------
/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles.
fn refit_run(anchors: &[Anchor], tol: f64) -> Vec<Anchor> {
/// The spread (points) of the Gaussian the Smooth tool smooths a path along its length with before
/// refitting it, as a share of the tool's reach: a third, so each point is averaged with the stretch
/// of path within the reach on either side of it. VectorCraft's own value.
const SMOOTH_SPREAD: f64 = 1.0 / 3.0;
/// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of
/// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the
/// smoothing moved it, keeping a corner only where the path turns back on itself.
fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions {
SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true }
}
/// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles.
fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec<Anchor> {
let piece = PathData::single(SubPath::new(anchors.to_vec(), false));
let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() });
let fitted = po::simplify_with(&piece, &refit_options(tol, sigma));
let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() };
let (f, l) = (anchors[0], anchors[anchors.len() - 1]);
let last = out.len() - 1;
@@ -25,7 +37,7 @@ fn refit_run(anchors: &[Anchor], tol: f64) -> Vec<Anchor> {
out
}
fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option<SubPath> {
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();
if n < 3 || !marked.iter().any(|m| *m) {
@@ -33,11 +45,8 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
}
if sp.closed {
let Some(k) = marked.iter().position(|m| !m) else {
// Everything is in range: smooth the whole closed path.
let f = po::simplify_with(
&PathData::single(sp.clone()),
&SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, ..Default::default() },
);
// 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();
};
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
@@ -48,14 +57,14 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
open.anchors.push(open.anchors[0]);
m.push(false);
open.closed = false;
let mut res = smooth_open(&open.anchors, &m, near, tol);
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma);
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false))
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false))
}
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
@@ -84,10 +93,11 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez
(hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0)))
}
/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments
/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under
/// it, and the rest of a long segment next to it stays as it was (#1512).
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec<Anchor> {
/// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted
/// within `tol`, together with the stretch of the segments leading into the run that is in the
/// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next
/// to it stays as it was (#1512).
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec<Anchor> {
let n = anchors.len();
let mut runs = vec![];
let mut i = 0;
@@ -127,7 +137,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
head = Some(Anchor { h_out: far.p1, ..*outer });
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
}
let new = refit_run(&piece, tol);
let new = refit_run(&piece, tol, sigma);
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
}
out
@@ -138,6 +148,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points_param(p, "points", C)?;
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
let sigma = radius * SMOOTH_SPREAD;
let st = s.doc()?;
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
let mut plan = vec![];
@@ -148,7 +159,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
let subs: Vec<SubPath> = pd
.subpaths
.iter()
.map(|sp| match smooth_subpath(sp, &near, tol) {
.map(|sp| match smooth_subpath(sp, &near, tol, sigma) {
Some(n) if n != *sp && n.anchors.len() >= 2 => {
changed = true;
n
+1 -1
View File
@@ -1228,7 +1228,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
Some(l) => po::simplify_level_tolerance(l),
None => given_tolerance.unwrap_or(1.0),
};
let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, smooth: 0.0 };
let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, ..Default::default() };
// A path with segments direct-selected simplifies only their runs, and keeps those runs (and the
// other anchors it had selected) direct-selected.
let roots = selected_roots(s)?;
+163
View File
@@ -1390,6 +1390,169 @@ fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() {
}
}
/// A selected path through `anchors` (x, y, and the in and out handles when given).
fn smooth_test_path(s: &mut Session, anchors: &[serde_json::Value], closed: bool) -> NodeId {
let r = s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
id
}
/// `path.smoothRegion` along `drag` with the Smooth tool's reach (12 pt at 100%) and default Fidelity.
fn smooth_pass(s: &mut Session, drag: &[Point]) {
let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect();
s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 2.5})).unwrap();
}
/// Points 1/32 of a segment apart along every segment of `p`.
fn smooth_test_samples(p: &PathData) -> Vec<Point> {
use kurbo::ParamCurve;
let mut v = vec![];
for sp in &p.subpaths {
for i in 0..sp.segment_count() {
let c = sp.segment(i);
v.extend((0..32).map(|k| c.eval(f64::from(k) / 32.0)));
}
}
v
}
/// How much `p` turns along its length, in radians: a wobbly path turns much more than a smooth one.
fn total_turning(p: &PathData) -> f64 {
let pts = smooth_test_samples(p);
pts.windows(3)
.map(|w| (w[1] - w[0], w[2] - w[1]))
.filter(|(u, v)| u.hypot() > 1e-9 && v.hypot() > 1e-9)
.map(|(u, v)| u.cross(v).atan2(u.dot(v)).abs())
.sum()
}
/// Deterministic noise in -1..1.
fn smooth_noise(seed: &mut u64) -> f64 {
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(*seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
/// A drag along y = `y` from x = `x0` to `x1`, one point every 5 pt.
fn smooth_drag_along(y: f64, x0: f64, x1: f64) -> Vec<Point> {
let n = ((x1 - x0) / 5.0).ceil() as usize;
(0..=n).map(|i| Point::new(x0 + (x1 - x0) * i as f64 / n as f64, y)).collect()
}
/// Each drag of the Smooth tool smooths the path under it, and a second drag over the same place
/// smooths it further, as the help page describes
/// (https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html): a curve that wobbles
/// 4 pt either side of its line every 12 pt, with an anchor at each crest, wobbles less after each drag.
#[test]
fn each_smooth_drag_smooths_a_wobble_larger_than_fidelity_further() {
let mut s = session();
let anchors: Vec<_> = (0..=20)
.map(|k| {
let (x, y) = (100.0 + 12.0 * f64::from(k), if k % 2 == 0 { 196.0 } else { 204.0 });
json!({"x": x, "y": y, "in": [x - 4.4, y], "out": [x + 4.4, y]})
})
.collect();
let id = smooth_test_path(&mut s, &anchors, false);
let drag = smooth_drag_along(200.0, 90.0, 350.0);
let wobble =
|p: &PathData| smooth_test_samples(p).iter().filter(|q| (130.0..=310.0).contains(&q.x)).map(|q| (q.y - 200.0).abs()).fold(0.0, f64::max);
let mut before = path(&s, id);
for pass in 1..=2 {
smooth_pass(&mut s, &drag);
let after = path(&s, id);
assert_ne!(after, before, "pass {pass} changes the path");
let (t0, t1) = (total_turning(&before), total_turning(&after));
assert!(t1 < 0.9 * t0, "pass {pass}: turning {t0} -> {t1}");
let (w0, w1) = (wobble(&before), wobble(&after));
assert!(w1 < 0.8 * w0, "pass {pass}: wobble {w0} -> {w1}");
let (a, b) = (before.subpaths[0].anchors[0].p, after.subpaths[0].anchors[0].p);
assert_eq!(a, b, "pass {pass}: the path keeps its start");
before = after;
}
}
/// Smoothing a line with up to 1 pt of noise again and again, over all of it or over its middle,
/// keeps it close to the line under the noise: each refit stays near the path it smooths.
#[test]
fn repeated_smooth_drags_stay_close_to_the_line_under_the_noise() {
let off = |p: &PathData| {
let d: Vec<f64> = smooth_test_samples(p).iter().map(|q| (q.y - 200.0).abs()).collect();
(d.iter().sum::<f64>() / d.len() as f64, d.iter().copied().fold(0.0, f64::max))
};
for (x0, x1) in [(90.0, 410.0), (150.0, 350.0)] {
let mut s = session();
let mut seed = 7u64;
let anchors: Vec<_> = (0..=60).map(|i| json!({"x": 100.0 + 5.0 * f64::from(i), "y": 200.0 + smooth_noise(&mut seed)})).collect();
let id = smooth_test_path(&mut s, &anchors, false);
let drag = smooth_drag_along(200.0, x0, x1);
let (mean0, _) = off(&path(&s, id));
for pass in 1..=5 {
smooth_pass(&mut s, &drag);
let (mean, max) = off(&path(&s, id));
assert!(
mean <= 1.1 * mean0 && max <= 1.5,
"drag {x0}-{x1}, pass {pass}: {mean} on average and {max} at most from the line, {mean0} before"
);
}
}
}
/// A drag over the whole of a noisy closed path leaves no corner where the path starts.
#[test]
fn smoothing_a_whole_closed_path_leaves_no_kink_at_its_start() {
let mut s = session();
let mut seed = 5u64;
let (c, r) = (Point::new(300.0, 300.0), 80.0);
let anchors: Vec<_> = (0..64)
.map(|i| {
let a = f64::from(i) / 64.0 * std::f64::consts::TAU;
let rr = r + 2.5 * smooth_noise(&mut seed);
json!({"x": c.x + rr * a.cos(), "y": c.y + rr * a.sin()})
})
.collect();
let id = smooth_test_path(&mut s, &anchors, true);
let ring: Vec<Point> = (0..=128).map(|i| f64::from(i) / 128.0 * std::f64::consts::TAU).map(|a| c + r * kurbo::Vec2::from_angle(a)).collect();
smooth_pass(&mut s, &ring);
let p = path(&s, id);
let sp = &p.subpaths[0];
assert!(sp.closed && sp.anchors.len() < 64, "{} anchors", sp.anchors.len());
let (last, first) = (sp.segment(sp.segment_count() - 1), sp.segment(0));
let tin = if last.p3.distance(last.p2) > 1e-9 { last.p3 - last.p2 } else { last.p3 - last.p1 };
let tout = if first.p1.distance(first.p0) > 1e-9 { first.p1 - first.p0 } else { first.p2 - first.p0 };
let kink = tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees();
assert!(kink <= 1.0, "a {kink}° corner at the start");
}
/// A drag over the whole of a closed path smooths a bump at its first anchor as much as the same
/// bump on the far side of it.
#[test]
fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() {
let mut s = session();
let (c, r) = (Point::new(300.0, 300.0), 80.0);
let anchors: Vec<_> = (0..64)
.map(|i| {
let a = f64::from(i) / 64.0 * std::f64::consts::TAU;
let rr = if i % 32 == 0 { r + 6.0 } else { r };
json!({"x": c.x + rr * a.cos(), "y": c.y + rr * a.sin()})
})
.collect();
let id = smooth_test_path(&mut s, &anchors, true);
let ring: Vec<Point> = (0..=128).map(|i| f64::from(i) / 128.0 * std::f64::consts::TAU).map(|a| c + r * kurbo::Vec2::from_angle(a)).collect();
// A Fidelity of 0.5 pt, so the refit follows the smoothed path closely on both sides.
let pts: Vec<_> = ring.iter().map(|p| json!([p.x, p.y])).collect();
s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 0.5})).unwrap();
let p = path(&s, id);
use std::f64::consts::{PI, TAU};
// How far the path rises above the circle within 0.3 radians of the angle `near`.
let bump = |near: f64| {
let off = |q: &Point| (((q.y - c.y).atan2(q.x - c.x) - near + PI).rem_euclid(TAU) - PI).abs();
smooth_test_samples(&p).iter().filter(|q| off(q) < 0.3).map(|q| q.distance(c) - r).fold(0.0, f64::max)
};
let (start, far) = (bump(0.0), bump(PI));
assert!(far < 5.0, "the far bump stays {far} pt high");
assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt");
}
#[test]
fn join_tool_joins_two_lines() {
let mut s = session();
+111 -5
View File
@@ -22,14 +22,23 @@ pub struct SimplifyOptions {
/// How far (points, the spread of a Gaussian along the path) the path is smoothed before it is
/// refitted, so that bumps and jags come out as one curve ([`smooth_level`]); 0 for none.
pub smooth: f64,
/// With [`smooth`](Self::smooth), refit each stretch fitted as one piece (between corners) within
/// twice the farthest the smoothing moved any of its points when that is less than `tolerance`, and
/// never within less than a tenth of `tolerance`. A stretch that smoothing barely moves anywhere
/// keeps its shape. The limit is the same along the whole stretch, so where smoothing moves some of
/// its points far, all of it is refitted within up to `tolerance`, as without this option.
pub smooth_moved_only: bool,
}
impl Default for SimplifyOptions {
fn default() -> Self {
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0 }
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0, smooth_moved_only: false }
}
}
/// The least share of the tolerance that [`SimplifyOptions::smooth_moved_only`] refits within.
const SMOOTH_MOVED_FLOOR: f64 = 0.1;
/// Simplify with the default corner threshold (30°).
pub fn simplify(path: &PathData, tolerance: f64) -> PathData {
simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() })
@@ -310,16 +319,27 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
}
/// Adds the refit of `part`, consecutive segments with no corner between them and the steps to
/// sample each one with, to `result`. `ends` is the direction it leaves and arrives along, when not
/// those of its first and last segments.
fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Option<Vec2>, result: &mut Vec<Seg>) {
/// sample each one with, to `result`. `ends`, given only when `part` is a whole closed loop, is the
/// direction it leaves and arrives along; when the loop is smoothed, the smoothing runs across its
/// start and the direction comes from the smoothed samples there.
fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option<Vec2>, result: &mut Vec<Seg>) {
let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return };
let mut pts = Vec::new();
for (k, (s, steps)) in part.iter().enumerate() {
sample(&s.c, *steps, &mut pts, k == 0);
}
let mut tol = tol;
if opts.smooth > 0.0 {
pts = smooth_points(&pts, opts.smooth);
let smoothed = if ends.is_some() { smooth_loop_points(&pts, opts.smooth) } else { smooth_points(&pts, opts.smooth) };
if ends.is_some()
&& let (Some(&next), Some(&prev)) = (smoothed.get(1), smoothed.len().checked_sub(2).and_then(|i| smoothed.get(i)))
{
ends = unit(next - prev).or(ends);
}
if opts.smooth_moved_only {
tol = tol.min((2.0 * smoothing_moved(&pts, &smoothed)).max(tol * SMOOTH_MOVED_FLOOR));
}
pts = smoothed;
}
if opts.straight_lines {
let keep = rdp(&pts, tol * (1.0 - SAMPLE_GAP));
@@ -346,6 +366,66 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec<Point> {
smooth_points_counted(pts, sigma, &mut 0)
}
/// [`smooth_points`] for the samples `pts` of a whole closed loop, whose last sample is its first:
/// the samples at its start are smoothed like the others, and the first and last ones move together.
/// The loop is smoothed twice as an open run of the same samples, once from its start and once from
/// the sample nearest halfway round by length, where [`smooth_points`] keeps each run's ends in place.
/// Each sample takes a blend of the two that runs smoothly round the loop, with weights
/// `(1 ± cos θ) / 2`: `θ` goes from 0 at the start to π at the halfway sample and on to 2π at the end,
/// in proportion to length, so the start takes only the second run and the halfway sample only the
/// first, and no sample takes any of a run's fixed ends.
fn smooth_loop_points(pts: &[Point], sigma: f64) -> Vec<Point> {
let Some(m) = pts.len().checked_sub(1).filter(|&m| m >= 3) else { return smooth_points(pts, sigma) };
let mut along = Vec::with_capacity(pts.len());
let mut total = 0.0;
for (i, p) in pts.iter().enumerate() {
if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) {
total += prev.distance(*p);
}
along.push(total);
}
if !(total.is_finite() && total > 0.0) {
return smooth_points(pts, sigma);
}
let half = (1..m).min_by(|&a, &b| (along[a] - total / 2.0).abs().total_cmp(&(along[b] - total / 2.0).abs())).unwrap_or(m / 2);
let mid = along[half];
if !(mid > 0.0 && mid < total) {
return smooth_points(pts, sigma);
}
let ring = &pts[..m];
let turned: Vec<Point> = ring[half..].iter().chain(&ring[..=half]).copied().collect();
let from_half = smooth_points(&turned, sigma);
let mut out = smooth_points(pts, sigma);
for (i, (p, &s)) in out.iter_mut().zip(&along).enumerate() {
// Sample `i` of the loop is sample `i - half` of `turned`, round the loop.
let Some(&q) = from_half.get((i + m - half) % m) else { continue };
let theta = if s <= mid { s / mid } else { 1.0 + (s - mid) / (total - mid) } * std::f64::consts::PI;
let w = 0.5 * (1.0 + theta.cos());
*p = p.lerp(q, w);
}
if let Some(&start) = out.first()
&& let Some(end) = out.last_mut()
{
*end = start;
}
out
}
/// How far [`smooth_points`] moved the samples `pts` to `smoothed` (the same number of points, in
/// order) across the path: the largest distance from a smoothed point to the lines between the
/// samples up to two on either side of the one it came from.
fn smoothing_moved(pts: &[Point], smoothed: &[Point]) -> f64 {
smoothed
.iter()
.enumerate()
.map(|(i, &q)| {
let own = pts.get(i).map_or(f64::INFINITY, |p| p.distance(q));
let (lo, hi) = (i.saturating_sub(2), (i + 2).min(pts.len().saturating_sub(1)));
(lo..hi).filter_map(|j| Some(Line::new(*pts.get(j)?, *pts.get(j + 1)?).nearest(q, 1e-9).distance_sq.sqrt())).fold(own, f64::min)
})
.fold(0.0, f64::max)
}
/// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups
/// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one
/// by one.
@@ -678,6 +758,7 @@ pub fn smooth_options(path: &PathData, level: f64) -> SimplifyOptions {
corner_angle_deg: SMOOTH_CORNER_DEG,
straight_lines: false,
smooth: size * 0.05 * level / 100.0,
smooth_moved_only: false,
}
}
@@ -1182,6 +1263,31 @@ mod tests {
assert!(work <= 400 * pts.len(), "{work} steps for {} samples", pts.len());
}
/// A spike on a closed loop whose samples are ten times denser on one half than on the other is
/// smoothed out wherever it is, including at the sample halfway through the list, which is only
/// about a quarter of the way round by length.
#[test]
fn loop_smoothing_smooths_a_spike_on_unevenly_sampled_loops() {
let c = Point::new(300.0, 300.0);
let on = |a: f64, r: f64| c + r * Vec2::from_angle(a);
let mut pts: Vec<Point> = (0..300).map(|i| on(f64::from(i) / 300.0 * std::f64::consts::PI, 100.0)).collect();
pts.extend((0..30).map(|i| on(std::f64::consts::PI * (1.0 + f64::from(i) / 30.0), 100.0)));
let m = pts.len();
pts.push(pts[0]);
for spike in [m / 2, 0, 100] {
let mut p = pts.clone();
p[spike] = on(std::f64::consts::PI * spike as f64 / 300.0, 105.0);
if spike == 0 {
p[m] = p[0];
}
let out = smooth_loop_points(&p, 10.0);
assert_eq!(out.len(), p.len());
assert_eq!(out[0], out[m], "spike at {spike}: the loop stays closed");
let left = out[spike].distance(c) - 100.0;
assert!(left < 1.0, "spike at {spike}: {left} pt of 5 pt left");
}
}
#[test]
fn the_samples_of_a_flat_loop_and_its_line_segments_stay_within_the_result_limit() {
for (curved, kept) in [(1, 0), (32, 1), (40, 1_000_000), (1_000, MAX_RUN_RESULT - 1_000), (3, MAX_RUN_RESULT - 7)] {
+1
View File
@@ -1086,6 +1086,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Live curve fitting in the Pencil Tool Options is on by default; while it is off, the path shown while a stroke is drawn joins the stroke's points with straight segments, and the release fits the curve, as [Pencil Tool Options](https://web.archive.org/web/20261002104400/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/pencil-tool-options.html) describes (`path.freehand` `fit`).
In the Pencil and Paintbrush Tool Options, Edit selected paths within 0 px turns editing off, so a stroke that starts exactly on an end of a selected open path draws a new path; Close paths when ends are within 0 px turns closing off, so a stroke that ends exactly where it started stays open.
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.
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.
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`).