//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average, //! Join, Split Into Grid. use std::collections::{BTreeMap, BTreeSet}; use kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, 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, /// With [`smooth`](Self::smooth), refit each stretch fitted as one piece (between corners) within /// twice the farthest the smoothing moved any of its points when that is less than `tolerance`, and /// never within less than a tenth of `tolerance`. A stretch that smoothing barely moves anywhere /// keeps its shape. The limit is the same along the whole stretch, so where smoothing moves some of /// its points far, all of it is refitted within up to `tolerance`, as without this option. pub smooth_moved_only: bool, } impl Default for SimplifyOptions { fn default() -> Self { Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0, smooth_moved_only: false } } } /// The least share of the tolerance that [`SimplifyOptions::smooth_moved_only`] refits within. const SMOOTH_MOVED_FLOOR: f64 = 0.1; /// Simplify with the default corner threshold (30°). pub fn simplify(path: &PathData, tolerance: f64) -> PathData { simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() }) } /// 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 { (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. A closed subpath /// whose refit is one segment, or lines that enclose no area, is refitted in two parts, cut at the /// point farthest from its start, or in three when two still give that. When three parts still give /// no area, straight lines follow the subpath within the tolerance, and without straight lines it /// stays as it is, as does one of a single segment. When no lines within the limits on pieces enclose /// an area, it stays as it is, or with straight lines follows the lines between samples of each /// curved segment when the limits leave room for lines within the tolerance; one that is all in line /// keeps the refit. fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let mut segs = subpath_segs(sp); let n = segs.len(); if n == 0 || sp.closed && n < 2 { 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 mut result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); let in_line = |pts: &[Point]| { let Some(&a) = pts.first() else { return true }; let far = pts.iter().map(|&p| p - a).max_by(|u, v| u.hypot2().total_cmp(&v.hypot2())).unwrap_or_default(); pts.iter().all(|&p| far.cross(p - a).abs() <= 1e-6 * far.hypot()) }; let flat = |r: &[Seg]| r.len() < 2 || r.iter().all(|s| s.line) && in_line(&r.iter().map(|s| s.c.p0).collect::>()); if sp.closed && flat(&result) { // Each segment in two, and the loop cut where they meet: first at the point farthest from its // start, then also at the point farthest from the line between those two. let halves: Vec = segs .iter() .flat_map(|s| { let (a, b) = s.c.subdivide(); [Seg { c: a, line: s.line }, Seg { c: b, line: s.line }] }) .collect(); let m = halves.len(); let at = |i: usize| halves[i].c.p0; let farthest = |from: &dyn Fn(Point) -> f64, skip: usize| (1..m).filter(|&i| i != skip).max_by(|&a, &b| from(at(a)).total_cmp(&from(at(b)))); let p0 = at(0); let j = farthest(&|p| p.distance(p0), 0).unwrap_or(m / 2); let chord = at(j) - p0; let off_chord = |p: Point| if chord.hypot2() > 0.0 { chord.cross(p - p0).abs() } else { p.distance(p0) }; let k = farthest(&off_chord, j).unwrap_or(m / 2); let refit = |cuts: &[usize]| -> Vec { cuts.windows(2).flat_map(|w| simplify_run_within(&halves[w[0]..w[1]], opts, MAX_RUN_SAMPLES / 3, None)).collect() }; result = refit(&[0, j, m]); if flat(&result) && k != j { result = refit(&[0, j.min(k), j.max(k), m]); } let ctrl: Vec = segs.iter().flat_map(|s| [s.c.p0, s.c.p1, s.c.p2]).collect(); if flat(&result) && !in_line(&ctrl) { // Each curved segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 pieces in all // with the line segments, until every piece is within the tolerance of the line between its // ends and those lines enclose an area: with straight lines they are the result, and without // them the loop stays as it is. When no split within those limits encloses an area, the loop // stays as it is, or with straight lines each curved segment becomes the lines between its // samples, up to `MAX_RUN_RESULT` lines in all with the line segments; when those do not keep // some curved segment within the tolerance, the loop stays as it is. A loop whose points are // all in line keeps the lines of the refit. let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance); let curved = segs.iter().filter(|s| !s.line).count(); for k in 1..=10u32 { let parts = 1u32 << k; if curved.saturating_mul(parts as usize).saturating_add(n - curved) > 1 << 16 { break; } let at = |i: u32| f64::from(i) / f64::from(parts); let pieces: Vec = segs .iter() .flat_map(|s| { let count = if s.line { 1 } else { parts }; (0..count).map(move |i| if s.line { s.c } else { s.c.subsegment(at(i)..at(i + 1)) }) }) .collect(); let lines: Vec = pieces.iter().map(|c| Seg::line(c.p0, c.p3)).collect(); if pieces.iter().all(near) && !flat(&lines) { if !opts.straight_lines { return Some(sp.clone()); } return segs_to_subpath(&lines, true); } } if !opts.straight_lines { return Some(sp.clone()); } let Some(room) = sample_room(curved, n - curved) else { return Some(sp.clone()); }; let mut lines: Vec = Vec::new(); for s in &segs { if s.line { lines.push(Seg::line(s.c.p0, s.c.p3)); continue; } let Some(steps) = steps_within(&s.c, opts.tolerance.max(1e-6), room) else { return Some(sp.clone()); }; let mut pts = Vec::new(); sample(&s.c, steps, &mut pts, true); lines.extend(pts.windows(2).map(|p| Seg::line(p[0], p[1]))); } return segs_to_subpath(&lines, true); } } let s = segs_to_subpath(&result, sp.closed)?; Some(if opts.straight_lines { s } else { no_worse(sp, s) }) } /// Most lines between samples of each of `curved` segments, so that they and the `kept` line segments /// make at most [`MAX_RUN_RESULT`] lines, and each has at most [`MAX_SEGMENT_SAMPLES`]. `None` when /// that leaves no line for some curved segment. fn sample_room(curved: usize, kept: usize) -> Option { let left = MAX_RUN_RESULT.checked_sub(kept)?; if curved > left { return None; } Some((left / curved.max(1)).min(MAX_SEGMENT_SAMPLES)) } /// How many evenly spaced lines along `c`, at most `room`, follow it: [`sample_steps`] when that /// fits, or fewer, but never fewer than keep `c` within `tol` of them. `None` when those do not fit /// in `room`. fn steps_within(c: &CubicBez, tol: f64, room: usize) -> Option { let need = line_steps(c, tol).max(1); (need <= room).then(|| sample_steps(c, tol).min(room).max(need)) } /// 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 { 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) -> Vec { let tol = opts.tolerance.max(1e-6); let mut cuts: Vec = (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 = 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`, given only when `part` is a whole closed loop, is the /// direction it leaves and arrives along; when the loop is smoothed, the smoothing runs across its /// start and the direction comes from the smoothed samples there. fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option, result: &mut Vec) { let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return }; let mut pts = Vec::new(); for (k, (s, steps)) in part.iter().enumerate() { sample(&s.c, *steps, &mut pts, k == 0); } let mut tol = tol; if opts.smooth > 0.0 { let smoothed = if ends.is_some() { smooth_loop_points(&pts, opts.smooth) } else { smooth_points(&pts, opts.smooth) }; if ends.is_some() && let (Some(&next), Some(&prev)) = (smoothed.get(1), smoothed.len().checked_sub(2).and_then(|i| smoothed.get(i))) { ends = unit(next - prev).or(ends); } if opts.smooth_moved_only { tol = tol.min((2.0 * smoothing_moved(&pts, &smoothed)).max(tol * SMOOTH_MOVED_FLOOR)); } pts = smoothed; } if opts.straight_lines { let keep = rdp(&pts, tol * (1.0 - SAMPLE_GAP)); 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. The fitted curves join // smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. The last // curve of a loop fitted across its start leaves along the loop's first segment. let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d)); let fitted = fit_cubics(&pts, t0, t1, tol * 0.98); let from = result.len(); let mut arrive = t0; for (k, &c) in fitted.iter().enumerate() { let leave = match fitted.get(k + 1) { Some(next) => start_tangent(next), None if ends.is_some() => result.get(from).map_or(t1, |s| start_tangent(&s.c)), None => t1, }; let line = is_straight(&c, tol * 0.02) && line_keeps_joins(&c, arrive, leave); arrive = if line { unit(c.p3 - c.p0).unwrap_or(arrive) } else { end_tangent(&c) }; result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); } } /// Most degrees between the line from a refitted curve's start to its end and the directions the /// path arrives and leaves along, for the curve to become that line: so every join next to such a /// line turns by at most this much. VectorCraft's own value. const LINE_JOIN_DEG: f64 = 0.5; /// Whether the line from `c.p0` to `c.p3` is within [`LINE_JOIN_DEG`] of `arrive`, the direction the /// path arrives at `c.p0` along, and of `leave`, the direction it leaves `c.p3` along. A curve whose /// ends are at one point, or a direction of no length, gives `false`. fn line_keeps_joins(c: &CubicBez, arrive: Vec2, leave: Vec2) -> bool { let (Some(dir), Some(arrive), Some(leave)) = (unit(c.p3 - c.p0), unit(arrive), unit(leave)) else { return false }; let min = LINE_JOIN_DEG.to_radians().cos(); dir.dot(arrive) >= min && dir.dot(leave) >= min } /// `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 { smooth_points_counted(pts, sigma, &mut 0) } /// [`smooth_points`] for the samples `pts` of a whole closed loop, whose last sample is its first: /// the samples at its start are smoothed like the others, and the first and last ones move together. /// The loop is smoothed twice as an open run of the same samples, once from its start and once from /// the sample nearest halfway round by length, where [`smooth_points`] keeps each run's ends in place. /// Each sample takes a blend of the two that runs smoothly round the loop, with weights /// `(1 ± cos θ) / 2`: `θ` goes from 0 at the start to π at the halfway sample and on to 2π at the end, /// in proportion to length, so the start takes only the second run and the halfway sample only the /// first, and no sample takes any of a run's fixed ends. fn smooth_loop_points(pts: &[Point], sigma: f64) -> Vec { let Some(m) = pts.len().checked_sub(1).filter(|&m| m >= 3) else { return smooth_points(pts, sigma) }; let mut along = Vec::with_capacity(pts.len()); let mut total = 0.0; for (i, p) in pts.iter().enumerate() { if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) { total += prev.distance(*p); } along.push(total); } if !(total.is_finite() && total > 0.0) { return smooth_points(pts, sigma); } let half = (1..m).min_by(|&a, &b| (along[a] - total / 2.0).abs().total_cmp(&(along[b] - total / 2.0).abs())).unwrap_or(m / 2); let mid = along[half]; if !(mid > 0.0 && mid < total) { return smooth_points(pts, sigma); } let ring = &pts[..m]; let turned: Vec = ring[half..].iter().chain(&ring[..=half]).copied().collect(); let from_half = smooth_points(&turned, sigma); let mut out = smooth_points(pts, sigma); for (i, (p, &s)) in out.iter_mut().zip(&along).enumerate() { // Sample `i` of the loop is sample `i - half` of `turned`, round the loop. let Some(&q) = from_half.get((i + m - half) % m) else { continue }; let theta = if s <= mid { s / mid } else { 1.0 + (s - mid) / (total - mid) } * std::f64::consts::PI; let w = 0.5 * (1.0 + theta.cos()); *p = p.lerp(q, w); } if let Some(&start) = out.first() && let Some(end) = out.last_mut() { *end = start; } out } /// How far [`smooth_points`] moved the samples `pts` to `smoothed` (the same number of points, in /// order) across the path: the largest distance from a smoothed point to the lines between the /// samples up to two on either side of the one it came from. fn smoothing_moved(pts: &[Point], smoothed: &[Point]) -> f64 { smoothed .iter() .enumerate() .map(|(i, &q)| { let own = pts.get(i).map_or(f64::INFINITY, |p| p.distance(q)); let (lo, hi) = (i.saturating_sub(2), (i + 2).min(pts.len().saturating_sub(1))); (lo..hi).filter_map(|j| Some(Line::new(*pts.get(j)?, *pts.get(j + 1)?).nearest(q, 1e-9).distance_sq.sqrt())).fold(own, f64::min) }) .fold(0.0, f64::max) } /// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups /// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one /// by one. 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 { 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, /// Sum of each node's samples, measured from the first sample. sum_q: Vec, } 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, 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 = (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, 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 = (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 { 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, smooth_moved_only: false, } } /// 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 = 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 { 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 { refit_across(&mut sp.anchors, (index + n - 1) % n, index, (index + 1) % n); } #[cfg(test)] tests::count_moves(n - index - 1); 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 } /// Refit the facing handles of anchors `pi` and `ni` so one cubic follows the two segments that /// meet at anchor `index` between them ([`remove_anchor`]). Two straight segments are left as /// they are, and so are the handles when the fit is not finite. fn refit_across(anchors: &mut [Anchor], pi: usize, index: usize, ni: usize) { let (Some(&a), Some(&b), Some(&c)) = (anchors.get(pi), anchors.get(index), anchors.get(ni)) else { return }; let line = |a: &Anchor, b: &Anchor| !a.has_out() && !b.has_in(); if line(&a, &b) && line(&b, &c) { return; } let (left, right) = (CubicBez::new(a.p, a.h_out, b.h_in, b.p), CubicBez::new(b.p, b.h_out, c.h_in, c.p)); 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 (curve, _, _) = golden_min(fit, 0.0, 1.0); if curve.p1.is_finite() && curve.p2.is_finite() { anchors[pi].h_out = curve.p1; anchors[ni].h_in = curve.p2; } } /// [`remove_anchor`] for each of `indexes`, the last first, with the same result, moving each /// anchor that stays at most once: removing many anchors from a long subpath one at a time shifts /// the rest of it each time. Indexes out of range and repeats are skipped. Returns how many /// anchors were removed. pub fn remove_anchors(sp: &mut SubPath, indexes: &[usize]) -> usize { let total = sp.anchors.len(); let mut targets: Vec = indexes.iter().copied().filter(|&i| i < total).collect(); targets.sort_unstable_by(|a, b| b.cmp(a)); targets.dedup(); if targets.is_empty() { return 0; } // Every anchor below the one being removed is still there, so it keeps its index, the one // before it is `index - 1` and the first one left is anchor 0 until anchor 0 itself goes. let mut gone = vec![false; total]; let mut len = total; let mut last = total - 1; // The lowest anchor removed so far and the first one left after it. let (mut lowest, mut after_lowest) = (total, None); for &index in &targets { let next = if index + 1 < lowest { Some(index + 1) } else { after_lowest }; if (sp.closed || (index > 0 && next.is_some())) && len >= 3 { let pi = if index > 0 { index - 1 } else { last }; refit_across(&mut sp.anchors, pi, index, next.unwrap_or(0)); } gone[index] = true; len -= 1; (lowest, after_lowest) = (index, next); if next.is_none() { last = index.saturating_sub(1); } if len == 0 { continue; } let first = if index > 0 { 0 } else { next.unwrap_or(0) }; if !sp.closed { // A new end has nothing beyond it. if let Some(a) = sp.anchors.get_mut(first) { a.h_in = a.p; } if let Some(a) = sp.anchors.get_mut(last) { a.h_out = a.p; } } if len < 3 { let ends = [first, last].map(|i| sp.anchors.get(i).is_some_and(|a| a.has_in() || a.has_out())); sp.closed = sp.closed && len == 2 && (ends[0] || ends[1]); } } if len == 0 { sp.closed = false; } let mut kept = 0; for (i, &g) in gone.iter().enumerate() { if !g { if kept != i { sp.anchors[kept] = sp.anchors[i]; #[cfg(test)] tests::count_moves(1); } kept += 1; } } sp.anchors.truncate(kept); targets.len() } /// 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 = 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 = Vec::new(); let mut open: Vec = 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 { 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 { if last { sp.anchors.last() } else { sp.anchors.first() }.map(|a| a.p) } #[cfg(test)] mod tests { use super::*; std::thread_local! { /// Anchors moved within a subpath's anchor list by [`remove_anchor`] and [`remove_anchors`]. static MOVES: std::cell::Cell = const { std::cell::Cell::new(0) }; } pub(super) fn count_moves(n: usize) { MOVES.set(MOVES.get() + n); } /// A subpath of `n` anchors from `seed`: corners, smooth points and points with one handle. fn random_subpath(seed: &mut u64, n: usize, closed: bool) -> SubPath { let mut rnd = || { *seed ^= *seed << 13; *seed ^= *seed >> 7; *seed ^= *seed << 17; *seed }; let anchors = (0..n) .map(|i| { let p = Point::new(i as f64 * 20.0 + (rnd() % 100) as f64 / 10.0, (rnd() % 400) as f64 / 4.0); let off = Vec2::new((rnd() % 300) as f64 / 10.0 - 15.0, (rnd() % 300) as f64 / 10.0 - 15.0); match rnd() % 4 { 0 => Anchor::corner(p), 1 => Anchor::smooth(p, p + off), 2 => Anchor::with_handles(p, p, p + off), _ => Anchor::with_handles(p, p - off, p), } }) .collect(); SubPath::new(anchors, closed) } /// `sp` with [`remove_anchor`] run for each of `indexes`, the last first. fn one_at_a_time(sp: &SubPath, indexes: &[usize]) -> SubPath { let mut order = indexes.to_vec(); order.sort_unstable_by(|a, b| b.cmp(a)); order.dedup(); let mut out = sp.clone(); for i in order { remove_anchor(&mut out, i); } out } fn assert_same_bits(got: &SubPath, want: &SubPath, what: &str) { let bits = |sp: &SubPath| -> Vec<[u64; 6]> { sp.anchors.iter().map(|a| [a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits)).collect() }; assert_eq!(got.closed, want.closed, "closed: {what}"); assert_eq!(bits(got), bits(want), "{what}"); assert_eq!(got.anchors.iter().map(|a| a.kind).collect::>(), want.anchors.iter().map(|a| a.kind).collect::>(), "{what}"); } #[test] fn removing_anchors_together_gives_what_removing_them_one_at_a_time_gives() { let mut seed = 0x9e37_79b9_7f4a_7c15_u64; for case in 0..600 { let n = case % 40 + 1; let closed = case % 2 == 0; let sp = random_subpath(&mut seed, n, closed); seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; // Up to n + 2 picks over 0..n + 2: runs of neighbors, both ends, everything, repeats // and indexes past the end. let picks = (seed % (n as u64 + 3)) as usize; let mut indexes: Vec = (0..picks).map(|k| ((seed >> (k % 48)) as usize).wrapping_add(k * 7) % (n + 2)).collect(); if case % 5 == 0 { indexes = (0..n).collect(); } let mut got = sp.clone(); let removed = remove_anchors(&mut got, &indexes); let want = one_at_a_time(&sp, &indexes); let what = format!("case {case}: {n} anchors, closed {closed}, removing {indexes:?}"); assert_same_bits(&got, &want, &what); assert_eq!(removed, sp.anchors.len() - want.anchors.len(), "{what}"); } } #[test] fn removing_anchors_together_keeps_each_step_of_one_at_a_time() { let curve = |p: Point, d: f64| Anchor::smooth(p, p + Vec2::new(d, 25.0)); let pts = [Point::new(0.0, 0.0), Point::new(50.0, 40.0), Point::new(100.0, 0.0), Point::new(150.0, 40.0), Point::new(200.0, 0.0)]; let cases: Vec<(SubPath, Vec)> = vec![ // A refit at one removal makes the next segment curved, which the next removal refits. ( SubPath::new( vec![Anchor::corner(pts[0]), Anchor::corner(pts[1]), curve(pts[2], 20.0), Anchor::corner(pts[3]), Anchor::corner(pts[4])], false, ), vec![1, 2, 3], ), // The last anchor of an open path goes, then the one before it becomes the end. (SubPath::new(pts.iter().map(|&p| curve(p, 15.0)).collect(), false), vec![2, 3, 4]), // The first anchor of a closed path refits across the wrap. (SubPath::new(pts.iter().map(|&p| curve(p, -10.0)).collect(), true), vec![0, 4]), // A closed path down to two anchors stays closed with a handle, and opens without one. (SubPath::new(pts.iter().map(|&p| curve(p, 10.0)).collect(), true), vec![1, 2, 3]), (SubPath::polyline(&pts, true), vec![1, 2, 3]), (SubPath::polyline(&pts, true), vec![0, 1, 2, 3]), (SubPath::polyline(&pts[..1], false), vec![0]), (SubPath::new(vec![], true), vec![0]), ]; for (sp, indexes) in cases { let mut got = sp.clone(); remove_anchors(&mut got, &indexes); assert_same_bits(&got, &one_at_a_time(&sp, &indexes), &format!("{sp:?} removing {indexes:?}")); } } #[test] fn removing_anchors_together_moves_each_anchor_left_at_most_once() { let n = 2001; let pts: Vec = (0..n).map(|i| Point::new(i as f64, (i % 2) as f64)).collect(); let mut sp = SubPath::polyline(&pts, false); let every_other: Vec = (1..n).step_by(2).collect(); MOVES.set(0); assert_eq!(remove_anchors(&mut sp, &every_other), n / 2); assert_eq!(sp.anchors.len(), n - n / 2); let moves = MOVES.get(); assert!(moves < n, "{moves} anchors moved to remove {} of {n}", n / 2); } /// 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, 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 = (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 { 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()); } /// A spike on a closed loop whose samples are ten times denser on one half than on the other is /// smoothed out wherever it is, including at the sample halfway through the list, which is only /// about a quarter of the way round by length. #[test] fn loop_smoothing_smooths_a_spike_on_unevenly_sampled_loops() { let c = Point::new(300.0, 300.0); let on = |a: f64, r: f64| c + r * Vec2::from_angle(a); let mut pts: Vec = (0..300).map(|i| on(f64::from(i) / 300.0 * std::f64::consts::PI, 100.0)).collect(); pts.extend((0..30).map(|i| on(std::f64::consts::PI * (1.0 + f64::from(i) / 30.0), 100.0))); let m = pts.len(); pts.push(pts[0]); for spike in [m / 2, 0, 100] { let mut p = pts.clone(); p[spike] = on(std::f64::consts::PI * spike as f64 / 300.0, 105.0); if spike == 0 { p[m] = p[0]; } let out = smooth_loop_points(&p, 10.0); assert_eq!(out.len(), p.len()); assert_eq!(out[0], out[m], "spike at {spike}: the loop stays closed"); let left = out[spike].distance(c) - 100.0; assert!(left < 1.0, "spike at {spike}: {left} pt of 5 pt left"); } } #[test] fn the_samples_of_a_flat_loop_and_its_line_segments_stay_within_the_result_limit() { for (curved, kept) in [(1, 0), (32, 1), (40, 1_000_000), (1_000, MAX_RUN_RESULT - 1_000), (3, MAX_RUN_RESULT - 7)] { let room = sample_room(curved, kept).unwrap_or_else(|| panic!("{curved} curved, {kept} kept: no room")); assert!((1..=MAX_SEGMENT_SAMPLES).contains(&room), "{curved} curved, {kept} kept: room {room}"); assert!(curved * room + kept <= MAX_RUN_RESULT, "{curved} curved, {kept} kept: room {room}"); } assert_eq!(sample_room(32, 0), Some(MAX_SEGMENT_SAMPLES)); assert_eq!(sample_room(2, MAX_RUN_RESULT - 1), None); assert_eq!(sample_room(1, MAX_RUN_RESULT + 5), None); } #[test] fn lines_with_too_little_room_for_the_tolerance_are_not_used() { let c = CubicBez::new((0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0)); let tol = 0.01; let need = line_steps(&c, tol); assert!(need > 4, "{need}"); assert_eq!(steps_within(&c, tol, need - 1), None); for room in [need, need + 3, MAX_SEGMENT_SAMPLES] { let steps = steps_within(&c, tol, room).unwrap_or_else(|| panic!("room {room}: no steps")); assert!((need..=room).contains(&steps), "room {room}: {steps} steps"); let mut pts = Vec::new(); sample(&c, steps, &mut pts, true); let lines: Vec = pts.windows(2).map(|p| Line::new(p[0], p[1])).collect(); for i in 0..=1000 { let p = c.eval(f64::from(i) / 1000.0); let gap = lines.iter().map(|l| l.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); assert!(gap <= tol + 1e-9, "room {room}, {steps} steps: {gap} at {i}"); } } } #[test] fn smoothing_points_whose_arc_length_overflows_returns_every_point() { let pts: Vec = (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 = (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}"); } } }