Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)

Thanks @midasdf.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-16 06:26:27 -04:00
1 parent 3126962cd4
commit bbd4f29ebb
43 files changed
+4753 -132

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
//! Blend curves: one Bézier that joins two curves with a requested continuity order, by Hermite
//! interpolation of each reference curve's position and derivatives at the joints. This is the
//! geometry `sketch.blend_curve` builds; it knows nothing about sketches or the document.
use crate::{MAX_DERIVATIVE_ORDER, Vec2, profile::binom};
/// Control points of the Bézier of degree `2k + 1`, `k = d0.len()`, that starts at `p0` with
/// derivatives `d0` and ends at `p1` with derivatives `d1`, all with respect to its own parameter
/// on [0, 1]. The derivative vectors are the reference curves' at the joints, measured in their
/// own parameters and oriented the way the blend runs through the joint, so the blend agrees with
/// each reference through its k-th derivative (G1, G2, G3, G4 for k = 1…4).
///
/// The two ends fix exactly half of the controls each: a Bézier's j-th forward difference is
/// `P⁽ʲ⁾(0)·(n − j)!/n!`, so the first `k + 1` controls follow from the start data and the last
/// `k + 1` from the end data, which meet exactly at the middle control. `None` when the ends
/// disagree on the order, the order is above [`MAX_DERIVATIVE_ORDER`], or a value is not finite.
pub fn hermite_bezier(p0: Vec2, p1: Vec2, d0: &[Vec2], d1: &[Vec2]) -> Option<Vec<Vec2>> {
let k = d0.len();
if k != d1.len() || k > MAX_DERIVATIVE_ORDER || !p0.is_finite() || !p1.is_finite() || d0.iter().chain(d1).any(|d| !d.is_finite()) {
return None;
}
let n = 2 * k + 1;
let mut c = vec![Vec2::ZERO; n + 1];
c[0] = p0;
for j in 1..=k {
// `Δʲp₀ = P⁽ʲ⁾(0)·(n − j)!/n!`, and solving it for `pⱼ` puts `(−1)^(j − i + 1)` on every
// control already known.
let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64);
let mut value = d0[j - 1] * scale;
for (i, p) in c.iter().take(j).enumerate() {
value += *p * (odd_sign(j - i) * binom(j, i));
}
c[j] = value;
}
c[n] = p1;
for j in 1..=k {
// The same at the far end, reading the controls backwards: `Σᵢ(−1)ⁱ·C(j,i)·pₙ₋ᵢ` is
// `P⁽ʲ⁾(1)·(n − j)!/n!`, and solving that for `pₙ₋ⱼ` puts `(−1)^(j + i + 1)` on every control
// already known.
let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64);
let mut value = d1[j - 1] * (scale * odd_sign(j + 1));
for (i, p) in c[n - j + 1..].iter().rev().enumerate() {
value += *p * (odd_sign(j + i) * binom(j, i));
}
c[n - j] = value;
}
c.iter().all(|p| p.is_finite()).then_some(c)
}
/// `(−1)^(m + 1)`: the sign an m-th difference expansion carries in a Bézier's endpoint identity.
fn odd_sign(m: usize) -> f64 {
if m % 2 == 1 { 1.0 } else { -1.0 }
}
+3 -1
View File
@@ -9,6 +9,7 @@
#![forbid(unsafe_code)]
mod arcfit;
pub mod blend;
pub mod brep;
pub mod bspline;
pub mod compare;
@@ -23,9 +24,10 @@ pub mod tspline;
mod vec;
pub use arcfit::MAX_FIT_PIECES;
pub use blend::hermite_bezier;
pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
pub use plane::Plane;
pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier, regions_apart};
pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart};
pub use vec::{Vec2, Vec3};
/// Geometric tolerance for coincidence tests (mm).
+131 -10
View File
@@ -38,26 +38,35 @@ pub enum Seg2 {
b: Vec2,
w: f64,
},
/// Bézier of degree `n` (4…7) over `p[0..=n]` (the rest unused): pieces of higher-degree
/// splines.
/// Bézier of degree `n` (4…[`MAX_BEZIER_DEGREE`]) over `p[0..=n]` (the rest unused): pieces of
/// higher-degree splines.
Bezier {
n: u8,
p: [Vec2; 8],
p: [Vec2; MAX_BEZIER_CTRL],
},
}
/// Highest degree of [`Seg2::Bezier`].
pub const MAX_BEZIER_DEGREE: usize = 7;
/// Highest degree of [`Seg2::Bezier`]: a degree-9 piece is the single Bézier a G4 blend curve
/// (`sketch.blend_curve` with `continuity: "g4"`) is made of.
pub const MAX_BEZIER_DEGREE: usize = 9;
/// A Bézier segment over `pts` (2…8 points): a line, a cubic or a higher-degree piece. A
/// quadratic becomes a conic of weight 1.
/// Control points a [`Seg2::Bezier`] carries: its highest degree plus one (the unused tail is
/// zero).
pub const MAX_BEZIER_CTRL: usize = MAX_BEZIER_DEGREE + 1;
/// Highest derivative order [`Seg2::derivatives`] hands out, and therefore the highest
/// continuity order a blend curve can be built for (4 = G4).
pub const MAX_DERIVATIVE_ORDER: usize = 4;
/// A Bézier segment over `pts` (2…[`MAX_BEZIER_CTRL`] points): a line, a cubic or a higher-degree
/// piece. A quadratic becomes a conic of weight 1.
pub fn bezier(pts: &[Vec2]) -> Option<Seg2> {
Some(match *pts {
[a, b] => Seg2::Line { a, b },
[a, apex, b] => Seg2::Conic { a, apex, b, w: 1.0 },
[p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 },
_ if pts.len() <= MAX_BEZIER_DEGREE + 1 => {
let mut p = [Vec2::ZERO; 8];
_ if (2..=MAX_BEZIER_CTRL).contains(&pts.len()) => {
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
for (d, s) in p.iter_mut().zip(pts) {
*d = *s;
}
@@ -86,6 +95,38 @@ fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec<Vec2>, Vec<Vec2>) {
(left.last().copied().unwrap_or(Vec2::ZERO), left, right)
}
/// Binomial coefficient `C(n, k)` for the small orders the derivative rules need (0 when
/// `k > n`).
pub(crate) fn binom(n: usize, k: usize) -> f64 {
if k > n {
return 0.0;
}
(0..k.min(n - k)).fold(1.0, |v, i| v * (n - i) as f64 / (i + 1) as f64)
}
/// Derivatives 1…`order` of the Bézier over `c` (degree `c.len() - 1`) at `t`: the j-th forward
/// difference of the control polygon, scaled by `n!/(n − j)!` and evaluated by de Casteljau.
/// Exact in exact arithmetic, zero above the degree, `None` for non-finite input.
fn bezier_derivatives(c: &[Vec2], t: f64, order: usize) -> Option<Vec<Vec2>> {
if !t.is_finite() || c.is_empty() || c.iter().any(|p| !p.is_finite()) {
return None;
}
let n = c.len() - 1;
let mut diffs = c.to_vec();
let mut scale = 1.0;
let mut out = Vec::with_capacity(order);
for j in 1..=order {
if diffs.len() < 2 {
out.push(Vec2::ZERO);
continue;
}
diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect();
scale *= (n + 1 - j) as f64;
out.push(casteljau(&diffs, t).0 * scale);
}
Some(out)
}
/// Gauss–Legendre nodes and weights on [0, 1] (5 points).
const GL5: [(f64, f64); 5] = [
(0.046_910_077_030_668, 0.118_463_442_528_095),
@@ -96,7 +137,7 @@ const GL5: [(f64, f64); 5] = [
];
impl Seg2 {
fn bez(n: u8, p: &[Vec2; 8]) -> &[Vec2] {
fn bez(n: u8, p: &[Vec2; MAX_BEZIER_CTRL]) -> &[Vec2] {
&p[..(n as usize).clamp(1, MAX_BEZIER_DEGREE) + 1]
}
/// Derivative with respect to the parameter at `t` (Béziers and conics; lines and arcs too).
@@ -121,6 +162,86 @@ impl Seg2 {
}
}
}
/// Derivatives 1…`order` with respect to the segment's own parameter at `t`, exactly: a line
/// and an arc from their analytic form (an arc's are the circular-motion recurrence, zero
/// curvature terms included), a cubic and a higher-degree piece from the forward differences
/// of their controls, a conic through the quotient rule of its rational form. A blend curve
/// reads a reference curve's derivatives here to build a Hermite interpolation, so this is
/// exact at both ends (`t` = 0 and 1) and `None` — never a wrong number — when `order` is
/// above [`MAX_DERIVATIVE_ORDER`], `t` is not finite or a value is not finite, as with a
/// degenerate rational piece.
pub fn derivatives(&self, t: f64, order: usize) -> Option<Vec<Vec2>> {
if !t.is_finite() || order > MAX_DERIVATIVE_ORDER {
return None;
}
let mut d = match *self {
Seg2::Line { a, b } => {
if !a.is_finite() || !b.is_finite() {
return None;
}
let mut d = vec![b - a];
d.resize(MAX_DERIVATIVE_ORDER, Vec2::ZERO);
d
}
Seg2::Arc { center, radius, start, sweep } => {
if !center.is_finite() || !radius.is_finite() || !start.is_finite() || !sweep.is_finite() {
return None;
}
// Differentiating the radial direction twice returns its negative, so the
// directions of the successive derivatives are tangential, radial, tangential…
// with alternating signs, each order picking up one more factor of the sweep.
let mut v = Vec2::from_angle(start + sweep * t).perp();
let mut d = Vec::with_capacity(MAX_DERIVATIVE_ORDER);
for j in 1..=MAX_DERIVATIVE_ORDER {
d.push(v * (radius * sweep.powi(j as i32)));
v = v.perp();
}
d
}
Seg2::Cubic { p0, p1, p2, p3 } => bezier_derivatives(&[p0, p1, p2, p3], t, MAX_DERIVATIVE_ORDER)?,
Seg2::Bezier { n, ref p } => bezier_derivatives(Self::bez(n, p), t, MAX_DERIVATIVE_ORDER)?,
Seg2::Conic { a, apex, b, w } => {
if !a.is_finite() || !apex.is_finite() || !b.is_finite() || !w.is_finite() {
return None;
}
// The conic is `N(t) / d(t)` with `N` the quadratic through `a`, `w·apex`, `b` and
// `d` its weight polynomial; both are evaluated here in the power basis (at the
// ends `d` = 1, so a join is never singular). The quotient rule in its
// derivative-of-a-product form gives the higher derivatives: the numerator of
// `x⁽ʲ⁾` is `N⁽ʲ⁾` minus the terms that already use `d` and a lower `x`.
let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b);
let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w));
let quotient = |j: usize| match j {
0 => n0 + n1 * t + n2 * (t * t),
1 => n1 + n2 * (2.0 * t),
2 => n2 * 2.0,
_ => Vec2::ZERO,
};
let weight = |j: usize| match j {
0 => 1.0 + e1 * t + e2 * (t * t),
1 => e1 + e2 * (2.0 * t),
2 => e2 * 2.0,
_ => 0.0,
};
let den = weight(0);
if !(den.abs() > 1e-300) {
return None;
}
let mut x = vec![quotient(0) / den];
for j in 1..=MAX_DERIVATIVE_ORDER {
let mut v = quotient(j);
for i in 1..=j {
v -= x[j - i] * (weight(i) * binom(j, i));
}
x.push(v / den);
}
x.remove(0);
x
}
};
d.truncate(order);
d.iter().all(|q| q.is_finite()).then_some(d)
}
pub fn start(&self) -> Vec2 {
match *self {
Seg2::Line { a, .. } | Seg2::Conic { a, .. } => a,
+158
View File
@@ -560,3 +560,161 @@ mod close_pairs {
assert!(budget < 10_000_000, "work was counted");
}
}
mod blend {
use super::*;
/// `j!` for the small orders the Hermite and quotient identities use.
fn fact(j: usize) -> f64 {
(1..=j).fold(1.0, |a, b| a * b as f64)
}
/// The derivatives 1…`order` a Bézier's control polygon carries at its end `at_end`: the j-th
/// difference of the controls, scaled by `n!/(n − j)!`. This is the textbook endpoint identity
/// of a Bézier, written here independently of the library's.
fn control_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec<Vec2> {
let c: Vec<Vec2> = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() };
let n = c.len() - 1;
let (mut diffs, mut scale) = (c, 1.0);
let mut out = Vec::new();
for j in 1..=order {
diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect();
scale *= (n + 1 - j) as f64;
out.push(diffs.first().copied().unwrap_or_default() * scale);
}
// The reversed controls describe `P(1 − s)`, so reversing again to read the original
// parameterisation negates the odd orders.
if at_end {
for (i, v) in out.iter_mut().enumerate() {
if i % 2 == 0 {
*v = -*v;
}
}
}
out
}
#[test]
fn hermite_bezier_meets_the_derivative_data_at_both_ends() {
let (p0, p1) = (Vec2::new(1.0, 2.0), Vec2::new(-3.0, 4.5));
let d0 = [Vec2::new(2.0, 1.0), Vec2::new(-0.5, 3.0), Vec2::new(1.5, 1.0), Vec2::new(0.25, -2.0)];
let d1 = [Vec2::new(-1.0, 0.5), Vec2::new(2.0, -1.5), Vec2::new(-0.75, 0.5), Vec2::new(3.0, 1.25)];
for k in 0..=MAX_DERIVATIVE_ORDER {
let cps = hermite_bezier(p0, p1, &d0[..k], &d1[..k]).unwrap();
assert_eq!(cps.len(), 2 * k + 2, "degree 2k+1 has 2k+2 controls");
assert_eq!(cps[0], p0);
assert_eq!(*cps.last().unwrap(), p1);
for (got, want) in control_derivatives(&cps, false, k).iter().zip(&d0[..k]) {
assert!(got.dist(*want) < 1e-11, "k={k} at t=0: {got:?} vs {want:?}");
}
for (got, want) in control_derivatives(&cps, true, k).iter().zip(&d1[..k]) {
assert!(got.dist(*want) < 1e-11, "k={k} at t=1: {got:?} vs {want:?}");
}
// The piece is exactly the single Bézier `bezier` hands back, and its ends are the two
// joints.
let seg = bezier(&cps).unwrap();
assert!(seg.start().dist(p0) < 1e-12 && seg.end().dist(p1) < 1e-12);
for (got, want) in seg.derivatives(0.0, k).unwrap().iter().zip(&d0[..k]) {
assert!(got.dist(*want) < 1e-10, "k={k} at t=0: {got:?} vs {want:?}");
}
}
}
#[test]
fn hermite_bezier_refuses_hostile_data() {
let (p0, p1) = (Vec2::ZERO, Vec2::X);
let d = [Vec2::Y, Vec2::X];
assert!(hermite_bezier(p0, p1, &d, &d).is_some());
assert!(hermite_bezier(p0, p1, &d, &d[..1]).is_none(), "the ends must agree on the order");
assert!(hermite_bezier(p0, p1, &[Vec2::ZERO; 5], &[Vec2::ZERO; 5]).is_none(), "order above G4");
assert!(hermite_bezier(Vec2::new(f64::NAN, 0.0), p1, &[], &[]).is_none());
assert!(hermite_bezier(p0, p1, &[Vec2::new(f64::INFINITY, 0.0)], &[Vec2::Y]).is_none());
assert!(hermite_bezier(p0, p1, &[], &[Vec2::Y]).is_none());
}
#[test]
fn seg_derivatives_are_exact_for_lines_and_polynomial_pieces() {
let line = Seg2::Line { a: Vec2::new(-1.0, 2.0), b: Vec2::new(3.0, 5.0) };
let d = line.derivatives(0.0, MAX_DERIVATIVE_ORDER).unwrap();
assert!(d[0].dist(Vec2::new(4.0, 3.0)) < 1e-12);
assert_eq!(d[1].len() + d[2].len() + d[3].len(), 0.0, "a line has no curvature");
let cubic = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(3.0, 9.0), p2: Vec2::new(7.0, -4.0), p3: Vec2::new(11.0, 2.0) };
let ctrl = [Vec2::new(0.0, 0.0), Vec2::new(3.0, 9.0), Vec2::new(7.0, -4.0), Vec2::new(11.0, 2.0)];
for (t, at_end) in [(0.0, false), (1.0, true)] {
for (got, want) in cubic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&ctrl, at_end, 4)) {
assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}");
}
}
for t in [0.0, 0.35, 1.0] {
assert!(cubic.derivatives(t, 1).unwrap()[0].dist(cubic.derivative(t)) < 1e-9, "t={t}");
}
// A degree-9 piece (the G4 blend's own shape) at both ends.
let pts: Vec<Vec2> = (0..10).map(|i| Vec2::new(i as f64 * 0.7, (i as f64 * 1.3).sin())).collect();
let seg = bezier(&pts).unwrap();
assert!(matches!(seg, Seg2::Bezier { n: 9, .. }));
for (t, at_end) in [(0.0, false), (1.0, true)] {
for (got, want) in seg.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&pts, at_end, 4)) {
assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}");
}
}
}
#[test]
fn seg_derivatives_follow_the_circular_recurrence() {
let arc = Seg2::Arc { center: Vec2::new(2.0, -1.0), radius: 5.0, start: 0.7, sweep: -1.9 };
let Seg2::Arc { center, sweep, .. } = arc else { panic!("an arc") };
for t in [0.0, 0.4, 1.0] {
let d = arc.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap();
let p = arc.point_at(t);
let sw2 = sweep * sweep;
// On a circle every derivative follows from the second: P″ = −sw²(P − c), and the rest
// differentiate that relation, so a line and an arc have zero curvature rates.
assert!(d[1].dist((p - center) * -sw2) < 1e-9, "t={t}");
assert!(d[2].dist(d[0] * -sw2) < 1e-9, "t={t}");
assert!(d[3].dist(d[1] * -sw2) < 1e-9, "t={t}");
assert!(d[0].dist(arc.derivative(t)) < 1e-9, "t={t}");
}
}
#[test]
fn seg_derivatives_of_a_conic_satisfy_the_quotient_identity() {
let conic = Seg2::Conic { a: Vec2::new(-2.0, 0.0), apex: Vec2::new(0.0, 3.0), b: Vec2::new(4.0, 0.5), w: 0.7 };
let Seg2::Conic { a, apex, b, w } = conic else { panic!("a conic") };
// The rational quadratic's numerator and weight polynomials, straight from their Bernstein
// form, and their power coefficients at `t` by differences (exact for a quadratic).
let n_at = |x: f64| a * ((1.0 - x) * (1.0 - x)) + apex * (2.0 * w * x * (1.0 - x)) + b * (x * x);
let d_at = |x: f64| (1.0 - x) * (1.0 - x) + 2.0 * w * x * (1.0 - x) + x * x;
for t in [0.0, 0.25, 1.0] {
let h = 1e-3;
// Taylor coefficients at `t` (differences are exact for a quadratic): f, f′/1!, f″/2!.
let coef_v = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)];
let coef_s = |f: &dyn Fn(f64) -> f64| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)];
let n = coef_v(&n_at);
let d = coef_s(&d_at);
let got = conic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap();
// Taylor coefficients of P: P(t) and P⁽ʲ⁾(t)/j!. Leibniz on N = P·D then reads
// Σ xᵢ·dⱼ₋ᵢ = nⱼ at every order, which the quotient rule must reproduce.
let x: Vec<Vec2> = std::iter::once(conic.point_at(t)).chain(got.iter().enumerate().map(|(i, v)| *v / fact(i + 1))).collect();
for j in 0..=4usize {
let lhs: Vec2 = (0..=j).fold(Vec2::ZERO, |acc, i| acc + x[i] * d.get(j - i).copied().unwrap_or(0.0));
let rhs = n.get(j).copied().unwrap_or(Vec2::ZERO);
assert!(lhs.dist(rhs) < 1e-8 * rhs.len().max(1.0), "t={t} j={j}: {lhs:?} vs {rhs:?}");
}
assert!(got[0].dist(conic.derivative(t)) < 1e-8, "t={t}");
}
}
#[test]
fn seg_derivatives_refuse_hostile_input_and_degenerate_pieces() {
let c = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::X, p2: Vec2::Y, p3: Vec2::X + Vec2::Y };
assert!(c.derivatives(0.0, MAX_DERIVATIVE_ORDER + 1).is_none(), "no order above G4");
assert!(c.derivatives(f64::NAN, 2).is_none());
let nan = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X };
assert!(nan.derivatives(0.0, 1).is_none());
// A conic whose weight vanishes is a straight chord drawn at zero speed: its derivatives
// are finite, but the first is zero, which is what a blend refuses.
let flat = Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 1.0), b: Vec2::new(2.0, 0.0), w: 0.0 };
let d = flat.derivatives(0.0, 1).unwrap();
assert_eq!(d[0], Vec2::ZERO);
}
}