154 lines
5.2 KiB
Rust
154 lines
5.2 KiB
Rust
//! 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<Vec2> {
|
|
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<f64> {
|
|
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<Point>, 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));
|
|
}
|
|
}
|