Files
vectorcraft/crates/pathops/src/edit.rs
T
dotCooCoo bc19dd64db Simplify: Convert to Straight Lines keeps the tip of a path that turns back past its end
`rdp` measured each sample to the infinite line through the ends of the
line that would replace it. A sample beyond either end could be far from
that line segment and still be dropped: a line to (100, 0), a 0.2 pt
U-turn and a line back to (50, 0.2) became one line at a tolerance of
1 pt, leaving the tip 50 pt away. `rdp` now measures to the segment, so a
sample beyond an end is measured to that end, and a zero-length chord
measures to its point as before.

The test `a_path_that_turns_back_past_its_end_converted_to_straight_lines_keeps_its_tip`
covers that path.
2026-11-08 11:22:30 +07:00

1106 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average,
//! Join, Split Into Grid.
use std::collections::{BTreeMap, BTreeSet};
use kurbo::{CubicBez, ParamCurve, PathSeg, Point, Rect, Vec2};
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, largest_second_difference, sample, start_tangent, unit};
/// Options for [`simplify_with`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SimplifyOptions {
/// Maximum distance (document points) between the original and simplified path.
pub tolerance: f64,
/// Anchors where the path turns by more than this many degrees stay corners.
pub corner_angle_deg: f64,
/// Produce only straight segments ("Convert to Straight Lines").
pub straight_lines: bool,
/// 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,
}
impl Default for SimplifyOptions {
fn default() -> Self {
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0 }
}
}
/// Simplify with the default corner threshold (30°).
pub fn simplify(path: &PathData, tolerance: f64) -> PathData {
simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() })
}
/// The Simplify slider's level for a tolerance of 1 pt, the middle of its scale.
pub const SIMPLIFY_LEVEL: f64 = 50.0;
/// The tolerance (points) of Simplify slider level `level`, from 0 (the fewest anchor points) to
/// 100 (the closest to the original): 1 pt at [`SIMPLIFY_LEVEL`], doubled for every 10 levels below
/// it and halved for every 10 above, so level 0 is 32 pt and level 100 is 1/32 pt. A level outside
/// 0..=100 counts as the nearer end, and one that is not a number counts as [`SIMPLIFY_LEVEL`].
pub fn simplify_level_tolerance(level: f64) -> f64 {
let level = if level.is_finite() { level.clamp(0.0, 100.0) } else { SIMPLIFY_LEVEL };
((SIMPLIFY_LEVEL - level) / 10.0).exp2()
}
/// Auto-Simplify's level for paths whose bounds are `size` points on their longer side: the lowest
/// whole level whose tolerance ([`simplify_level_tolerance`]) is at most 1/400 of `size`. It returns
/// level 100 for a size of 0 or one that is not a number, and for a size under 12.5 pt, where no
/// level is that close.
pub fn auto_simplify_level(size: f64) -> f64 {
let target = size / 400.0;
if !(target.is_finite() && target > 0.0) {
return 100.0;
}
(SIMPLIFY_LEVEL - 10.0 * target.log2()).ceil().clamp(0.0, 100.0)
}
/// Simplify never adds anchors: keep whichever subpath is smaller.
fn no_worse(original: &SubPath, fitted: SubPath) -> SubPath {
if fitted.anchors.len() > original.anchors.len() { original.clone() } else { fitted }
}
fn subpath_segs(sp: &SubPath) -> Vec<Seg> {
(0..sp.segment_count()).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect()
}
fn turn_deg(a: &Seg, b: &Seg) -> f64 {
let d = end_tangent(&a.c).dot(start_tangent(&b.c)).clamp(-1.0, 1.0);
d.acos().to_degrees()
}
/// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold.
pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData {
let mut out = Vec::new();
for sp in &path.subpaths {
if let Some(s) = simplify_subpath(sp, opts) {
out.push(s);
}
}
PathData::new(out)
}
/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it.
fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
let mut segs = subpath_segs(sp);
let n = segs.len();
if n == 0 {
return Some(sp.clone());
}
if sp.closed {
// Start the loop at its first corner, so no fit runs across one.
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);
}
// 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);
cuts.push(run.len());
let mut result: Vec<Seg> = Vec::new();
for w in cuts.windows(2) {
let piece = &run[w[0]..w[1]];
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. 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();
}
let mut s = 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);
}
s.push(total);
}
let groups = SampleGroups::new(pts, &s, origin);
let (mut lo, mut hi) = (0usize, 0usize);
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;
while s.get(lo).is_some_and(|&v| v < si - reach) {
lo += 1;
}
while s.get(hi + 1).is_some_and(|&v| v <= si + reach) {
hi += 1;
}
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 {
pub path: PathData,
/// The segments of the simplified runs, as (subpath, segment) in `path`.
pub segments: BTreeSet<(usize, usize)>,
/// Where each anchor of the original path that is still there went: (subpath, anchor) in the
/// original → in `path`. Anchors inside a simplified run are gone; every other anchor is kept.
pub anchors: BTreeMap<(usize, usize), (usize, usize)>,
}
/// Simplify only the `segments` (subpath, segment) of `path`: each run of consecutive segments
/// among them (a closed subpath's run may cross its start point) is refitted on its own between its
/// two end anchors, which stay where they are with their handles on the side away from the run.
/// The other segments and anchors stay exactly as they are. A subpath whose every segment is in
/// `segments` is simplified whole, as [`simplify_with`] does. A run whose refit would have more
/// segments than it had is left as it is (except with Convert to Straight Lines).
pub fn simplify_part(path: &PathData, segments: &BTreeSet<(usize, usize)>, opts: &SimplifyOptions) -> SimplifiedPart {
let (mut runs, mut anchors) = (BTreeSet::new(), BTreeMap::new());
let mut subpaths = Vec::with_capacity(path.subpaths.len());
for (si, sp) in path.subpaths.iter().enumerate() {
let n = sp.segment_count();
let picked: Vec<bool> = (0..n).map(|i| segments.contains(&(si, i))).collect();
let ni = subpaths.len();
if !picked.contains(&true) {
anchors.extend((0..sp.anchors.len()).map(|ai| ((si, ai), (ni, ai))));
subpaths.push(sp.clone());
} else if !picked.contains(&false) {
if let Some(s) = simplify_subpath(sp, opts) {
runs.extend((0..s.segment_count()).map(|i| (ni, i)));
subpaths.push(s);
}
} else {
let (s, segs, kept) = simplify_runs(sp, &picked, opts);
runs.extend(segs.into_iter().map(|i| (ni, i)));
anchors.extend(kept.into_iter().map(|(old, new)| ((si, old), (ni, new))));
subpaths.push(s);
}
}
SimplifiedPart { path: PathData::new(subpaths), segments: runs, anchors }
}
/// [`simplify_part`] for a subpath with some but not all segments `picked`: → (the subpath, the
/// segments of its refitted runs, old → new index of each anchor kept).
fn simplify_runs(sp: &SubPath, picked: &[bool], opts: &SimplifyOptions) -> (SubPath, Vec<usize>, Vec<(usize, usize)>) {
let m = sp.anchors.len();
let n = picked.len();
// An anchor inside a run (both its segments picked) goes; a closed subpath's walk starts at the
// first anchor that stays, so that no run crosses the start of the walk.
let inside = |a: usize| (sp.closed && a == 0 && picked[n - 1] && picked[0]) || (a > 0 && a < n && picked[a - 1] && picked[a]);
let start = (0..m).find(|&a| !inside(a)).unwrap_or(0);
let mut anchors = vec![sp.anchors[start]];
let mut kept = vec![(start, 0)];
let mut runs = vec![];
let mut k = 0;
while k < n {
let j = (start + k) % n;
if !picked[j] {
let next = (j + 1) % m;
if k + 1 < n || !sp.closed {
kept.push((next, anchors.len()));
anchors.push(sp.anchors[next]);
}
k += 1;
continue;
}
let len = (k..n).take_while(|&i| picked[(start + i) % n]).count();
let run: Vec<Seg> = (k..k + len).map(|i| (start + i) % n).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect();
let end = (start + k + len) % m;
let fitted = simplify_run(&run, opts);
let keep_original = fitted.is_empty() || !opts.straight_lines && fitted.len() > run.len();
let first = anchors.len() - 1;
if keep_original {
anchors.extend((k + 1..k + len).map(|i| sp.anchors[(start + i) % m]));
} else {
let a = anchors[first];
anchors[first] = with_handle(a, true, if fitted[0].line { a.p } else { fitted[0].c.p1 });
for w in fitted.windows(2) {
let p = w[1].c.p0;
let h_in = if w[0].line { p } else { w[0].c.p2 };
let h_out = if w[1].line { p } else { w[1].c.p1 };
anchors.push(Anchor::with_handles(p, h_in, h_out));
}
}
// The run's segments, from its first anchor on: a run back to the walk's start ends with
// the closing segment, the last index.
runs.extend(first..first + if keep_original { run.len() } else { fitted.len() });
let end_seg = fitted.last().filter(|_| !keep_original);
if sp.closed && k + len == n {
// The run ends where the walk started.
if let Some(s) = end_seg {
anchors[0] = with_handle(anchors[0], false, if s.line { anchors[0].p } else { s.c.p2 });
}
} else {
let a = sp.anchors[end];
kept.push((end, anchors.len()));
anchors.push(match end_seg {
Some(s) => with_handle(a, false, if s.line { a.p } else { s.c.p2 }),
None => a,
});
}
k += len;
}
(SubPath::new(anchors, sp.closed), runs, kept)
}
/// `a` with its out (`out`) or in handle at `h` and the other as it is: still smooth when it was
/// and its handles still line up, a corner otherwise.
fn with_handle(a: Anchor, out: bool, h: Point) -> Anchor {
let (h_in, h_out) = if out { (a.h_in, h) } else { (h, a.h_out) };
let kind = if a.kind == AnchorKind::Smooth { Anchor::with_handles(a.p, h_in, h_out).kind } else { AnchorKind::Corner };
Anchor { p: a.p, h_in, h_out, kind }
}
/// 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();
}
let mut keep = vec![false; pts.len()];
keep[0] = true;
keep[pts.len() - 1] = true;
let mut stack = vec![(0, pts.len() - 1)];
// Distances measured at most: lopsided splits (a shallow zigzag) make it quadratic. Past it,
// the points of the parts left are kept as they are.
let mut budget = pts.len().saturating_mul(64).saturating_add(1 << 20);
while let Some((a, b)) = stack.pop() {
budget = match budget.checked_sub(b - a) {
Some(left) => left,
None => {
keep[a..=b].fill(true);
continue;
}
};
let (pa, pb) = (pts[a], pts[b]);
let chord = pb - pa;
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 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);
}
}
if best.0 > tol {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect()
}
/// Object → Path → Smooth: pull handles towards Catmull-Rom-like tangents. `amount` ∈ [0, 1]
/// (0 = unchanged, 1 = fully smooth). Endpoints of open paths keep their handles.
pub fn smooth(path: &PathData, amount: f64) -> PathData {
let t = amount.clamp(0.0, 1.0);
let mut out = path.clone();
for sp in &mut out.subpaths {
let n = sp.anchors.len();
if n < 3 {
continue;
}
let orig = sp.anchors.clone();
for i in 0..n {
if !sp.closed && (i == 0 || i == n - 1) {
continue;
}
let prev = orig[(i + n - 1) % n].p;
let next = orig[(i + 1) % n].p;
let p = orig[i].p;
let Some(dir) = crate::fit::unit(next - prev) else { continue };
let tin = p - dir * (p.distance(prev) / 3.0);
let tout = p + dir * (p.distance(next) / 3.0);
let a = &orig[i];
let h_in = a.h_in.lerp(tin, t);
let h_out = a.h_out.lerp(tout, t);
sp.anchors[i] = Anchor::with_handles(p, h_in, h_out);
}
}
out
}
/// The Smooth slider's level Auto-Smooth picks, halfway between minimum and maximum smoothing.
/// VectorCraft's own rule: the help page says only that Auto-Smooth smooths the path automatically
/// (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html).
/// [`smooth_level`] already scales with each path's size, so one level suits every path.
pub const SMOOTH_AUTO_LEVEL: f64 = 50.0;
/// The refit Object › Path › Smooth makes of `path` at slider level `level` ([`smooth_level`]): a
/// corner only where the path turns back on itself, the path smoothed along its length by up to 5 %
/// of its longer side (none at level 0) and refitted within 0.1 % (level 0) to 2.5 % (level 100) of
/// it. A level outside 0..=100 counts as the nearer end, and one that is not a number as
/// [`SMOOTH_AUTO_LEVEL`]; a path of no finite size gets no smoothing.
pub fn smooth_options(path: &PathData, level: f64) -> SimplifyOptions {
let level = if level.is_finite() { level.clamp(0.0, 100.0) } else { SMOOTH_AUTO_LEVEL };
let size = path.bounds().map_or(0.0, |b| b.width().max(b.height()));
let size = if size.is_finite() { size } else { 0.0 };
SimplifyOptions {
tolerance: size * (0.001 + 0.024 * level / 100.0),
corner_angle_deg: SMOOTH_CORNER_DEG,
straight_lines: false,
smooth: size * 0.05 * level / 100.0,
}
}
/// Object › Path › Smooth at slider level `level`, from 0 (minimum smoothing: the path stays within
/// 0.1 % of its longer side of where it was) to 100 (maximum). The help page names only the two
/// ends; the rest is VectorCraft's own rule ([`smooth_options`]). Each subpath is smoothed along its
/// length, so that bumps and jags give way to one curve, then refitted with curves as Simplify
/// does, keeping a corner only where the path turns back on itself. A refit never has more anchor
/// points than the subpath had: a shape whose corners it can't round, such as a rectangle, stays as
/// it is. Closed subpaths stay closed, with no kink left where they start unless rounding it would
/// pass the tolerance, and open ones keep their end points.
pub fn smooth_level(path: &PathData, level: f64) -> PathData {
let opts = smooth_options(path, level);
if opts.tolerance <= 0.0 {
return path.clone();
}
let smoothed = |sp: &SubPath| {
let Some(mut fit) = simplify_subpath(sp, &opts).filter(|f| f != sp) else { return sp.clone() };
smooth_seam(&mut fit, sp, opts.tolerance);
fit
};
PathData::new(path.subpaths.iter().map(smoothed).collect())
}
/// [`smooth_level`] keeps a corner only where the path turns by more than this (turns back).
const SMOOTH_CORNER_DEG: f64 = 179.0;
/// A closed subpath's refit leaves its start anchor with the tangents of the original segments on
/// either side of it, a kink when the path had one there: turn it into a smooth anchor with the same
/// handle lengths, unless the path turns back there or the rounded path would stray from `original`
/// by more than `tol` (or than the refit already does).
fn smooth_seam(sp: &mut SubPath, original: &SubPath, tol: f64) {
if !sp.closed {
return;
}
let Some(a) = sp.anchors.first() else { return };
let (Some(i), Some(o)) = (crate::fit::unit(a.p - a.h_in), crate::fit::unit(a.h_out - a.p)) else { return };
if i.dot(o) < SMOOTH_CORNER_DEG.to_radians().cos() {
return;
}
let Some(dir) = crate::fit::unit(i + o) else { return };
let mut rounded = sp.clone();
if let Some(r) = rounded.anchors.first_mut() {
*r = Anchor::with_handles(a.p, a.p - dir * a.h_in.distance(a.p), a.p + dir * a.h_out.distance(a.p));
}
if seam_error(original, sp, &rounded, tol) <= tol.max(seam_error(original, sp, sp, tol)) {
*sp = rounded;
}
}
/// How far the segments on either side of the start anchor of `fit` (a refit of the closed
/// `original` whose other segments match `before`) are from `original`, both ways: the largest
/// distance from those segments to `original`, and from the parts of `original` that `before`'s
/// segments there cover (within `tol`) to `fit`.
fn seam_error(original: &SubPath, before: &SubPath, fit: &SubPath, tol: f64) -> f64 {
use kurbo::ParamCurveNearest;
let n = fit.segment_count();
if n == 0 || before.segment_count() != n || original.segment_count() == 0 {
return f64::INFINITY;
}
let is_seam = |j: usize| j == 0 || j + 1 == n;
let dist = |q: Point, sp: &SubPath, seam: bool| {
(0..sp.segment_count())
.filter(|&j| is_seam(j) == seam)
.map(|j| sp.segment(j).nearest(q, 1e-9).distance_sq)
.fold(f64::INFINITY, f64::min)
.sqrt()
};
let samples = |sp: &SubPath, seam_only: bool| {
let mut pts = Vec::new();
for j in (0..sp.segment_count()).filter(|&j| !seam_only || is_seam(j)) {
let c = sp.segment(j);
let m = ((measured_arclen(&PathSeg::Cubic(c), 1e-6) / (tol.max(1e-6) * 0.5)).ceil() as usize).clamp(4, 256);
sample(&c, m, &mut pts, true);
}
pts
};
let all = |q: Point| dist(q, original, true).min(dist(q, original, false));
let forward = samples(fit, true).into_iter().map(all).fold(0.0, f64::max);
let back = samples(original, false)
.into_iter()
.filter(|&q| dist(q, before, true) <= tol)
.map(|q| {
let d = dist(q, fit, true);
if d > tol { d.min(dist(q, fit, false)) } else { d }
})
.fold(0.0, f64::max);
forward.max(back)
}
/// Remove anchors that can be deleted without changing the shape by more than `tolerance`
/// (collinear corner points, duplicate points, and curve joints that one cubic can replace).
pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData {
let tol = tolerance.max(1e-9);
let mut out = Vec::new();
for sp in &path.subpaths {
let mut segs = subpath_segs(sp);
if segs.is_empty() {
out.push(sp.clone());
continue;
}
// Drop zero-length segments.
segs.retain(|s| !(s.c.p0.distance(s.c.p3) < 1e-9 && s.c.p1.distance(s.c.p0) < 1e-9 && s.c.p2.distance(s.c.p0) < 1e-9));
let min_segs = if sp.closed { 2 } else { 1 };
// Joints are merged along the path in one pass (a merged segment is tried with the one
// before it, then the next); the search below then only finds what a closed path's wrap
// leaves. (Merging at the first joint of a dense line one at a time shifts the rest each time.)
let total = segs.len();
let mut merged = 0;
let mut done: Vec<Seg> = Vec::with_capacity(total);
for s in segs.drain(..) {
done.push(s);
while done.len() >= 2 && total - merged > min_segs {
let n = done.len();
let Some(m) = merge_pair(&done[n - 2], &done[n - 1], tol) else { break };
done.truncate(n - 1);
done[n - 2] = m;
merged += 1;
}
}
segs = done;
let mut changed = true;
while changed && segs.len() > min_segs {
changed = false;
let n = segs.len();
let joints = if sp.closed { n } else { n - 1 };
for j in 0..joints {
let (i0, i1) = if sp.closed { ((j + n - 1) % n, j) } else { (j, j + 1) };
if let Some(m) = merge_pair(&segs[i0], &segs[i1], tol) {
segs[i0] = m;
segs.remove(i1);
if sp.closed && i1 == 0 {
// The merged segment now starts the loop.
if let Some(last) = segs.pop() {
segs.insert(0, last);
}
}
changed = true;
break;
}
}
}
if let Some(s) = segs_to_subpath(&segs, sp.closed) {
out.push(s);
}
}
PathData::new(out)
}
fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option<Seg> {
if a.line && b.line {
let chord = b.c.p3 - a.c.p0;
let len = chord.hypot();
if len < 1e-12 {
return None;
}
let mid = a.c.p3 - a.c.p0;
let along = mid.dot(chord) / len;
return ((mid.cross(chord) / len).abs() <= tol && along >= 0.0 && along <= len).then(|| Seg::line(a.c.p0, b.c.p3));
}
// Only merge across (near-)smooth joints.
if end_tangent(&a.c).dot(start_tangent(&b.c)) < 0.99 {
return None;
}
let mut pts = Vec::new();
sample(&a.c, 16, &mut pts, true);
sample(&b.c, 16, &mut pts, false);
let (c, err, _) = fit_single(&pts, start_tangent(&a.c), end_tangent(&b.c));
(err <= tol).then_some(Seg { c, line: false })
}
/// Remove anchor `index` without opening the path (the Delete Anchor Point tool and Object → Path
/// → Remove Anchor Points). Its neighbours keep their handle directions and their facing handles
/// are refitted so one cubic follows the two segments that met there; two straight segments
/// become one. Removing an end of an open path drops its segment. Returns false, leaving `sp`
/// alone, when `index` is out of range.
pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool {
let n = sp.anchors.len();
if index >= n {
return false;
}
if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 {
let (pi, ni) = ((index + n - 1) % n, (index + 1) % n);
if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) {
let (left, right) = (sp.segment(pi), sp.segment(index));
const N: usize = 16;
let mut pts = Vec::with_capacity(2 * N + 1);
sample(&left, N, &mut pts, true);
sample(&right, N, &mut pts, false);
let (t0, t1) = (start_tangent(&left), end_tangent(&right));
// The new curve passes the removed point at some parameter k: the left samples sit
// at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit.
let fit = |k: f64| {
let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect();
fit_single_from(&pts, u, t0, t1)
};
let (c, _, _) = golden_min(fit, 0.0, 1.0);
if c.p1.is_finite() && c.p2.is_finite() {
sp.anchors[pi].h_out = c.p1;
sp.anchors[ni].h_in = c.p2;
}
}
}
sp.anchors.remove(index);
if !sp.closed {
// A new end has nothing beyond it.
if let Some(a) = sp.anchors.first_mut() {
a.h_in = a.p;
}
if let Some(a) = sp.anchors.last_mut() {
a.h_out = a.p;
}
}
if sp.anchors.len() < 3 {
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
}
true
}
/// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a
/// golden-section search (a bounded number of steps).
fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) {
const R: f64 = 0.618_033_988_749_895;
let (mut x1, mut x2) = (b - R * (b - a), a + R * (b - a));
let (mut f1, mut f2) = (f(x1), f(x2));
for _ in 0..40 {
// A NaN error never wins.
if f1.1 < f2.1 || f2.1.is_nan() {
(b, x2, f2) = (x2, x1, f1);
x1 = b - R * (b - a);
f1 = f(x1);
} else {
(a, x1, f1) = (x1, x2, f2);
x2 = a + R * (b - a);
f2 = f(x2);
}
}
if f1.1 <= f2.1 { f1 } else { f2 }
}
/// Object → Path → Add Anchor Points: one new anchor at the middle (t = 0.5) of every segment.
pub fn add_anchor_points(path: &PathData) -> PathData {
let mut out = path.clone();
for sp in &mut out.subpaths {
let n = sp.segment_count();
if n == 0 {
continue;
}
// Built in one pass (inserting into the middle of the vector shifts the rest each time):
// anchor k lands at 2k and the new anchor of segment k at 2k + 1.
let mut v = Vec::with_capacity(sp.anchors.len() * 2);
for (k, a) in sp.anchors.iter().enumerate() {
v.push(*a);
if k < n {
v.push(*a);
}
}
for seg in 0..n {
let c = sp.segment(seg);
let (l, r) = (c.subsegment(0.0..0.5), c.subsegment(0.5..1.0));
if sp.segment_is_line(seg) {
v[2 * seg + 1] = Anchor::corner(l.p3);
} else {
let at = (2 * seg + 2) % v.len();
v[2 * seg].h_out = l.p1;
v[at].h_in = r.p2;
v[2 * seg + 1] = Anchor { p: l.p3, h_in: l.p2, h_out: r.p1, kind: AnchorKind::Smooth };
}
}
sp.anchors = v;
}
out
}
/// Axis for [`average`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AverageAxis {
/// Align on a common horizontal line (same y).
Horizontal,
/// Align on a common vertical line (same x).
Vertical,
/// Collapse to the common centroid.
Both,
}
/// Object → Path → Average: move the selected anchors `(subpath, anchor)` (with their handles)
/// to their average position along `axis`.
pub fn average(path: &PathData, selection: &[(usize, usize)], axis: AverageAxis) -> PathData {
let mut out = path.clone();
let pts: Vec<Point> = selection.iter().filter_map(|&(s, a)| path.subpaths.get(s).and_then(|sp| sp.anchors.get(a)).map(|a| a.p)).collect();
if pts.is_empty() {
return out;
}
let c = pts.iter().fold(Vec2::ZERO, |acc, p| acc + p.to_vec2()) / pts.len() as f64;
for &(s, a) in selection {
if let Some(an) = out.anchor_mut(s, a) {
let d = match axis {
AverageAxis::Horizontal => Vec2::new(0.0, c.y - an.p.y),
AverageAxis::Vertical => Vec2::new(c.x - an.p.x, 0.0),
AverageAxis::Both => c - an.p.to_vec2(),
};
an.translate(d);
}
}
out
}
/// Object → Path → Join. Open subpaths of all inputs are joined nearest-endpoint-first into one
/// open path (endpoints closer than `tolerance` are merged into a single anchor, otherwise a
/// straight segment connects them). A single open subpath is closed instead. Closed subpaths
/// pass through unchanged.
pub fn join(paths: &[PathData], tolerance: f64) -> PathData {
let mut closed: Vec<SubPath> = Vec::new();
let mut open: Vec<SubPath> = Vec::new();
for p in paths {
for sp in &p.subpaths {
if sp.closed || sp.anchors.len() < 2 {
if !sp.anchors.is_empty() {
closed.push(sp.clone());
}
} else {
open.push(sp.clone());
}
}
}
if open.len() == 1
&& let Some(mut sp) = open.pop()
{
let ends_meet = sp.anchors.len() > 2 && end_point(&sp, false).zip(end_point(&sp, true)).is_some_and(|(a, b)| a.distance(b) <= tolerance);
if ends_meet
&& let Some(last) = sp.anchors.pop()
&& let Some(f) = sp.anchors.first_mut()
{
*f = Anchor::with_handles(f.p, last.h_in, f.h_out);
} else {
if let Some(f) = sp.anchors.first_mut() {
f.h_in = f.p;
}
if let Some(l) = sp.anchors.last_mut() {
l.h_out = l.p;
}
}
sp.closed = true;
closed.push(sp);
return PathData::new(closed);
}
while open.len() > 1 {
// Find the closest pair of endpoints on different subpaths. Past this many subpaths every
// pair at every merge is too much (cubic): the first one is joined to its nearest.
const SEARCH_ALL: usize = 200;
let first_only = open.len() > SEARCH_ALL;
let mut best = (f64::INFINITY, 0, 0, false, false);
for i in 0..if first_only { 1 } else { open.len() } {
for j in (i + 1)..open.len() {
for ei in [false, true] {
for ej in [false, true] {
let (Some(pi), Some(pj)) = (end_point(&open[i], ei), end_point(&open[j], ej)) else { continue };
let d = pi.distance(pj);
if d < best.0 {
best = (d, i, j, ei, ej);
}
}
}
}
}
let (d, i, j, ei, ej) = best;
let mut b = open.remove(j);
let a = &mut open[i];
if !ei {
a.reverse();
}
if ej {
b.reverse();
}
if d <= tolerance && !b.anchors.is_empty() {
let first = b.anchors.remove(0);
if let Some(l) = a.anchors.last_mut() {
*l = Anchor::with_handles(l.p, l.h_in, first.h_out);
}
} else {
if let Some(l) = a.anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = b.anchors.first_mut() {
f.h_in = f.p;
}
}
a.anchors.extend(b.anchors);
}
closed.extend(open);
PathData::new(closed)
}
/// Object → Path → Split Into Grid: `rows × cols` rectangles filling `rect` with a gutter between
/// the rows and one between the columns (row-major, top-left first). No cells when they would have
/// no size.
pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, (row_gutter, col_gutter): (f64, f64)) -> Vec<PathData> {
if rows == 0 || cols == 0 {
return Vec::new();
}
let w = (rect.width() - col_gutter * (cols as f64 - 1.0)) / cols as f64;
let h = (rect.height() - row_gutter * (rows as f64 - 1.0)) / rows as f64;
// Written so that a size that is not a number gives no cells either.
if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) {
return Vec::new();
}
let mut out = Vec::with_capacity(rows * cols);
for r in 0..rows {
for c in 0..cols {
let x = rect.x0 + c as f64 * (w + col_gutter);
let y = rect.y0 + r as f64 * (h + row_gutter);
out.push(PathData::single(SubPath::polyline(
&[Point::new(x, y), Point::new(x + w, y), Point::new(x + w, y + h), Point::new(x, y + h)],
true,
)));
}
}
out
}
/// The first (`last == false`) or last anchor point of a subpath.
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}");
}
}
}