//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average, //! Join, Split Into Grid. use kurbo::{ParamCurveArclen, Point, Rect, Vec2}; use vectorcraft_geom::{Anchor, PathData, SubPath}; use crate::boolean::{Seg, segs_to_subpath}; use crate::fit::{end_tangent, fit_cubics, fit_single, is_straight, sample, start_tangent}; /// 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 (Illustrator's "Convert to Straight Lines"). pub straight_lines: bool, } impl Default for SimplifyOptions { fn default() -> Self { Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false } } } /// Simplify with the default corner threshold (30°). pub fn simplify(path: &PathData, tolerance: f64) -> PathData { simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() }) } /// 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 tol = opts.tolerance.max(1e-6); let mut out = Vec::new(); for sp in &path.subpaths { let mut segs = subpath_segs(sp); let n = segs.len(); if n == 0 { out.push(sp.clone()); continue; } let corner_at = |segs: &[Seg], i: usize| -> bool { if !sp.closed && (i == 0 || i == n) { return true; } turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg }; let mut corners: Vec = (0..n).filter(|&i| corner_at(&segs, i)).collect(); if sp.closed { if let Some(&k) = corners.first() { segs.rotate_left(k); corners = corners.iter().map(|&c| c - k).collect(); } else { corners = vec![0]; } corners.push(n); } else if corners.last() != Some(&n) { corners.push(n); } let mut result: Vec = Vec::new(); for w in corners.windows(2) { let run = &segs[w[0]..w[1]]; if run.is_empty() { continue; } let mut pts = Vec::new(); for (k, s) in run.iter().enumerate() { let len = s.c.arclen(1e-6); let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); sample(&s.c, m, &mut pts, k == 0); } if opts.straight_lines { let keep = rdp(&pts, tol); result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); continue; } let t0 = start_tangent(&run[0].c); let t1 = end_tangent(&run[run.len() - 1].c); for c in fit_cubics(&pts, t0, t1, tol) { let line = is_straight(&c, tol * 0.02); result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); } } if let Some(s) = segs_to_subpath(&result, sp.closed) { out.push(if opts.straight_lines { s } else { no_worse(sp, s) }); } } PathData::new(out) } /// Ramer–Douglas–Peucker polyline reduction. 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)]; while let Some((a, b)) = stack.pop() { let (pa, pb) = (pts[a], pts[b]); let chord = pb - pa; let len = chord.hypot(); let mut best = (0.0, a); for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) { let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len }; 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 } /// 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 }; 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. let last = segs.pop().expect("non-empty"); 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 }) } /// 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 { for seg in (0..sp.segment_count()).rev() { sp.insert_anchor(seg, 0.5); } } 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 mut sp = open.pop().expect("one"); let n = sp.anchors.len(); if n > 2 && sp.anchors[0].p.distance(sp.anchors[n - 1].p) <= tolerance { let last = sp.anchors.pop().expect("n > 2"); let f = &mut sp.anchors[0]; *f = Anchor::with_handles(f.p, last.h_in, f.h_out); } else { let f = &mut sp.anchors[0]; f.h_in = f.p; let l = sp.anchors.last_mut().expect("n >= 2"); 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. let mut best = (f64::INFINITY, 0, 0, false, false); for i in 0..open.len() { for j in (i + 1)..open.len() { for ei in [false, true] { for ej in [false, true] { let pi = if ei { open[i].anchors.last() } else { open[i].anchors.first() }.expect("len>=2").p; let pj = if ej { open[j].anchors.last() } else { open[j].anchors.first() }.expect("len>=2").p; 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 { let first = b.anchors.remove(0); let l = a.anchors.last_mut().expect("len>=2"); *l = Anchor::with_handles(l.p, l.h_in, first.h_out); } else { let l = a.anchors.last_mut().expect("len>=2"); l.h_out = l.p; b.anchors[0].h_in = b.anchors[0].p; } a.anchors.extend(b.anchors); } closed.extend(open); PathData::new(closed) } /// Object → Path → Split Into Grid: `rows × cols` rectangles filling `rect` with `gutter` spacing /// (row-major, top-left first). pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec { if rows == 0 || cols == 0 { return Vec::new(); } let w = (rect.width() - gutter * (cols as f64 - 1.0)) / cols as f64; let h = (rect.height() - gutter * (rows as f64 - 1.0)) / rows as f64; if w <= 0.0 || h <= 0.0 { 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 + gutter); let y = rect.y0 + r as f64 * (h + 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 }