//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code, //! MIT OR Apache-2.0). //! //! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end //! tangents, then reparameterise with Newton steps, then split at the worst point" approach from //! Graphics Gems, written from the published description). use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2}; /// Maximum Newton reparameterisation rounds per fit. const MAX_REPARAM: usize = 6; /// Unit vector or `None` if (nearly) zero. pub(crate) fn unit(v: Vec2) -> Option { let l = v.hypot(); if l > 1e-12 && l.is_finite() { Some(v / l) } else { None } } /// Direction the curve leaves `p0` (robust to retracted handles). pub(crate) fn start_tangent(c: &CubicBez) -> Vec2 { unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) } /// Direction the curve arrives at `p3`, i.e. pointing *forward* along the travel direction. pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 { unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) } /// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of /// the worst point). pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) { let mut u = chord_params(pts); let mut best = generate(pts, &u, t0, t1); let (mut best_err, mut best_split) = max_error(pts, &best, &u); for _ in 0..MAX_REPARAM { for (i, ui) in u.iter_mut().enumerate() { *ui = newton(&best, pts[i], *ui); } let c = generate(pts, &u, t0, t1); let (e, s) = max_error(pts, &c, &u); if e < best_err { best = c; best_err = e; best_split = s; } else { break; } } (best, best_err, best_split) } fn chord_params(pts: &[Point]) -> Vec { let mut u = Vec::with_capacity(pts.len()); let mut acc = 0.0; u.push(0.0); for w in pts.windows(2) { acc += w[0].distance(w[1]); u.push(acc); } if acc > 0.0 { for x in &mut u { *x /= acc; } } u } fn bern(t: f64) -> [f64; 4] { let s = 1.0 - t; [s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t] } /// Solve the 2x2 normal equations for the handle lengths along the fixed tangents. fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez { let (Some(&p0), Some(&p3)) = (pts.first(), pts.last()) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) }; let dir_in = -t1; // handle at the end points backwards let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0); for (p, &t) in pts.iter().zip(u) { let b = bern(t); let a0 = t0 * b[1]; let a1 = dir_in * b[2]; c00 += a0.dot(a0); c01 += a0.dot(a1); c11 += a1.dot(a1); let tmp = *p - (p0.to_vec2() * (b[0] + b[1]) + p3.to_vec2() * (b[2] + b[3])).to_point(); x0 += a0.dot(tmp); x1 += a1.dot(tmp); } let det = c00 * c11 - c01 * c01; let seg = p0.distance(p3); let eps = 1e-6 * seg; let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (seg / 3.0, seg / 3.0) }; if !al.is_finite() || !ar.is_finite() || al < eps || ar < eps { al = seg / 3.0; ar = seg / 3.0; } // Guard against wild handles on nearly-degenerate data. let cap = seg * 4.0 + 1e-9; al = al.min(cap); ar = ar.min(cap); CubicBez::new(p0, p0 + t0 * al, p3 + dir_in * ar, p3) } fn max_error(pts: &[Point], c: &CubicBez, u: &[f64]) -> (f64, usize) { let mut e = 0.0; let mut idx = pts.len() / 2; for (i, (p, &t)) in pts.iter().zip(u).enumerate() { let d = c.eval(t).distance(*p); if d > e { e = d; idx = i; } } (e, idx) } fn newton(c: &CubicBez, p: Point, t: f64) -> f64 { let d1 = c.deriv(); let d2 = d1.deriv(); let q = c.eval(t) - p; let q1 = d1.eval(t).to_vec2(); let q2 = d2.eval(t).to_vec2(); let num = q.dot(q1); let den = q1.dot(q1) + q.dot(q2); if den.abs() < 1e-12 { return t; } let nt = t - num / den; if nt.is_finite() { nt.clamp(0.0, 1.0) } else { t } } /// Is the cubic (within `tol`) a straight line from p0 to p3 with handles inside the chord? pub(crate) fn is_straight(c: &CubicBez, tol: f64) -> bool { let chord = c.p3 - c.p0; let len = chord.hypot(); if len < 1e-12 { return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol; } let dir = chord / len; [c.p1, c.p2].iter().all(|&h| { let v = h - c.p0; let along = v.dot(dir); v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol }) } /// Sample a cubic at `n+1` evenly spaced parameters (including both ends). pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec, include_start: bool) { let start = if include_start { 0 } else { 1 }; for i in start..=n { out.push(c.eval(i as f64 / n as f64)); } }