Merge pull request #5995 from dotCooCoo/fix/simplify-long-paths

Simplify: fitted curves stay within the tolerance of the path, and serpentines simplify faster
This commit is contained in:
BFlat
2026-11-08 12:33:23 +08:00
committed by GitHub
5 changed files with 1009 additions and 60 deletions
+332 -37
View File
@@ -8,7 +8,7 @@ use vectorcraft_geom::path::measured_arclen;
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
use crate::boolean::{Seg, segs_to_subpath};
use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent};
use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, largest_second_difference, sample, start_tangent, unit};
/// Options for [`simplify_with`].
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -96,14 +96,66 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0);
segs.rotate_left(k);
}
let result = simplify_run(&segs, opts);
// A smoothed loop with no corner is fitted along one direction across its start, when no segment
// stays as it is and the fit has one part. A nearly straight first or last curve becomes a line
// along its chord, and `no_worse` can return the original subpath, so the start can keep a kink.
let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None };
let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg));
let result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across);
let s = segs_to_subpath(&result, sp.closed)?;
Some(if opts.straight_lines { s } else { no_worse(sp, s) })
}
/// How far a segment may be from the lines between its samples, as a share of the tolerance.
const SAMPLE_GAP: f64 = 0.005;
/// Most samples of one segment. A segment that needs more stays as it is.
const MAX_SEGMENT_SAMPLES: usize = 1 << 16;
/// Most samples of one fit, which limits the memory a fit holds. A run with more is fitted in parts,
/// with an anchor between them.
pub(crate) const MAX_FIT_SAMPLES: usize = 1 << 20;
/// Most samples of one run, which limits its time. The segments after the run reaches this stay as
/// they are.
const MAX_RUN_SAMPLES: usize = 1 << 24;
/// Most segments of the result of one run before its other segments stay as they are, which limits
/// the memory it holds. A fit under way can add up to [`MAX_FIT_SAMPLES`] more.
pub(crate) const MAX_RUN_RESULT: usize = 1 << 21;
/// How many evenly spaced steps along `c` keep it within [`SAMPLE_GAP`] of `tol` from the lines
/// between its samples. Over a step of `1 / m`, a curve stays within `0.75 * s / (m * m)` of the line
/// between its ends, where `s` is its largest second difference. The samples of a straight segment
/// are on its line, so their spacing follows its length and `tol`, up to 256.
pub(crate) fn sample_steps(c: &CubicBez, tol: f64) -> usize {
// Guarded: kurbo subdivides without end on huge finite coordinates.
let len = measured_arclen(&PathSeg::Cubic(*c), 1e-6);
let by_length = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
if is_straight(c, tol * SAMPLE_GAP * 0.5) {
return by_length;
}
by_length.max((0.75 * largest_second_difference(c) / (tol * SAMPLE_GAP)).sqrt().ceil() as usize)
}
/// How many evenly spaced straight lines keep `c` within `tol` of them.
fn line_steps(c: &CubicBez, tol: f64) -> usize {
(0.75 * largest_second_difference(c) / tol).sqrt().ceil() as usize
}
/// The refit of the consecutive segments `run`, split at its corners sharper than the threshold:
/// it starts and ends where `run` does, leaving the same way.
fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec<Seg> {
simplify_run_within(run, opts, MAX_RUN_SAMPLES, None)
}
/// [`simplify_run`] with at most `budget` samples. A segment that needs more samples than
/// [`MAX_SEGMENT_SAMPLES`] or than the budget has left, or that comes after [`MAX_RUN_RESULT`] segments
/// of result, stays as it is, and the segments on each side of it are refitted without it. With
/// `straight_lines` such a segment becomes [`line_steps`] lines, or as many as those limits allow when
/// that is fewer, and then it can pass the tolerance. `ends`, when the whole run is refitted as one
/// part, is the direction the refit leaves and arrives along.
pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budget: usize, ends: Option<Vec2>) -> Vec<Seg> {
let tol = opts.tolerance.max(1e-6);
let mut cuts: Vec<usize> = (1..run.len()).filter(|&i| turn_deg(&run[i - 1], &run[i]) > opts.corner_angle_deg).collect();
cuts.insert(0, 0);
@@ -111,39 +163,85 @@ fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec<Seg> {
let mut result: Vec<Seg> = Vec::new();
for w in cuts.windows(2) {
let piece = &run[w[0]..w[1]];
if piece.is_empty() {
continue;
}
let mut pts = Vec::new();
for (k, s) in piece.iter().enumerate() {
// Guarded: kurbo subdivides without end on huge finite coordinates.
let len = measured_arclen(&PathSeg::Cubic(s.c), 1e-6);
let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
sample(&s.c, m, &mut pts, k == 0);
}
if opts.smooth > 0.0 {
pts = smooth_points(&pts, opts.smooth);
}
if opts.straight_lines {
let keep = rdp(&pts, tol);
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
continue;
}
let t0 = start_tangent(&piece[0].c);
let t1 = end_tangent(&piece[piece.len() - 1].c);
for c in fit_cubics(&pts, t0, t1, tol) {
let line = is_straight(&c, tol * 0.02);
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
let mut part: Vec<(&Seg, usize)> = Vec::new();
let mut part_samples = 0;
for s in piece {
let steps = sample_steps(&s.c, tol);
if steps <= budget.min(MAX_SEGMENT_SAMPLES) && result.len() < MAX_RUN_RESULT {
if part_samples + steps > MAX_FIT_SAMPLES {
refit_part(&part, opts, tol, None, &mut result);
part.clear();
part_samples = 0;
}
budget -= steps;
part_samples += steps;
part.push((s, steps));
} else {
part_samples = 0;
refit_part(&part, opts, tol, None, &mut result);
part.clear();
if opts.straight_lines {
let room = MAX_RUN_RESULT.saturating_sub(result.len());
let lines = line_steps(&s.c, tol).min(budget.min(MAX_SEGMENT_SAMPLES)).min(room).max(1);
budget = budget.saturating_sub(lines);
let mut pts = Vec::new();
sample(&s.c, lines, &mut pts, true);
result.extend(pts.windows(2).map(|p| Seg::line(p[0], p[1])));
} else {
result.push(*s);
}
}
}
refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), &mut result);
}
result
}
/// 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>) {
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);
}
if opts.smooth > 0.0 {
pts = smooth_points(&pts, opts.smooth);
}
if opts.straight_lines {
let keep = rdp(&pts, tol * (1.0 - SAMPLE_GAP));
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
return;
}
// The fit may be `tol * 0.98` from the lines between the samples, those lines may be
// `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up
// to 1.5% of `tol`: 3/4 of how far its handles may be from the line.
let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d));
for c in fit_cubics(&pts, t0, t1, tol * 0.98) {
let line = is_straight(&c, tol * 0.02);
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
}
}
/// `pts` (a run's samples, in order) smoothed along the path: each point becomes the Gaussian-weighted
/// mean of the points around it, `sigma` points of arc length wide, the window narrowing towards the
/// ends so that the first and last points stay where they are. Most points looked at per point is
/// bounded by the samples within three spreads of it.
/// ends so that the first and last points stay where they are. Consecutive samples within
/// 1/[`SMOOTH_GROUP`] of a spread of each other along the path are weighed together, as their count at
/// their mean place, so the work per point is bounded by the groups within three spreads of it however
/// densely the path is sampled.
fn smooth_points(pts: &[Point], sigma: f64) -> Vec<Point> {
smooth_points_counted(pts, sigma, &mut 0)
}
/// 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.
const SMOOTH_GROUP: f64 = 16.0;
/// [`smooth_points`], adding the number of sample groups it looks at to `work`.
fn smooth_points_counted(pts: &[Point], sigma: f64, work: &mut usize) -> Vec<Point> {
let Some(&origin) = pts.first() else { return Vec::new() };
if pts.len() < 3 || !(sigma.is_finite() && sigma > 0.0) {
return pts.to_vec();
}
@@ -155,6 +253,7 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec<Point> {
}
s.push(total);
}
let groups = SampleGroups::new(pts, &s, origin);
let (mut lo, mut hi) = (0usize, 0usize);
pts.iter()
.zip(&s)
@@ -170,17 +269,99 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec<Point> {
while s.get(hi + 1).is_some_and(|&v| v <= si + reach) {
hi += 1;
}
let (mut sum, mut weight) = (Vec2::ZERO, 0.0);
for (q, &sj) in pts.iter().zip(&s).take(hi + 1).skip(lo) {
let w = (-(sj - si) * (sj - si) / (2.0 * spread * spread)).exp();
sum += q.to_vec2() * w;
weight += w;
}
if weight > 0.0 { (sum / weight).to_point() } else { p }
let window = Window { lo, end: hi + 1, at: si, spread, group: spread / SMOOTH_GROUP };
let mut acc = Weighed::default();
groups.weigh(0, (0, pts.len()), &window, &mut acc);
*work += acc.work;
if acc.weight > 0.0 { origin + acc.sum / acc.weight } else { p }
})
.collect()
}
/// The samples [`smooth_points`] weighs for one point: indices `lo..end`, weighed by a Gaussian of
/// `spread` around arc length `at`, in groups at most `group` long along the path.
struct Window {
lo: usize,
end: usize,
at: f64,
spread: f64,
group: f64,
}
/// The sums of halves, quarters and so on of a run's samples, by index: node 0 holds all of them and
/// node `k`'s halves are nodes `2k + 1` and `2k + 2`.
struct SampleGroups<'a> {
/// Arc length of each sample.
s: &'a [f64],
/// Sum of the arc lengths of each node's samples.
sum_s: Vec<f64>,
/// Sum of each node's samples, measured from the first sample.
sum_q: Vec<Vec2>,
}
impl<'a> SampleGroups<'a> {
fn new(pts: &[Point], s: &'a [f64], origin: Point) -> Self {
let size = 4 * pts.len();
let mut groups = Self { s, sum_s: vec![0.0; size], sum_q: vec![Vec2::ZERO; size] };
groups.build(0, 0, pts.len(), pts, origin);
groups
}
fn build(&mut self, node: usize, l: usize, r: usize, pts: &[Point], origin: Point) {
if r <= l {
return;
}
let (sum_s, sum_q) = if r - l == 1 {
(self.s.get(l).copied().unwrap_or(0.0), pts.get(l).map_or(Vec2::ZERO, |&p| p - origin))
} else {
let m = l + (r - l) / 2;
self.build(2 * node + 1, l, m, pts, origin);
self.build(2 * node + 2, m, r, pts, origin);
let at = |v: &Vec<f64>, k: usize| v.get(k).copied().unwrap_or(0.0);
let q = |k: usize| self.sum_q.get(k).copied().unwrap_or(Vec2::ZERO);
(at(&self.sum_s, 2 * node + 1) + at(&self.sum_s, 2 * node + 2), q(2 * node + 1) + q(2 * node + 2))
};
if let Some(a) = self.sum_s.get_mut(node) {
*a = sum_s;
}
if let Some(a) = self.sum_q.get_mut(node) {
*a = sum_q;
}
}
/// Adds the weighted samples of node `node` (indices `l..r`) that are inside `w` to `acc`: a node
/// inside `w` no longer than its groups as its count at its mean arc length, any other node by
/// its halves.
fn weigh(&self, node: usize, (l, r): (usize, usize), w: &Window, acc: &mut Weighed) {
if r <= w.lo || l >= w.end || r <= l {
return;
}
acc.work += 1;
let length = self.s.get(r - 1).copied().unwrap_or(0.0) - self.s.get(l).copied().unwrap_or(0.0);
// One sample is always a group, even where arc lengths that overflowed leave `length` NaN.
if r - l == 1 || (w.lo <= l && r <= w.end && length <= w.group) {
let count = (r - l) as f64;
let t = self.sum_s.get(node).copied().unwrap_or(0.0) / count - w.at;
let g = (-t * t / (2.0 * w.spread * w.spread)).exp();
acc.sum += self.sum_q.get(node).copied().unwrap_or(Vec2::ZERO) * g;
acc.weight += count * g;
return;
}
let m = l + (r - l) / 2;
self.weigh(2 * node + 1, (l, m), w, acc);
self.weigh(2 * node + 2, (m, r), w, acc);
}
}
/// What [`SampleGroups::weigh`] has added up: the weighted sum of the samples (from the first
/// sample), the sum of the weights, and the number of nodes looked at.
#[derive(Default)]
struct Weighed {
sum: Vec2,
weight: f64,
work: usize,
}
/// What [`simplify_part`] made of a path.
#[derive(Clone, Debug, PartialEq)]
pub struct SimplifiedPart {
@@ -295,7 +476,8 @@ fn with_handle(a: Anchor, out: bool, h: Point) -> Anchor {
Anchor { p: a.p, h_in, h_out, kind }
}
/// Ramer–Douglas–Peucker polyline reduction.
/// Ramer–Douglas–Peucker polyline reduction. Each point dropped is within `tol` of the line segment
/// that replaces it: a point beyond either end of the segment is measured to that end.
fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
if pts.len() < 3 {
return pts.to_vec();
@@ -317,10 +499,10 @@ fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
};
let (pa, pb) = (pts[a], pts[b]);
let chord = pb - pa;
let len = chord.hypot();
let len2 = chord.hypot2();
let mut best = (0.0, a);
for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) {
let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len };
let d = if len2 < 1e-24 { p.distance(pa) } else { p.distance(pa + chord * ((p - pa).dot(chord) / len2).clamp(0.0, 1.0)) };
if d > best.0 {
best = (d, i);
}
@@ -808,3 +990,116 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, (row_gutter, col_gu
fn end_point(sp: &SubPath, last: bool) -> Option<Point> {
if last { sp.anchors.last() } else { sp.anchors.first() }.map(|a| a.p)
}
#[cfg(test)]
mod tests {
use super::*;
/// The samples Object › Path › Smooth at `level` smooths for `sp`, a subpath with no corner,
/// and the spread it smooths them with.
fn smooth_samples(sp: &SubPath, level: f64) -> (Vec<Point>, f64) {
let opts = smooth_options(&PathData::single(sp.clone()), level);
let mut pts = Vec::new();
for (k, s) in subpath_segs(sp).iter().enumerate() {
sample(&s.c, sample_steps(&s.c, opts.tolerance), &mut pts, k == 0);
}
(pts, opts.smooth)
}
/// A traced outline: `n` straight segments around a circle of radius 200, each anchor moved in
/// or out by up to 0.5 pt.
fn traced_circle(n: usize) -> SubPath {
let pts: Vec<Point> = (0..n)
.map(|i| {
let a = i as f64 / n as f64 * std::f64::consts::TAU;
let r = 200.0 + 0.5 * ((i * 7919) % 13) as f64 / 13.0;
Point::new(300.0 + r * a.cos(), 300.0 + r * a.sin())
})
.collect();
SubPath::polyline(&pts, true)
}
/// [`smooth_points`] weighing every sample on its own.
fn smooth_points_one_by_one(pts: &[Point], sigma: f64) -> Vec<Point> {
let mut s = vec![0.0];
for w in pts.windows(2) {
s.push(s.last().copied().unwrap_or(0.0) + w[0].distance(w[1]));
}
let total = s.last().copied().unwrap_or(0.0);
pts.iter()
.zip(&s)
.map(|(&p, &si)| {
let reach = (3.0 * sigma).min(si).min(total - si);
if reach <= 1e-9 {
return p;
}
let spread = reach / 3.0;
let (mut sum, mut weight) = (Vec2::ZERO, 0.0);
for (q, &sj) in pts.iter().zip(&s).filter(|(_, sj)| (*sj - si).abs() <= reach) {
let w = (-(sj - si) * (sj - si) / (2.0 * spread * spread)).exp();
sum += (*q - p) * w;
weight += w;
}
p + sum / weight
})
.collect()
}
/// Next value in 0..1 of a small linear congruential generator.
fn next(seed: &mut u64) -> f64 {
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(*seed >> 11) as f64 / (1u64 << 53) as f64
}
#[test]
fn smoothing_work_per_sample_stays_bounded_on_dense_paths() {
// Weighing every sample within three spreads took 360 steps per sample for 1,000 anchors and
// 2,949 for 16,000: the work grew with the square of the anchor count.
for n in [1_000usize, 16_000] {
let (pts, sigma) = smooth_samples(&traced_circle(n), 100.0);
let mut work = 0;
smooth_points_counted(&pts, sigma, &mut work);
assert!(work <= 400 * pts.len(), "{n} anchors: {work} steps for {} samples", pts.len());
}
// A curve sampled at a tiny tolerance and smoothed far beyond its length.
let c = CubicBez::new((0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0));
let mut pts = Vec::new();
sample(&c, sample_steps(&c, 0.00001), &mut pts, true);
let mut work = 0;
smooth_points_counted(&pts, 1000.0, &mut work);
assert!(work <= 400 * pts.len(), "{work} steps for {} samples", pts.len());
}
#[test]
fn smoothing_points_whose_arc_length_overflows_returns_every_point() {
let pts: Vec<Point> = (0..40).map(|i| Point::new(if i % 2 == 0 { -1e308 } else { 1e308 }, f64::from(i))).collect();
assert_eq!(smooth_points(&pts, 10.0).len(), pts.len());
}
#[test]
fn grouped_smoothing_stays_within_a_thousandth_of_a_spread_of_weighing_one_by_one() {
let mut seed = 7u64;
for case in 0..300 {
let n = 3 + (next(&mut seed) * 2000.0) as usize;
let mut p = Point::ZERO;
let mut dir: f64 = 0.0;
let dense = next(&mut seed) < 0.5;
let pts: Vec<Point> = (0..n)
.map(|_| {
dir += (next(&mut seed) - 0.5) * 1.5;
let step = if dense { next(&mut seed) * 0.05 } else { next(&mut seed) * 5.0 };
p += Vec2::new(dir.cos(), dir.sin()) * step;
p
})
.collect();
let sigma = 0.01 + next(&mut seed) * 50.0;
let exact = smooth_points_one_by_one(&pts, sigma);
let grouped = smooth_points(&pts, sigma);
assert_eq!(grouped.len(), exact.len());
for (i, (a, b)) in exact.iter().zip(&grouped).enumerate() {
assert!(a.distance(*b) <= 1e-3 * sigma, "case {case}, point {i}: {a:?} and {b:?}, spread {sigma}");
}
assert_eq!((grouped.first(), grouped.last()), (pts.first(), pts.last()), "case {case}");
}
}
}
+666 -19
View File
@@ -1,12 +1,22 @@
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from
//! tangents, then reparameterise with Newton steps, then split near the worst point" approach from
//! Graphics Gems, written from the published description).
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2};
use kurbo::{CubicBez, Line, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point, Vec2};
/// Maximum Newton reparameterisation rounds per fit.
const MAX_REPARAM: usize = 6;
/// Most pieces joined to the first piece of a fitted curve. Each join refits every point of the
/// curve, so this limits how many times a point is refitted.
const MAX_JOINS: usize = 32;
/// Most samples of a fitted curve checked between two neighboring points. See [`bulge`].
const MAX_BULGE_SAMPLES: f64 = 1024.0;
/// About how many points of a long piece its first fit uses.
const COARSE_POINTS: usize = 16;
/// Unit vector or `None` if (nearly) zero.
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
let l = v.hypot();
@@ -28,15 +38,43 @@ pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
/// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point
/// (forward direction). Consecutive output cubics are G1 continuous.
pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
let mut out = Vec::new();
let pts = dedup(pts);
if pts.len() < 2 {
return out;
return Vec::new();
}
let tol = tol.max(1e-9);
let mut pieces = Vec::new();
fit_rec(&pts, 0, pts.len() - 1, t0, t1, tol, &mut pieces);
join_pieces(&pts, &pieces, t0, tol)
}
/// The curves of `pieces`, which follow each other along `pts` from its first point, leaving it
/// along `t0`. Each curve takes up to [`MAX_JOINS`] of the pieces after its first one, as long as
/// the fit of them together is within `tol`.
fn join_pieces(pts: &[Point], pieces: &[Piece], t0: Vec2, tol: f64) -> Vec<CubicBez> {
let mut out = Vec::new();
let (mut start, mut leaving, mut next) = (0, t0, 0);
while let Some(first) = pieces.get(next) {
let (mut curve, mut end, mut arriving) = (first.curve, first.end, first.t1);
next += 1;
for piece in pieces.iter().skip(next).take(MAX_JOINS) {
let Ok(joined) = fit_piece(&pts[start..=piece.end], leaving, piece.t1, tol) else { break };
(curve, end, arriving) = (joined, piece.end, piece.t1);
next += 1;
}
out.push(curve);
(start, leaving) = (end, arriving);
}
fit_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0);
out
}
/// A fitted piece: its curve, the index of the point it ends on and its tangent there.
struct Piece {
curve: CubicBez,
end: usize,
t1: Vec2,
}
fn dedup(pts: &[Point]) -> Vec<Point> {
let mut v: Vec<Point> = Vec::with_capacity(pts.len());
for &p in pts {
@@ -47,23 +85,65 @@ fn dedup(pts: &[Point]) -> Vec<Point> {
v
}
fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>, depth: usize) {
/// Fits `all[start..=end]`, splitting a piece in two until each piece is within `tol`. A piece of
/// two points is a cubic through both with handles along the tangents, at most `tol` long, so it
/// stays within `tol` of the line between them.
///
/// The split point is the piece's worst point moved, when it is near an end, to two fifths of the
/// piece from that end. Each half then holds about three fifths of the points at most, so the
/// recursion depth is logarithmic in the number of points.
fn fit_rec(all: &[Point], start: usize, end: usize, t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<Piece>) {
let pts = &all[start..=end];
let n = pts.len();
if n == 2 {
let d = pts[0].distance(pts[1]) / 3.0;
out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]));
let d = (pts[0].distance(pts[1]) / 3.0).min(tol);
out.push(Piece { curve: CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]), end, t1 });
return;
}
let (c, err, split) = fit_single(pts, t0, t1);
if err <= tol || depth > 40 {
out.push(c);
return;
let split = match fit_piece(pts, t0, t1, tol) {
Ok(curve) => {
out.push(Piece { curve, end, t1 });
return;
}
Err(worst) => worst,
};
let margin = (n * 2 / 5).max(1);
let split = split.clamp(margin, n - 1 - margin);
let tc = center_tangent(pts, split, tol).unwrap_or(t0);
fit_rec(all, start, start + split, t0, tc, tol, out);
fit_rec(all, start + split, end, tc, t1, tol, out);
}
/// One cubic for three or more `pts`, when every point is within `tol` of it and it is within `tol`
/// of the lines between the points; otherwise the index of a point where it is not. A first fit over
/// 8 times `tol` plus 5% of the chord is not reparameterized.
///
/// A piece of many points is first fitted to about [`COARSE_POINTS`] of them, and it is split when
/// that fit is over `tol` at one of those. Each split can be undone by [`join_pieces`].
fn fit_piece(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Result<CubicBez, usize> {
let give_up = tol * 8.0 + pts[0].distance(pts[pts.len() - 1]) * 0.05;
if pts.len() >= COARSE_POINTS * 2 {
let stride = pts.len() / COARSE_POINTS;
let some: Vec<Point> = pts.iter().step_by(stride).chain(pts.last()).copied().collect();
let (_, err, worst, _) = fit_single_within(&some, chord_params(&some), t0, t1, give_up);
if err > tol {
return Err((worst * stride).min(pts.len() - 1));
}
}
// Split at the worst point with a centre tangent estimated from its neighbours.
let split = split.clamp(1, n - 2);
let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0);
fit_rec(&pts[..=split], t0, tc, tol, out, depth + 1);
fit_rec(&pts[split..], tc, t1, tol, out, depth + 1);
let (c, err, worst, u) = fit_single_within(pts, chord_params(pts), t0, t1, give_up);
if err > tol {
return Err(worst);
}
bulge(pts, &c, &u, tol).map_or(Ok(c), Err)
}
/// Direction at `pts[split]`, between the nearest points on each side that are over `tol` from it,
/// or the first and last points when none is, so that details smaller than `tol` do not turn it.
fn center_tangent(pts: &[Point], split: usize, tol: f64) -> Option<Vec2> {
let p = pts[split];
let before = pts[..split].iter().rev().find(|q| q.distance(p) > tol).unwrap_or(&pts[0]);
let after = pts[split + 1..].iter().find(|q| q.distance(p) > tol).unwrap_or(&pts[pts.len() - 1]);
unit(*after - *before)
}
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
@@ -74,9 +154,20 @@ pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, u
/// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the
/// chord-length ones.
pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
pub(crate) fn fit_single_from(pts: &[Point], u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
let (c, err, worst, _) = fit_single_within(pts, u, t0, t1, f64::INFINITY);
(c, err, worst)
}
/// [`fit_single_from`] that also returns the fit's parameters, and returns the first fit without
/// any reparameterization when its error is over `give_up`.
fn fit_single_within(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2, give_up: f64) -> (CubicBez, f64, usize, Vec<f64>) {
let mut best = generate(pts, &u, t0, t1);
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
if best_err > give_up {
return (best, best_err, best_split, u);
}
let mut best_u = u.clone();
for _ in 0..MAX_REPARAM {
for (i, ui) in u.iter_mut().enumerate() {
*ui = newton(&best, pts[i], *ui);
@@ -87,11 +178,42 @@ pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2
best = c;
best_err = e;
best_split = s;
best_u.copy_from_slice(&u);
} else {
break;
}
}
(best, best_err, best_split)
(best, best_err, best_split, best_u)
}
/// Index of the first point of `pts` after which the curve `c`, between that point's parameter in
/// `u` and the next point's, is over 99% of `tol` from the line between the two points at a sample.
///
/// Over a parameter step `h`, a curve stays within `h * h * m / 8` of the chord between its ends,
/// where `m` is its largest second derivative, at most 6 times the control points' largest second
/// difference. The samples are close enough to keep that to 1% of `tol`. A pair of points that would
/// need more than [`MAX_BULGE_SAMPLES`] counts as a point where the curve is too far.
fn bulge(pts: &[Point], c: &CubicBez, u: &[f64], tol: f64) -> Option<usize> {
let step = (0.01 * tol * 8.0 / (6.0 * largest_second_difference(c))).sqrt();
let limit = tol * 0.99;
pts.windows(2).zip(u.windows(2)).position(|(p, t)| {
let line = Line::new(p[0], p[1]);
let samples = ((t[1] - t[0]).abs() / step).ceil().max(1.0);
if samples > MAX_BULGE_SAMPLES {
return true;
}
let samples = samples as usize;
(1..=samples).any(|k| {
let at = t[0] + (t[1] - t[0]) * k as f64 / (samples + 1) as f64;
line.nearest(c.eval(at), 1e-9).distance_sq > limit * limit
})
})
}
/// The larger second difference of the control points of `c`. Its largest second derivative is 6
/// times this.
pub(crate) fn largest_second_difference(c: &CubicBez) -> f64 {
(c.p2 - c.p1 - (c.p1 - c.p0)).hypot().max((c.p3 - c.p2 - (c.p2 - c.p1)).hypot())
}
fn chord_params(pts: &[Point]) -> Vec<f64> {
@@ -200,6 +322,7 @@ pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec<Point>, include_start
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::{Anchor, PathData, SubPath};
#[test]
fn fits_a_cubic_exactly() {
@@ -222,6 +345,530 @@ mod tests {
}
}
/// Numbers from 0 to 1, the same ones for the same `seed`.
fn random(mut seed: u64) -> impl FnMut() -> f64 {
move || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(seed >> 11) as f64 / (1u64 << 53) as f64
}
}
/// Largest distance from a point of `pts` to the nearest of the cubics `cs`, or infinity when
/// a point is more than `reach` from the bounding box of every cubic.
fn farthest(pts: &[Point], cs: &[CubicBez], reach: f64) -> f64 {
use kurbo::ParamCurveExtrema;
let boxes: Vec<_> = cs.iter().map(|c| c.bounding_box().inflate(reach, reach)).collect();
pts.iter()
.map(|&p| {
cs.iter().zip(&boxes).filter(|(_, b)| b.contains(p)).map(|(c, _)| c.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min)
})
.fold(0.0, f64::max)
}
fn cubics(path: &PathData) -> Vec<CubicBez> {
path.subpaths.iter().flat_map(|s| (0..s.segment_count()).map(|i| s.segment(i))).collect()
}
fn samples(cs: &[CubicBez]) -> Vec<Point> {
let mut pts = Vec::new();
for c in cs {
sample(c, 16, &mut pts, true);
}
pts
}
/// Samples of `cs` that follow each curve to within 1% of `tol`, at least 16 per curve.
fn close_samples(cs: &[CubicBez], tol: f64) -> Vec<Point> {
let mut pts = Vec::new();
for c in cs {
let steps = (0.75 * largest_second_difference(c) / (0.01 * tol)).sqrt().ceil().clamp(16.0, 20000.0);
sample(c, steps as usize, &mut pts, true);
}
pts
}
/// Simplify of `path` has no more anchors, and each of the path and its result is within `tol`
/// of the other, measured on the curves themselves. Returns the result.
fn assert_simplifies_within(path: &PathData, tol: f64, name: &str) -> PathData {
let out = crate::simplify_with(path, &crate::SimplifyOptions { tolerance: tol, ..Default::default() });
assert!(out.anchor_count() <= path.anchor_count(), "{name}: {} anchors from {}", out.anchor_count(), path.anchor_count());
assert_within(&cubics(path), &cubics(&out), tol, name);
out
}
/// Each of `before` and `after` is within `tol` of the other, measured on the curves themselves.
fn assert_within(before: &[CubicBez], after: &[CubicBez], tol: f64, name: &str) {
let limit = tol * (1.0 + 1e-6);
let to_result = farthest(&close_samples(before, tol), after, limit * 8.0);
assert!(to_result <= limit, "{name}: a point of the path is {to_result} from the result at a tolerance of {tol}");
let to_path = farthest(&close_samples(after, tol), before, limit * 8.0);
assert!(to_path <= limit, "{name}: a point of the result is {to_path} from the path at a tolerance of {tol}");
}
/// The segments of `sp` as a run for `simplify_run_within`.
fn run_of(sp: &SubPath) -> Vec<crate::boolean::Seg> {
(0..sp.segment_count()).map(|i| crate::boolean::Seg { c: sp.segment(i), line: false }).collect()
}
/// Anchors along a circle of radius `r` about the origin, from `first` degrees in steps of
/// `step` degrees, smooth, with the handles of a circular arc.
fn arc_anchors(r: f64, first: f64, step: f64, count: usize) -> Vec<Anchor> {
let handle = r * 4.0 / 3.0 * (step.to_radians() / 4.0).tan();
(0..count)
.map(|i| {
let (s, c) = (first + i as f64 * step).to_radians().sin_cos();
let p = Point::new(r * c, r * s);
Anchor::smooth(p, p + Vec2::new(-s, c) * handle)
})
.collect()
}
/// `count` corner anchors 0.5 pt apart, going on from `from` along `dir`.
fn short_anchors(from: Point, dir: Vec2, count: usize) -> Vec<Anchor> {
(1..=count).map(|i| Anchor::corner(from + dir * (i as f64 * 0.5))).collect()
}
#[test]
fn long_arcs_stay_within_the_tolerance() {
// Simplify keeps a subpath as it was when the refit has more anchors, so this measures the
// refit itself.
for (r, tol) in [(1_000_000.0, 2.0), (1_000_000.0, 1.0), (100_000.0, 0.1), (100_000.0, 1.0 / 32.0), (20_000.0, 0.01), (5_000.0, 0.01)] {
let run = run_of(&SubPath::new(arc_anchors(r, 0.0, 90.0, 4), false));
let opts = crate::SimplifyOptions { tolerance: tol, ..Default::default() };
let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None);
let (before, after): (Vec<_>, Vec<_>) = (run.iter().map(|s| s.c).collect(), out.iter().map(|s| s.c).collect());
assert_within(&before, &after, tol, &format!("arcs of {r} pt"));
assert!(after.len() < 200, "arcs of {r} pt at {tol} pt give {} curves", after.len());
}
}
#[test]
fn long_arcs_and_short_anchors_stay_within_the_tolerance() {
for (r, tol) in [(100_000.0, 1.0), (100_000.0, 0.1), (100_000.0, 1.0 / 32.0), (20_000.0, 0.01), (5_000.0, 0.01), (1_000_000.0, 1.0)] {
let mut anchors = arc_anchors(r, 0.0, 90.0, 4);
let end = anchors[3].p;
anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 3000));
let out = assert_simplifies_within(&PathData::single(SubPath::new(anchors, false)), tol, &format!("arcs of {r} pt and short anchors"));
assert!(out.anchor_count() < 200, "arcs of {r} pt at {tol} pt give {} anchors", out.anchor_count());
}
}
#[test]
fn long_arcs_converted_to_straight_lines_stay_within_the_tolerance() {
for (r, tol) in [(100_000.0, 0.1), (20_000.0, 0.01)] {
let mut anchors = arc_anchors(r, 0.0, 90.0, 4);
let end = anchors[3].p;
anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 3000));
let path = PathData::single(SubPath::new(anchors, false));
let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() });
assert_within(&cubics(&path), &cubics(&out), tol, &format!("lines along arcs of {r} pt"));
}
}
#[test]
fn a_path_that_turns_back_past_its_end_converted_to_straight_lines_keeps_its_tip() {
// A line to (100, 0), a smooth 0.2 pt U-turn and a line back to (50, 0.2): every point is
// within 0.4 pt of the line through the ends, but the tip is 50 pt past the end.
let k = 0.2 * 2.0 / 3.0;
let (a, b) = (Point::new(100.0, 0.0), Point::new(100.0, 0.2));
let anchors = vec![
Anchor::corner(Point::new(0.0, 0.0)),
Anchor::with_handles(a, a, Point::new(100.0 + k, 0.0)),
Anchor::with_handles(b, Point::new(100.0 + k, 0.2), b),
Anchor::corner(Point::new(50.0, 0.2)),
];
let path = PathData::single(SubPath::new(anchors, false));
let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: 1.0, straight_lines: true, ..Default::default() });
assert_within(&cubics(&path), &cubics(&out), 1.0, "a U-turn past the end");
}
#[test]
fn long_and_short_segments_stay_within_the_tolerance() {
for (r, tol) in [(50_000.0, 2.0), (50_000.0, 1.0 / 32.0), (8_000.0, 0.01), (500.0, 0.5)] {
let mut anchors = Vec::new();
for block in 0..6 {
let first = f64::from(block) * 60.0;
let arc = arc_anchors(r, first, 20.0, 3);
let (s, c) = (first + 40.0).to_radians().sin_cos();
let end = arc[2].p;
anchors.extend(arc);
anchors.extend(short_anchors(end, Vec2::new(-s, c), 300));
}
assert_simplifies_within(&PathData::single(SubPath::new(anchors, false)), tol, &format!("radius {r}"));
}
}
#[test]
fn a_segment_that_needs_too_many_samples_stays_as_it_is() {
// Radius 10,000,000 pt at 0.001 pt needs about 770,000 steps per quarter circle.
let mut anchors = arc_anchors(10_000_000.0, 0.0, 90.0, 4);
let end = anchors[3].p;
anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 500));
let path = PathData::single(SubPath::new(anchors, false));
let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: 0.001, ..Default::default() });
assert_eq!(cubics(&out)[..3], cubics(&path)[..3]);
assert!(out.anchor_count() < 20, "{} anchors", out.anchor_count());
}
#[test]
fn a_segment_that_needs_too_many_samples_becomes_lines_when_asked_for_lines() {
// About 55,000 lines keep this arc within 0.001 pt, and about 770,000 samples would be needed
// to fit it.
let (r, tol) = (10_000_000.0, 0.001);
let path = PathData::single(SubPath::new(arc_anchors(r, 0.0, 90.0, 2), false));
let arc = cubics(&path)[0];
let out = cubics(&crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() }));
assert!(out.iter().all(|c| is_straight(c, 1e-6)));
for (k, line) in out.iter().enumerate() {
let middle = arc.eval((k as f64 + 0.5) / out.len() as f64);
assert!(middle.distance(line.p0.midpoint(line.p3)) <= tol, "line {k} of {}", out.len());
}
}
#[test]
fn lines_for_segments_that_need_too_many_samples_are_limited() {
// Each of these 35 segments takes the most lines a segment can have.
let run = run_of(&SubPath::new(arc_anchors(1e9, 0.0, 10.0, 36), false));
let opts = crate::SimplifyOptions { tolerance: 1e-3, straight_lines: true, ..Default::default() };
let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None);
assert!(out.len() <= crate::edit::MAX_RUN_RESULT + run.len(), "{} lines", out.len());
assert!(out.iter().all(|s| s.line));
assert_eq!(out[out.len() - 1].c.p3, run[run.len() - 1].c.p3);
}
#[test]
fn a_run_past_the_samples_of_one_fit_has_an_anchor_where_the_parts_meet() {
let (r, tol) = (1_000_000.0, 0.0005);
let run = run_of(&SubPath::new(arc_anchors(r, 0.0, 15.0, 24), false));
let steps = crate::edit::sample_steps(&run[0].c, tol);
let segments = crate::edit::MAX_FIT_SAMPLES / steps;
assert!(segments + 1 < run.len(), "{steps} steps per segment");
let opts = crate::SimplifyOptions { tolerance: tol, ..Default::default() };
let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None);
let meet = run[segments - 1].c.p3;
assert!(out.iter().any(|s| s.c.p3 == meet), "no anchor at {meet:?}");
}
#[test]
fn segments_past_the_sample_budget_stay_as_they_are() {
// Every segment takes the same steps, so the budget covers the first ten.
let sp = SubPath::new(arc_anchors(100.0, 0.0, 15.0, 20), false);
let run = run_of(&sp);
let opts = crate::SimplifyOptions { tolerance: 1.0, ..Default::default() };
let steps = crate::edit::sample_steps(&run[0].c, 1.0);
let out = crate::edit::simplify_run_within(&run, &opts, steps * 10, None);
assert!(out.len() < 19, "{} segments", out.len());
let tail: Vec<CubicBez> = out[out.len() - 9..].iter().map(|s| s.c).collect();
let want: Vec<CubicBez> = run[10..].iter().map(|s| s.c).collect();
assert_eq!(tail, want);
}
#[test]
fn a_curve_that_needs_too_many_checks_is_rejected() {
// The first curve goes along the line and back. The second one goes along it evenly.
let pts = [Point::new(0.0, 0.0), Point::new(1e9, 0.0)];
let c = CubicBez::new(pts[0], pts[1], pts[0], pts[1]);
assert_eq!(bulge(&pts, &c, &[0.0, 1.0], 1e-3), Some(0));
let c = CubicBez::new(pts[0], Point::new(1e9 / 3.0, 0.0), Point::new(2e9 / 3.0, 0.0), pts[1]);
assert_eq!(bulge(&pts, &c, &[0.0, 1.0], 1e-3), None);
}
/// `rows` rows of `cols` smooth anchors 1.6 pt apart, alternately 2 pt up.
fn serpentine(rows: usize, cols: usize, closed: bool) -> SubPath {
let mut anchors = Vec::with_capacity(rows * cols);
for row in 0..rows {
let dir = if row % 2 == 0 { 1.0 } else { -1.0 };
for col in 0..cols {
let x = if row % 2 == 0 { col } else { cols - 1 - col };
let p = Point::new(x as f64 * 1.6, row as f64 * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 });
anchors.push(Anchor::smooth(p, p + Vec2::new(0.5 * dir, 0.0)));
}
}
SubPath::new(anchors, closed)
}
/// `n` straight segments of `step` pt that turn by `degrees` left and right in turn.
fn zigzag(n: usize, degrees: f64, step: f64, closed: bool) -> SubPath {
let half = (degrees / 2.0).to_radians();
let mut p = Point::ZERO;
let mut pts = Vec::with_capacity(n);
for i in 0..n {
pts.push(p);
let a = if i % 2 == 0 { half } else { -half };
p += Vec2::new(a.cos(), a.sin()) * step;
}
SubPath::polyline(&pts, closed)
}
/// Smooth anchors along arcs that bend one way and then the other, 40 anchors per arc.
fn alternating_arcs(arcs: usize) -> SubPath {
let mut anchors = Vec::with_capacity(arcs * 40);
let (mut p, mut heading) = (Point::ZERO, 0.0_f64);
for arc in 0..arcs {
for _ in 0..40 {
heading += if arc % 2 == 0 { 0.08 } else { -0.08 };
let d = Vec2::new(heading.cos(), heading.sin()) * 2.0;
anchors.push(Anchor::smooth(p, p + d / 3.0));
p += d;
}
}
SubPath::new(anchors, false)
}
/// A polyline of random steps, with some points repeated when `repeats` is set.
fn random_walk(rnd: &mut impl FnMut() -> f64, n: usize, repeats: bool, closed: bool) -> SubPath {
let mut p = Point::new(rnd() * 100.0, rnd() * 100.0);
let mut pts = Vec::with_capacity(n);
for _ in 0..n {
pts.push(p);
if repeats && rnd() < 0.3 {
pts.push(p);
}
p += Vec2::new(rnd() * 6.0 - 3.0, rnd() * 6.0 - 3.0);
}
SubPath::polyline(&pts, closed)
}
/// A chain of smooth anchors with random steps and handles.
fn random_curves(rnd: &mut impl FnMut() -> f64, n: usize, closed: bool) -> SubPath {
let mut p = Point::ZERO;
let mut anchors = Vec::with_capacity(n);
for _ in 0..n {
anchors.push(Anchor::smooth(p, p + Vec2::new(1.0 + rnd() * 6.0, rnd() * 8.0 - 4.0)));
p += Vec2::new(5.0 + rnd() * 20.0, rnd() * 30.0 - 15.0);
}
SubPath::new(anchors, closed)
}
/// `n` smooth anchors 1.6 pt apart, each at a random height of up to 3 pt.
fn random_ripple(rnd: &mut impl FnMut() -> f64, n: usize, closed: bool) -> SubPath {
let anchors = (0..n)
.map(|i| {
let p = Point::new(i as f64 * 1.6, rnd() * 3.0);
Anchor::smooth(p, p + Vec2::new(0.5, 0.0))
})
.collect();
SubPath::new(anchors, closed)
}
#[test]
fn no_fitted_piece_is_farther_than_the_tolerance() {
for (rows, cols) in [(1, 300), (1, 1000), (5, 400)] {
assert_simplifies_within(&PathData::single(serpentine(rows, cols, false)), 1.0, &format!("serpentine {rows} x {cols}"));
}
assert_simplifies_within(&PathData::single(serpentine(1, 1000, false)), 0.25, "serpentine at 0.25 pt");
assert_simplifies_within(&PathData::single(alternating_arcs(25)), 0.5, "alternating arcs");
}
#[test]
fn a_zigzag_stays_within_the_tolerance() {
// The turns are below the 30 degree corner threshold, so the fit runs across them.
assert_simplifies_within(&PathData::single(zigzag(1500, 20.0, 3.0, false)), 0.5, "zigzag");
assert_simplifies_within(&PathData::single(zigzag(1500, 29.0, 1.0, false)), 0.1, "fine zigzag");
// These turns are corners, so every segment is a run of its own.
assert_simplifies_within(&PathData::single(zigzag(500, 60.0, 3.0, false)), 2.0, "zigzag of corners");
// Simplify samples a segment this long more than `tol` apart, so a curve between two
// samples can leave the path without a sample showing it.
assert_simplifies_within(&PathData::single(zigzag(60, 25.0, 900.0, false)), 1.0, "zigzag of long segments");
assert_simplifies_within(&PathData::single(zigzag(60, 8.0, 2500.0, true)), 0.5, "closed zigzag of long segments");
// The short anchors make the result smaller than the path, so Simplify does not return the
// path as it was.
for (step, tol) in [(1000.0, 0.1), (5000.0, 1.0), (20000.0, 1.0)] {
let mut sp = zigzag(40, 25.0, step, false);
let end = sp.anchors[sp.anchors.len() - 1].p;
sp.anchors.extend((1..=2000).map(|i| Anchor::corner(end + Vec2::new(i as f64 * 0.5, 0.0))));
assert_simplifies_within(&PathData::single(sp), tol, &format!("zigzag of {step} pt segments and short anchors"));
}
}
#[test]
fn closed_paths_stay_within_the_tolerance() {
assert_simplifies_within(&PathData::single(serpentine(3, 1000, true)), 1.0, "closed serpentine");
assert_simplifies_within(&PathData::single(zigzag(800, 20.0, 3.0, true)), 0.5, "closed zigzag");
let circle: Vec<Anchor> = (0..2000)
.map(|i| {
let a = i as f64 / 2000.0 * std::f64::consts::TAU;
let p = Point::new(300.0 * a.cos(), 300.0 * a.sin());
Anchor::smooth(p, p + Vec2::new(-a.sin(), a.cos()) * 0.3)
})
.collect();
assert_simplifies_within(&PathData::single(SubPath::new(circle, true)), 0.05, "closed circle of 2,000 anchors");
}
#[test]
fn compound_paths_stay_within_the_tolerance() {
let mut rnd = random(11);
let compound = PathData::new(vec![
serpentine(1, 1000, false),
zigzag(400, 20.0, 3.0, true),
random_curves(&mut rnd, 100, false),
SubPath::polyline(&[Point::new(0.0, -50.0), Point::new(100.0, -50.0)], false),
]);
assert_simplifies_within(&compound, 0.75, "compound");
}
#[test]
fn coincident_anchors_stay_within_the_tolerance() {
let mut rnd = random(23);
assert_simplifies_within(&PathData::single(random_walk(&mut rnd, 600, true, false)), 1.0, "repeated points");
assert_simplifies_within(&PathData::single(random_walk(&mut rnd, 600, true, true)), 1.0, "repeated points, closed");
let p = Point::new(5.0, 5.0);
let same = SubPath::new(vec![Anchor::smooth(p, p + Vec2::new(1.0, 0.0)); 50], false);
assert_simplifies_within(&PathData::single(same), 0.5, "one place");
let mut doubled = serpentine(1, 1500, false);
let copy = doubled.anchors.clone();
doubled.anchors = copy.iter().flat_map(|a| [*a, *a]).collect();
assert_simplifies_within(&PathData::single(doubled), 1.0, "every anchor twice");
}
#[test]
fn random_paths_stay_within_the_tolerance() {
let mut rnd = random(0x9e37_79b9_7f4a_7c15);
for case in 0..60 {
let tol = [0.05, 0.25, 1.0, 4.0][case % 4];
let n = 2 + (rnd() * 300.0) as usize;
let closed = case % 3 == 0;
let sp = if case % 2 == 0 { random_walk(&mut rnd, n, false, closed) } else { random_curves(&mut rnd, n, closed) };
assert_simplifies_within(&PathData::single(sp), tol, &format!("case {case}"));
}
for (case, tol) in [0.5, 1.0, 2.0].into_iter().enumerate() {
assert_simplifies_within(&PathData::single(random_ripple(&mut rnd, 2000, case == 1)), tol, &format!("ripple at {tol} pt"));
}
}
#[test]
fn a_curve_stays_within_the_tolerance_between_wide_points() {
let up = Vec2::new(0.6, 0.8);
let down = Vec2::new(0.6, -0.8);
let runs: [&[Point]; 3] = [
// Two points: the tangents alone shape the curve.
&[Point::new(0.0, 0.0), Point::new(600.0, 0.0)],
// Three points: a curve through the middle one can still bulge on each side of it.
&[Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(600.0, 0.0)],
&[Point::new(0.0, 0.0), Point::new(100.0, 20.0), Point::new(200.0, 0.0), Point::new(300.0, 20.0), Point::new(400.0, 0.0)],
];
for pts in runs {
let out = fit_cubics(pts, up, down, 1.0);
let far = samples(&out)
.iter()
.map(|&q| pts.windows(2).map(|w| Line::new(w[0], w[1]).nearest(q, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min))
.fold(0.0, f64::max);
assert!(far <= 1.0 + 1e-6, "a point of the curve is {far} from the {} points", pts.len());
}
}
#[test]
fn a_curve_stays_within_the_tolerance_between_its_checks() {
// A curve with handles this long has its peak between evenly spaced samples.
let pts = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(600.0, 0.0)];
let tol = 105.47;
let mut dense = Vec::new();
for c in fit_cubics(&pts, Vec2::new(0.6, 0.8), Vec2::new(0.8, -0.6), tol) {
sample(&c, 4096, &mut dense, true);
}
let far = dense.iter().map(|q| q.y.abs()).fold(0.0, f64::max);
assert!(far <= tol, "a point of the curve is {far} from the line");
}
#[test]
fn every_point_is_within_the_tolerance_of_its_chain() {
let mut rnd = random(0x2545_f491_4f6c_dd1d);
for case in 0..400 {
let tol = [0.01, 0.1, 1.0, 5.0][case % 4];
let mut pts: Vec<Point> = Vec::new();
match case % 5 {
// Open random walk.
0 => {
let mut p = Point::new(rnd() * 100.0, rnd() * 100.0);
for _ in 0..2 + (rnd() * 400.0) as usize {
pts.push(p);
p += Vec2::new(rnd() * 4.0 - 1.0, rnd() * 4.0 - 2.0);
}
}
// Closed noisy loop: the last point is the first.
1 => {
let n = 3 + (rnd() * 300.0) as usize;
for i in 0..n {
let a = i as f64 / n as f64 * std::f64::consts::TAU;
let r = 50.0 + rnd() * 3.0;
pts.push(Point::new(r * a.cos(), r * a.sin()));
}
pts.push(pts[0]);
}
// Very short runs.
2 => {
for _ in 0..2 + (rnd() * 3.0) as usize {
pts.push(Point::new(rnd() * 20.0, rnd() * 20.0));
}
}
// Coincident points.
3 => {
let mut p = Point::new(0.0, 0.0);
for _ in 0..2 + (rnd() * 200.0) as usize {
pts.push(p);
if rnd() < 0.3 {
pts.push(p);
}
p += Vec2::new(rnd() * 3.0, rnd() * 6.0 - 3.0);
}
}
// Samples of a chain of wavy cubics, as Simplify takes them.
_ => {
let mut p0 = Point::new(0.0, 0.0);
for k in 0..1 + (rnd() * 60.0) as usize {
let p3 = p0 + Vec2::new(5.0 + rnd() * 10.0, rnd() * 10.0 - 5.0);
let c = CubicBez::new(p0, p0 + Vec2::new(2.0, rnd() * 8.0 - 4.0), p3 - Vec2::new(2.0, rnd() * 8.0 - 4.0), p3);
sample(&c, 4 + (rnd() * 20.0) as usize, &mut pts, k == 0);
p0 = p3;
}
}
}
let t0 = unit(Vec2::new(rnd() - 0.5, rnd() - 0.5)).unwrap_or(Vec2::new(1.0, 0.0));
let t1 = unit(Vec2::new(rnd() - 0.5, rnd() - 0.5)).unwrap_or(Vec2::new(1.0, 0.0));
let out = fit_cubics(&pts, t0, t1, tol);
assert_eq!(out.first().map(|c| c.p0), pts.first().copied(), "case {case}");
assert_eq!(out.last().map(|c| c.p3), pts.last().copied(), "case {case}");
for w in out.windows(2) {
assert_eq!(w[0].p3, w[1].p0, "case {case}");
}
let far = farthest(&pts, &out, tol);
assert!(far <= tol + 1e-9, "case {case}: a point is {far} from the fit at a tolerance of {tol}");
}
// Samples of a long serpentine, which split lopsidedly at their worst points.
let pts = samples(&cubics(&PathData::single(serpentine(1, 1000, false))));
let out = fit_cubics(&pts, Vec2::new(1.0, 0.0), Vec2::new(1.0, 0.0), 1.0);
let far = farthest(&pts, &out, 1.0);
assert!(far <= 1.0 + 1e-9, "a point of the serpentine is {far} from the fit");
}
/// `n` pieces of one segment each along a straight line, and the line's points.
fn straight_pieces(n: usize) -> (Vec<Point>, Vec<Piece>) {
let pts: Vec<Point> = (0..=n).map(|i| Point::new(i as f64, 0.0)).collect();
let t1 = Vec2::new(1.0, 0.0);
let pieces = (1..=n).map(|end| Piece { curve: CubicBez::new(pts[end - 1], pts[end - 1], pts[end], pts[end]), end, t1 }).collect();
(pts, pieces)
}
#[test]
fn joined_pieces_make_one_curve_up_to_the_limit() {
let t0 = Vec2::new(1.0, 0.0);
let (pts, pieces) = straight_pieces(MAX_JOINS + 1);
let out = join_pieces(&pts, &pieces, t0, 1.0);
assert_eq!(out.len(), 1);
assert_eq!((out[0].p0, out[0].p3), (pts[0], pts[MAX_JOINS + 1]));
}
#[test]
fn a_curve_joins_no_more_pieces_than_the_limit() {
let (pts, pieces) = straight_pieces(1000);
let out = join_pieces(&pts, &pieces, Vec2::new(1.0, 0.0), 1.0);
assert_eq!(out.len(), 1000_usize.div_ceil(MAX_JOINS + 1));
assert_eq!((out[0].p0, out[out.len() - 1].p3), (pts[0], pts[1000]));
for w in out.windows(2) {
assert_eq!(w[0].p3, w[1].p0);
}
}
#[test]
fn straight_detection() {
let c = CubicBez::new((0.0, 0.0), (3.0, 0.0), (6.0, 0.0), (9.0, 0.0));
+3 -2
View File
@@ -453,9 +453,10 @@ fn simplify_slider_escape_puts_the_paths_back() {
let undo_len = app.session.doc().unwrap().history.undo.len();
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
settle(&ctx, &mut app);
// Level 0's tolerance of 32 pt takes out the path's 0.1 pt zigzag; level 100's 1/32 pt keeps it.
let (_, _, rail, _) = slider_strip(&ctx);
drag(&ctx, &mut app, rail, vec2(100.0, 0.0));
assert_eq!(simplify_preview(&app), json!({"level": 100.0}));
drag(&ctx, &mut app, rail + vec2(90.0, 0.0), vec2(-190.0, 0.0));
assert_eq!(simplify_preview(&app), json!({"level": 0.0}));
assert_ne!(app.session.doc().unwrap().doc, before, "the preview changes the path");
let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events: vec![escape], ..Default::default() };
+7 -2
View File
@@ -3096,8 +3096,13 @@ the path it had begun while that stays selected. Its reply's `tool` is the tool
`object.path.simplify` takes the amount as `tolerance` (points, 1 by default) or as the Simplify slider's `level`, a whole
number from 0 (the fewest anchor points) to 100 (the closest fit). Level 50 is a tolerance of 1 pt, and every 10 levels
halve or double it, so level 0 is 32 pt and level 100 is 1/32 pt. `auto: true` (Auto-Simplify) uses the lowest level
whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt. With
`level` or `auto`, the reply also has the `level` used:
whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt. The
result and the original path stay within `tolerance` of each other, except that a closed subpath that refits to one curve
is dropped, so a small circle in a compound path can be removed at a large `tolerance`. A segment that would need more
than 65,536 samples at that tolerance stays as it is, and so does a segment after a run's first 16,777,216 samples or
2,097,152 result segments. With `straightLines`, such a segment becomes evenly spaced lines, up to 65,536, and past those
limits the lines can be farther than `tolerance` from the segment. With `level` or `auto`, the reply also has the `level`
used:
```json
{"name":"run_command","arguments":{"command":"object.path.simplify","params":{"auto":true}}}
+1
View File
@@ -965,6 +965,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Public references: [Simplify benefits](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-path-benefits.html) and [advanced options](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-paths-advanced-options-overview.html).
Nearly unchanged appearance is expected when reducing points. The checklist is partial, without raising scores.
Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape.
Simplify keeps its result within the tolerance of the path, on long arcs and on paths of many anchors alike (`fit.rs` tests `no_fitted_piece_is_farther_than_the_tolerance` and `long_arcs_stay_within_the_tolerance`). A closed subpath that refits to one curve is dropped, so this does not hold for it. A segment that would need more than 65,536 samples at that tolerance stays as it is, and with Convert to Straight Lines it becomes evenly spaced lines instead, up to 65,536, which past the limits in `docs/mcp.md` (Simplify) can be farther than the tolerance from the segment.
The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines.
Object › Path › Simplify opens a slider under the selection ([Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html), [Auto simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/auto-simplify-paths.html)) with Auto-Simplify, a rail from the fewest anchor points to the closest fit, and More Options, which opens the dialog at the slider's tolerance. The rail's levels are VectorCraft's own scale (`object.path.simplify {level}`): level 50 is 1 pt, and every 10 levels halve or double the tolerance, from 32 pt at 0 to 1/32 pt at 100. Auto-Simplify picks the lowest level whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt; the slider opens at that level, with its result previewed, as the manual-simplify page describes. The dialog's "Open this dialog with these settings next time" makes the menu item open the dialog on the settings of its last OK.
With anchors or segments of a path selected with Direct Selection, Simplify acts on just that part of the path, as the [Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) page puts it: the slider, Auto-Simplify and the dialog refit only each run of selected segments, between its end anchors, which keep their place and their handles on the side away from the run. The anchors and segments outside the runs stay exactly as they were, a closed path's run may cross its start point, separate runs on one path are refitted independently in one undo step, and the runs stay selected. A path with only lone anchors selected is still simplified whole, and `object.path.simplify {wholePaths: true}` simplifies partly selected paths whole. Tests: `crates/engine/src/tests/simplify_part.rs` and `simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path`.