Skip to main content

geop_core_geometry/nurb_curve/
interpolate.rs

1//! General-purpose NURBS curve interpolation through an ordered sequence of
2//! points (e.g. a `TracedCurve`'s marched polyline), in either 2-D or 3-D.
3//!
4//! Follows the standard global-interpolation recipe (Piegl & Tiller, "The
5//! NURBS Book", §9.2.1): chord-length parameterization, the knot-averaging
6//! technique, then one linear solve per coordinate for control points that
7//! pass exactly through the given points at those parameters.
8
9use geop_core_math::{
10    geop_error::{GeopError, GeopResult},
11    scalars::Scalar,
12    solvers::linear::{solve_banded, solve_pivoted},
13    vector::{Vector, Vector2, Vector3, Vector4},
14};
15
16use super::{NurbCurve, ParameterRefinable};
17use crate::spline::find_span;
18
19/// Uniform parameterization: `t[0] = 0`, `t[last] = 1`, interior values
20/// evenly spaced regardless of the spacing of `points`.
21fn uniform_params(m: usize) -> Vec<Scalar> {
22    let mut t = vec![Scalar::ZERO; m];
23    t[m - 1] = Scalar::ONE;
24    for (k, tk) in t.iter_mut().enumerate().take(m - 1).skip(1) {
25        *tk = Scalar::from_ratio(k as i64, (m - 1) as i64).unwrap();
26    }
27    t
28}
29
30/// Chord-length parameterization (Piegl & Tiller eq. 9.5): each point gets
31/// the fraction of the polyline's total length that precedes it.
32///
33/// The parameter values are a free choice — any strictly increasing sequence
34/// yields an interpolant through the same points — but not an innocent one:
35/// they decide the *speed* the curve is asked to travel between them, and a
36/// spline forced to cover a long span and a short one in equal parameter time
37/// overshoots on the long one. That is not hypothetical here. A traced
38/// intersection curve marches in even strides and then makes one final,
39/// arbitrarily-sized leg onto the vertex it terminates at; parameterized
40/// uniformly, the resulting edge left the true intersection by 7.1e-4 over
41/// that last leg — seven times the width the rest of the kernel is allowed to
42/// carry, and enough that the same arc traced from its two ends produced two
43/// curves 1.3e-4 apart, which no containment search can then recognise as the
44/// same curve. Under chord length the same data reproduces the arc to about
45/// its own step size to the fourth power, as cubic interpolation should.
46///
47/// Falls back to [`uniform_params`] when the polyline has a repeated point (or
48/// no length at all): chord length would hand those two points the same
49/// parameter, and the collocation matrix built from it is singular.
50fn chord_length_params<const C: usize>(points: &[Vector<C>]) -> Vec<Scalar> {
51    let m = points.len();
52    let mut cumulative = vec![Scalar::ZERO; m];
53    for k in 1..m {
54        let chord = points[k].sub(&points[k - 1]).norm();
55        if chord.could_be_equal(Scalar::ZERO) {
56            return uniform_params(m);
57        }
58        cumulative[k] = cumulative[k - 1].add(chord);
59    }
60    let total = cumulative[m - 1];
61    if total.could_be_equal(Scalar::ZERO) {
62        return uniform_params(m);
63    }
64    let mut t = vec![Scalar::ZERO; m];
65    t[m - 1] = Scalar::ONE;
66    for k in 1..(m - 1) {
67        // Sharpened because a parameter value is a free choice, not an
68        // answer: any increasing sequence interpolates the same points, so
69        // the width the chord lengths carry says nothing about where the
70        // curve is and only propagates into every basis function below.
71        t[k] = match cumulative[k].div(total) {
72            Ok(x) => x.sharpen(),
73            Err(_) => return uniform_params(m),
74        };
75    }
76    // Sharpened from chords as wide as they are long — points a rounding
77    // apart, as a silhouette 4e-13 long gave — the fractions need not even
78    // increase, or stay below 1, and the knots averaged from them leave a
79    // parameter outside the domain.
80    if !t.windows(2).all(|w| w[0].definitely_less(w[1])) {
81        return uniform_params(m);
82    }
83    t
84}
85
86/// Knot vector via the averaging technique: `degree + 1` repeated knots at
87/// each end, interior knots the running average of `degree` consecutive
88/// parameter values — guarantees every knot span contains at least one
89/// parameter value, which is what keeps the collocation matrix below
90/// nonsingular.
91fn averaging_knots(t: &[Scalar], degree: usize) -> Vec<Scalar> {
92    let m = t.len();
93    let n = m - 1;
94    let p = degree;
95    let mut knots = vec![Scalar::ZERO; m + p + 1];
96    for i in 0..=p {
97        knots[i] = Scalar::ZERO;
98        let last = knots.len() - 1 - i;
99        knots[last] = Scalar::ONE;
100    }
101    let p_s = Scalar::from_i64(p as i64);
102    for j in 1..=(n - p) {
103        let mut sum = Scalar::ZERO;
104        for &tk in &t[j..(j + p)] {
105            sum = sum.add(tk);
106        }
107        knots[j + p] = sum.div(p_s).unwrap();
108    }
109    knots
110}
111
112/// All `degree + 1` nonzero basis function values at `t`, for the span
113/// found by `find_span` (Piegl & Tiller algorithm A2.2).
114pub(crate) fn basis_funs(span: usize, t: Scalar, degree: usize, knots: &[Scalar]) -> Vec<Scalar> {
115    let p = degree;
116    let mut n = vec![Scalar::ZERO; p + 1];
117    n[0] = Scalar::ONE;
118    let mut left = vec![Scalar::ZERO; p + 1];
119    let mut right = vec![Scalar::ZERO; p + 1];
120
121    for j in 1..=p {
122        left[j] = t.sub(knots[span + 1 - j]);
123        right[j] = knots[span + j].sub(t);
124        let mut saved = Scalar::ZERO;
125        for r in 0..j {
126            let denom = right[r + 1].add(left[j - r]);
127            let temp = if denom.could_be_equal(Scalar::ZERO) {
128                Scalar::ZERO
129            } else {
130                n[r].div(denom).unwrap_or(Scalar::ZERO)
131            };
132            n[r] = saved.add(right[r + 1].mul(temp));
133            saved = left[j - r].mul(temp);
134        }
135        n[j] = saved;
136    }
137    n
138}
139
140/// Solve the `m x m` interpolation system (one row per data point, one
141/// column per control point) for the control points, by
142/// [`solve_banded`]: the collocation matrix of a B-spline basis evaluated
143/// at parameters chosen by `averaging_knots` is totally positive and
144/// nonsingular (Piegl & Tiller §9.2.1), so it needs no pivoting, and it is
145/// banded: row `k` is nonzero only in the `degree + 1` columns of its
146/// parameter's knot span, and the spans never decrease down the rows.
147///
148/// Generic over the dimension `C` of `points`, which are solved for as they
149/// are: Cartesian points (see [`with_unit_weights`]) or homogeneous ones.
150pub(crate) fn solve_interpolation_system<const C: usize>(
151    t: &[Scalar],
152    knots: &[Scalar],
153    degree: usize,
154    points: &[Vector<C>],
155) -> GeopResult<Vec<Vector<C>>> {
156    let m = points.len();
157    let p = degree;
158
159    // Row `k`'s band: columns `first[k] ..= last[k]`.
160    let mut a = vec![vec![Scalar::ZERO; m]; m];
161    let mut first = vec![0; m];
162    let mut last = vec![0; m];
163    for (k, &tk) in t.iter().enumerate() {
164        let span = find_span(p, knots, m - 1, tk)?;
165        let funs = basis_funs(span, tk, p, knots);
166        for (j, &val) in funs.iter().enumerate() {
167            a[k][span - p + j] = val;
168        }
169        (first[k], last[k]) = (span - p, span);
170    }
171    if first.windows(2).any(|w| w[1] < w[0]) || last.windows(2).any(|w| w[1] < w[0]) {
172        return Err(GeopError::new(format!(
173            "NurbCurve::interpolate: parameters {t:?} do not run through the knot spans in order"
174        )));
175    }
176
177    solve_banded(a, &first, &last, points.to_vec())
178}
179
180/// The Cartesian points `points` as homogeneous control points of weight
181/// one (`D = C + 1`).
182fn with_unit_weights<const C: usize, const D: usize>(points: Vec<Vector<C>>) -> Vec<Vector<D>> {
183    points
184        .into_iter()
185        .map(|p| {
186            let mut v = Vector::<D>::zero();
187            for c in 0..C {
188                v[c] = p[c];
189            }
190            v[C] = Scalar::ONE;
191            v
192        })
193        .collect()
194}
195
196/// Where, within interval `i` of `intervals` between consecutive samples, a
197/// caller of `interpolate_enclosing` should locate its true points: these
198/// fractions of the way from one sample to the next.
199///
200/// One point, in the middle, for an interior interval — that is where a
201/// cubic interpolant's drift peaks, and it keeps the extra work at one
202/// projection per interval. The two end intervals get seven, at eighths:
203/// next to a clamped end the drift is lopsided and several times larger than
204/// inside, so it sets the width of the whole enclosure, and its peak lies
205/// wherever the end conditions put it. Measured at quarters, a smooth hump
206/// can peak between the points by ~10% of its height: on a traced
207/// plane × cylinder branch that left the branch 1.36e-8 from its curve,
208/// widened to 1.25e-8 (see
209/// `cylinder_joined_over_a_hole` in `geop-cad-base`). At eighths the
210/// shortfall is ~2%, and on a circle sampled in 8 intervals a single midpoint
211/// there left a true point unenclosed.
212pub fn true_point_fractions(i: usize, intervals: usize) -> &'static [(i64, i64)] {
213    if i == 0 || i + 1 == intervals {
214        &[(1, 8), (1, 4), (3, 8), (1, 2), (5, 8), (3, 4), (7, 8)]
215    } else {
216        &[(1, 2)]
217    }
218}
219
220/// Shared core of `NurbCurve::interpolate` / `interpolate_enclosing` for both
221/// the 2-D and 3-D cases: fit a NURBS curve of degree `degree` (clamped to at
222/// least 1 and to at most `points.len() - 1`) that passes exactly through
223/// `points`, then — given `between`, true points inside each interval — widen
224/// it to enclose the true curve too (see [`widen_to_enclose`]).
225fn interpolate<const C: usize, const D: usize>(
226    points: &[Vector<C>],
227    between: Option<&[Vec<Vector<C>>]>,
228    degree: usize,
229) -> GeopResult<NurbCurve<D>>
230where
231    NurbCurve<D>: ParameterRefinable<C>,
232{
233    if points.len() < 2 {
234        return Err(GeopError::new(
235            "NurbCurve::interpolate: need at least 2 points",
236        ));
237    }
238
239    let m = points.len();
240    let p = degree.max(1).min(m - 1);
241
242    // With true points to enclose, the interpolant runs through the samples'
243    // centres — which points it passes through is a free choice — and the
244    // samples' own width is enclosed below like any other true point.
245    // Solving through interval samples instead would amplify their width
246    // through the elimination.
247    let centres: Vec<Vector<C>>;
248    let through = if between.is_some() {
249        centres = points.iter().map(|q| q.sharpen()).collect();
250        &centres[..]
251    } else {
252        points
253    };
254
255    let t = chord_length_params(through);
256    let knots = averaging_knots(&t, p);
257    let control_points = with_unit_weights(solve_interpolation_system(&t, &knots, p, through)?);
258
259    let mut curve = NurbCurve::try_new(p, control_points, knots)?;
260    if let Some(between) = between {
261        if between.len() != m - 1 {
262            return Err(GeopError::new(format!(
263                "NurbCurve::interpolate_enclosing: expected true points for each of the {} \
264                 intervals between the {m} samples, got {}",
265                m - 1,
266                between.len()
267            )));
268        }
269        // Every sample (whole, not just its centre) at its own parameter, and
270        // each true point between samples evenly through its interval in the
271        // interpolant's parameter — the `j`th of `k` at `(j + 1) / (k + 1)` of
272        // the way. Where a check starts is a free choice, and a sharp one.
273        let mut checks: Vec<(Scalar, Vector<C>)> =
274            t.iter().copied().zip(points.iter().copied()).collect();
275        for (w, qs) in t.windows(2).zip(between) {
276            for (j, &q) in qs.iter().enumerate() {
277                let frac = Scalar::from_ratio(j as i64 + 1, qs.len() as i64 + 1)?;
278                checks.push((Scalar::interpolate(w[0], w[1], frac).sharpen(), q));
279            }
280        }
281        widen_to_enclose(&mut curve, &checks)?;
282    }
283    Ok(curve)
284}
285
286/// Widen `curve` until it encloses every check's point — and, as far as the
287/// checks measure it, the whole true curve.
288///
289/// Each check is first moved to the curve's parameter nearest its point by
290/// one Gauss–Newton step from its initial guess. The interpolant and the true
291/// curve are parameterized differently, so comparing both at "the same
292/// fraction" of an interval would mostly measure a shift *along* the curve,
293/// which is no drift at all; one step removes that to first order, cheaply.
294/// Where the check is made is a free choice, so the stepped parameter is
295/// sharpened.
296///
297/// There, per coordinate, it measures how far the check's point sticks out
298/// of the curve's own enclosure, and every control point is widened by the
299/// largest of those, `±d` in each
300/// coordinate (times its weight, in homogeneous form). The basis is
301/// non-negative and sums to one, so that widens the curve everywhere by
302/// exactly `d`: every check is enclosed, and so is the true curve wherever it
303/// strays no further than the worst check saw. One uniform `d` rather than a
304/// local one keeps it cheap and keeps a locally tight neighbour from diluting
305/// the width between checks, where the drift is least measured.
306fn widen_to_enclose<const C: usize, const D: usize>(
307    curve: &mut NurbCurve<D>,
308    checks: &[(Scalar, Vector<C>)],
309) -> GeopResult<()>
310where
311    NurbCurve<D>: ParameterRefinable<C>,
312{
313    let pad = enclosing_pad(curve, checks)?;
314    widen(curve, &pad);
315    Ok(())
316}
317
318/// How far, per coordinate, `curve` has to be widened to enclose every
319/// check's point (see [`widen_to_enclose`]).
320fn enclosing_pad<const C: usize, const D: usize>(
321    curve: &NurbCurve<D>,
322    checks: &[(Scalar, Vector<C>)],
323) -> GeopResult<[Scalar; C]>
324where
325    NurbCurve<D>: ParameterRefinable<C>,
326{
327    let (lo, hi) = curve.domain();
328    let mut pad = [Scalar::ZERO; C];
329    for &(guess, q) in checks {
330        let position = curve.evaluate_cartesian(guess)?;
331        let tangent = curve.tangent_cartesian(guess)?;
332        let step = position
333            .sub(&q)
334            .prod_dot(&tangent)
335            .div(tangent.prod_dot(&tangent))
336            .unwrap_or(Scalar::ZERO);
337        let tau = guess.sub(step).sharpen();
338        let tau = if tau.definitely_less(lo) {
339            lo
340        } else if tau.definitely_greater(hi) {
341            hi
342        } else {
343            tau
344        };
345        // How far `q` sticks out of the curve's enclosure there, on either
346        // side — what the curve must grow by to hold it. Not `|C(τ) - q|`,
347        // which would count the width both already carry.
348        let on_curve = curve.evaluate_cartesian(tau)?;
349        for k in 0..C {
350            let above = q[k].upper().sub(on_curve[k].upper()).upper();
351            let below = on_curve[k].lower().sub(q[k].lower()).upper();
352            pad[k] = pad[k].union(above).union(below).upper();
353        }
354    }
355    Ok(pad)
356}
357
358/// `curve` widened by `±pad[k]` in each coordinate `k`, everywhere: every
359/// control point by that much (times its weight, in homogeneous form) —
360/// the basis is non-negative and sums to one.
361fn widen<const C: usize, const D: usize>(curve: &mut NurbCurve<D>, pad: &[Scalar; C]) {
362    for cp in &mut curve.control_points {
363        let w = cp[D - 1];
364        for k in 0..C {
365            let d = pad[k].mul(w);
366            cp[k] = cp[k].sub(d).union(cp[k].add(d));
367        }
368    }
369    curve.recompute_aabb();
370}
371
372/// Checks `params` are parameters to interpolate `m` values at: as many,
373/// strictly increasing, from exactly 0 to exactly 1.
374fn check_params(params: &[Scalar], m: usize) -> GeopResult<()> {
375    let increasing = params.windows(2).all(|w| w[0].definitely_less(w[1]));
376    let ends = params
377        .first()
378        .is_some_and(|t| t.is_sharp() && t.could_be_equal(Scalar::ZERO))
379        && params
380            .last()
381            .is_some_and(|t| t.is_sharp() && t.could_be_equal(Scalar::ONE));
382    if params.len() != m || m < 2 || !increasing || !ends {
383        return Err(GeopError::new(format!(
384            "NurbCurve::interpolate_homogeneous: {m} values need as many parameters, strictly increasing from 0 to 1, not {params:?}"
385        )));
386    }
387    Ok(())
388}
389
390impl<const D: usize> NurbCurve<D> {
391    /// The rational curve of `degree` whose homogeneous control points
392    /// interpolate the homogeneous points `values` — `(w p, w)`, the point
393    /// `p` of weight `w` — at the strictly increasing `params`, from 0 to 1,
394    /// on the knots their averages give: it passes through each `p` at its
395    /// parameter. Curves interpolated at the same parameters share one knot
396    /// vector, so they are the rows of a surface skinned through curves
397    /// whose control points are the values (see
398    /// [`crate::nurb_surface::NurbSurface::try_new`]), its iso-curve at each
399    /// parameter exactly the curve there.
400    ///
401    /// The values are solved for as they are: interval values carry their
402    /// width into the control points (sharpen a value that is a free choice
403    /// first). An error if a control point's weight comes out not positive.
404    pub fn interpolate_homogeneous(
405        values: &[Vector<D>],
406        params: &[Scalar],
407        degree: usize,
408    ) -> GeopResult<Self> {
409        check_params(params, values.len())?;
410        let p = degree.max(1).min(values.len() - 1);
411        let knots = averaging_knots(params, p);
412        let control_points = solve_interpolation_system(params, &knots, p, values)?;
413        NurbCurve::try_new(p, control_points, knots)
414    }
415}
416
417impl NurbCurve<4> {
418    /// The cubic spline through `points`, in order: twice continuously
419    /// differentiable, parameterized by chord length (see
420    /// [`chord_length_params`]) with a knot at every point, so it bends
421    /// only as much as passing through them needs.
422    ///
423    /// Each end leaves along its tangent `start` / arrives along `end` if
424    /// given — a direction, whose length does not matter — and is free
425    /// otherwise: straight there, its second derivative zero (a natural
426    /// spline). How fast it leaves along a given tangent is a free choice
427    /// of the spline's shape; it is the speed the parameterization travels
428    /// at anyway, the polyline's length per unit of parameter, worked out
429    /// from the points' midpoints so that the same points always give the
430    /// same spline.
431    ///
432    /// Two points and no tangents give the straight line between them.
433    pub fn cubic_spline(
434        points: &[Vector3],
435        start: Option<Vector3>,
436        end: Option<Vector3>,
437    ) -> GeopResult<Self> {
438        let m = points.len();
439        if m < 2 {
440            return Err(GeopError::new(
441                "NurbCurve::cubic_spline: need at least 2 points",
442            ));
443        }
444        let t = chord_length_params(points);
445        // Knots `0 0 0 0 t1 .. t(m-2) 1 1 1 1`: `m + 2` control points.
446        let mut knots = vec![Scalar::ZERO; 4];
447        knots.extend_from_slice(&t[1..m - 1]);
448        knots.extend([Scalar::ONE; 4]);
449        let n = m + 2;
450        let speed = Scalar::from_f64(
451            points
452                .windows(2)
453                .map(|w| w[1].sub(&w[0]).norm().to_f64())
454                .sum(),
455        );
456        let mut a = vec![vec![Scalar::ZERO; n]; n];
457        let mut rhs = vec![Vector3::zero(); n];
458        // Through the ends.
459        a[0][0] = Scalar::ONE;
460        rhs[0] = points[0];
461        a[n - 1][n - 1] = Scalar::ONE;
462        rhs[n - 1] = points[m - 1];
463        // Through the points between, at their parameters.
464        for k in 1..m - 1 {
465            let span = find_span(3, &knots, n - 1, t[k])?;
466            for (j, value) in basis_funs(span, t[k], 3, &knots).into_iter().enumerate() {
467                a[k + 1][span - 3 + j] = value;
468            }
469            rhs[k + 1] = points[k];
470        }
471        // The end conditions, as rows over the first (last) three control
472        // points: `C'(0) = 3 (P1 - P0) / u4`, `C''(0) ∝ (P2 - P1) / u5 -
473        // (P1 - P0) / u4`, and mirrored at the end.
474        let (u4, u5) = (knots[4], knots[5]);
475        let (v4, v5) = (Scalar::ONE.sub(knots[n - 1]), Scalar::ONE.sub(knots[n - 2]));
476        let mut condition = |row: usize,
477                             tangent: Option<Vector3>,
478                             h1: Scalar,
479                             h2: Scalar,
480                             ends: [usize; 3],
481                             sign: Scalar|
482         -> GeopResult<()> {
483            let [p0, p1, p2] = ends;
484            match tangent {
485                Some(direction) => {
486                    let derivative = direction.normalize()?.prod_scalar(speed.mul(sign));
487                    a[row][p1] = Scalar::ONE;
488                    a[row][p0] = Scalar::ONE.neg();
489                    rhs[row] = derivative.prod_scalar(h1.div(Scalar::from_i64(3))?);
490                }
491                None => {
492                    let (k1, k2) = (Scalar::ONE.div(h1)?, Scalar::ONE.div(h2)?);
493                    a[row][p2] = k2;
494                    a[row][p1] = k1.add(k2).neg();
495                    a[row][p0] = k1;
496                }
497            }
498            Ok(())
499        };
500        condition(1, start, u4, u5, [0, 1, 2], Scalar::ONE)?;
501        // At the end, the tangent points back along the spline from its
502        // last control point.
503        condition(n - 2, end, v4, v5, [n - 1, n - 2, n - 3], Scalar::ONE.neg())?;
504        let control_points = solve_pivoted(a, rhs)?
505            .into_iter()
506            .map(|p| Vector4::from_array([p[0], p[1], p[2], Scalar::ONE]))
507            .collect();
508        NurbCurve::try_new(3, control_points, knots)
509    }
510
511    /// Fit a 3-D NURBS curve exactly through `points` — see `interpolate`
512    /// above. Between the points it is only an approximation of whatever
513    /// curve they were sampled from; use [`Self::interpolate_enclosing`]
514    /// when the result has to *enclose* that curve.
515    pub fn interpolate(points: &[Vector3], degree: usize) -> GeopResult<Self> {
516        interpolate::<3, 4>(points, None, degree)
517    }
518
519    /// Fit a 3-D NURBS curve through samples `points` of some true curve
520    /// (through their centres; their full width is enclosed), widened so it
521    /// also encloses that curve between them: `between[i]` are
522    /// points of the true curve strictly between `points[i]` and
523    /// `points[i + 1]`, in order and roughly evenly spaced (see
524    /// [`true_point_fractions`] for the usual choice of where).
525    ///
526    /// An interpolant drifts from the curve it was sampled from between the
527    /// samples. That drift is a genuine uncertainty about where the curve is,
528    /// so it belongs in the curve's interval width: otherwise a point on the
529    /// true curve tests as *not* on its interpolant, and every containment or
530    /// intersection question asked of it is answered for the wrong curve. A
531    /// true point per interval measures it where it is largest; the
532    /// enclosure is exact there and as good as that measurement in between.
533    pub fn interpolate_enclosing(
534        points: &[Vector3],
535        between: &[Vec<Vector3>],
536        degree: usize,
537    ) -> GeopResult<Self> {
538        interpolate::<3, 4>(points, Some(between), degree)
539    }
540
541    /// How far the true points `between` of each interval stick out of the
542    /// interpolant [`Self::interpolate_enclosing`] fits through `points`,
543    /// before it is widened, in the coordinate they stick out most: what
544    /// each interval asks of the one width the whole curve is then given.
545    pub fn drift_by_interval(
546        points: &[Vector3],
547        between: &[Vec<Vector3>],
548        degree: usize,
549    ) -> GeopResult<Vec<f64>> {
550        let centres: Vec<Vector3> = points.iter().map(|q| q.sharpen()).collect();
551        let p = degree.max(1).min(centres.len() - 1);
552        let t = chord_length_params(&centres);
553        let knots = averaging_knots(&t, p);
554        let control_points =
555            with_unit_weights(solve_interpolation_system(&t, &knots, p, &centres)?);
556        let curve = NurbCurve::<4>::try_new(p, control_points, knots)?;
557        let mut drifts = Vec::with_capacity(between.len());
558        for (w, qs) in t.windows(2).zip(between) {
559            let mut checks = Vec::with_capacity(qs.len());
560            for (j, &q) in qs.iter().enumerate() {
561                let frac = Scalar::from_ratio(j as i64 + 1, qs.len() as i64 + 1)?;
562                checks.push((Scalar::interpolate(w[0], w[1], frac).sharpen(), q));
563            }
564            let pad = enclosing_pad::<3, 4>(&curve, &checks)?;
565            drifts.push(pad.iter().map(|d| d.upper().to_f64()).fold(0.0, f64::max));
566        }
567        Ok(drifts)
568    }
569
570    /// How far, per coordinate, this curve has to be widened (see
571    /// [`Self::widen`]) to enclose every check's point: `(t, q)`, the true
572    /// point `q`, near the curve at about `t` — exactly as
573    /// [`Self::interpolate_enclosing`] widens an interpolant to enclose the
574    /// curve it was sampled from.
575    pub fn enclosing_pad(&self, checks: &[(Scalar, Vector3)]) -> GeopResult<[Scalar; 3]> {
576        enclosing_pad(self, checks)
577    }
578
579    /// This curve widened by `±pad[k]` in each coordinate `k`, everywhere.
580    pub fn widen(&mut self, pad: &[Scalar; 3]) {
581        widen(self, pad)
582    }
583}
584
585impl NurbCurve<3> {
586    /// Fit a 2-D NURBS curve exactly through `points` — see the 3-D
587    /// [`NurbCurve::interpolate`].
588    pub fn interpolate(points: &[Vector2], degree: usize) -> GeopResult<Self> {
589        interpolate::<2, 3>(points, None, degree)
590    }
591
592    /// The 2-D counterpart of the 3-D [`NurbCurve::interpolate_enclosing`].
593    pub fn interpolate_enclosing(
594        points: &[Vector2],
595        between: &[Vec<Vector2>],
596        degree: usize,
597    ) -> GeopResult<Self> {
598        interpolate::<2, 3>(points, Some(between), degree)
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn v3(x: f64, y: f64, z: f64) -> Vector3 {
607        Vector3::from_array([
608            Scalar::from_f64(x),
609            Scalar::from_f64(y),
610            Scalar::from_f64(z),
611        ])
612    }
613
614    fn v2(x: f64, y: f64) -> Vector2 {
615        Vector2::from_array([Scalar::from_f64(x), Scalar::from_f64(y)])
616    }
617
618    /// A clamped interpolating spline must reproduce its first and last data
619    /// points *exactly*, not merely closely: the end rows of the collocation
620    /// matrix are `[1, 0, ...]` and `[..., 0, 1]`, so the end control points
621    /// are the end data points. Callers rely on this to make a fitted curve
622    /// start and end on the topological vertices it was built between — a
623    /// traced intersection edge whose endpoint drifts even slightly no longer
624    /// matches its own `start_vertex`/`end_vertex` and fails validation.
625    #[test]
626    fn interpolate_reproduces_endpoints() {
627        // Deliberately not axis-aligned or evenly spaced, so no coordinate
628        // is reproduced by accident.
629        let points = vec![
630            v3(0.5, 0.5, 0.5),
631            v3(0.4713, 0.5219, 0.4102),
632            v3(0.4402, 0.5411, 0.3301),
633            v3(0.4001, 0.5502, 0.2604),
634            v3(0.5, 0.5, 0.2),
635        ];
636        for degree in [1, 2, 3] {
637            let curve = NurbCurve::<4>::interpolate(&points, degree).unwrap();
638            let (t0, t1) = curve.domain();
639            let start = curve.evaluate(t0).unwrap();
640            let end = curve.evaluate(t1).unwrap();
641            assert!(
642                start.could_be_equal(&points[0]),
643                "degree {degree}: start {start:?} != {:?}",
644                points[0]
645            );
646            assert!(
647                end.could_be_equal(points.last().unwrap()),
648                "degree {degree}: end {end:?} != {:?}",
649                points.last().unwrap()
650            );
651        }
652    }
653
654    /// Points a rounding apart, each as wide as the steps between them — a
655    /// silhouette 4e-13 long, from an iso view of `cross_drilled_shaft` —
656    /// still interpolate. Their chord-length fractions, sharpened, did not
657    /// increase, and a knot averaged from them lay outside the domain.
658    #[test]
659    fn points_a_rounding_apart_interpolate() {
660        let i = |lo: f64, hi: f64| Scalar::from_f64(lo).union(Scalar::from_f64(hi));
661        let (x, y) = (
662            i(-0.4242640687119382, -0.4242640687119197),
663            i(-0.42426406871193406, -0.4242640687119227),
664        );
665        let points: Vec<Vector3> = [
666            (1.0000000000003741, 1.0000000000004499),
667            (1.000000000000271, 1.0000000000003466),
668            (1.0000000000001676, 1.0000000000002431),
669            (1.0000000000000644, 1.0000000000001399),
670            (0.9999999999999599, 1.0000000000000369),
671        ]
672        .iter()
673        .map(|&(lo, hi)| Vector3::from_array([x, y, i(lo, hi)]))
674        .collect();
675        let curve = NurbCurve::<4>::interpolate(&points, 3).unwrap();
676        let (t0, t1) = curve.domain();
677        assert!(curve.evaluate(t0).unwrap().could_be_equal(&points[0]));
678        assert!(curve.evaluate(t1).unwrap().could_be_equal(&points[4]));
679    }
680
681    /// Same parabola-fitting check as `interpolate_curve_matches_samples`,
682    /// but through the 2-D (`Vector2`) code path.
683    #[test]
684    fn interpolate_2d_curve_matches_samples() {
685        let n = 20;
686        let points: Vec<_> = (0..=n)
687            .map(|i| {
688                let x = i as f64 / n as f64;
689                v2(x, x * x)
690            })
691            .collect();
692
693        let curve = NurbCurve::<3>::interpolate(&points, 3).unwrap();
694
695        let (t0, t1) = curve.domain();
696        let mid_t = t0.add(t1.sub(t0).mul(Scalar::from_f64(0.5)));
697        let p = curve.evaluate(mid_t).unwrap();
698        assert!(
699            p[1].sub(p[0].mul(p[0]))
700                .abs()
701                .could_be_less(Scalar::from_f64(1e-3))
702        );
703    }
704
705    /// Densely-sampled straight line: evaluating the fitted curve anywhere
706    /// should reproduce the corresponding point on the line.
707    #[test]
708    fn interpolate_line() {
709        let n = 50;
710        let points: Vec<_> = (0..=n).map(|i| v3(i as f64 / n as f64, 0.0, 0.0)).collect();
711
712        let curve = NurbCurve::<4>::interpolate(&points, 3).unwrap();
713
714        let p = curve.evaluate(Scalar::from_f64(0.5)).unwrap();
715        assert!(p[0].could_be_equal(Scalar::from_f64(0.5)));
716        assert!(p[1].could_be_equal(Scalar::ZERO));
717        assert!(p[2].could_be_equal(Scalar::ZERO));
718    }
719
720    /// A right-angle "L" polyline: the fitted curve must still pass through
721    /// every original vertex.
722    #[test]
723    fn interpolate_passes_through_corner() {
724        let mut points = Vec::new();
725        for i in 0..=10 {
726            points.push(v3(i as f64 / 10.0, 0.0, 0.0));
727        }
728        for i in 1..=10 {
729            points.push(v3(1.0, i as f64 / 10.0, 0.0));
730        }
731
732        let curve = NurbCurve::<4>::interpolate(&points, 3).unwrap();
733
734        let (t0, t1) = curve.domain();
735        let start = curve.evaluate(t0).unwrap();
736        let end = curve.evaluate(t1).unwrap();
737        assert!(start[0].could_be_equal(Scalar::ZERO));
738        assert!(start[1].could_be_equal(Scalar::ZERO));
739        assert!(end[0].could_be_equal(Scalar::ONE));
740        assert!(end[1].could_be_equal(Scalar::ONE));
741    }
742
743    /// Sampled points on a parabola `y = x^2`: fit with degree 3 and confirm
744    /// the curve reproduces intermediate sample points closely (a non-linear
745    /// curve needs its interior control points, unlike the straight-line
746    /// case).
747    #[test]
748    fn interpolate_curve_matches_samples() {
749        let n = 20;
750        let points: Vec<_> = (0..=n)
751            .map(|i| {
752                let x = i as f64 / n as f64;
753                v3(x, x * x, 0.0)
754            })
755            .collect();
756
757        let curve = NurbCurve::<4>::interpolate(&points, 3).unwrap();
758
759        let (t0, t1) = curve.domain();
760        let mid_t = t0.add(t1.sub(t0).mul(Scalar::from_f64(0.5)));
761        let p = curve.evaluate(mid_t).unwrap();
762        // The fitted point must lie on the parabola.
763        assert!(
764            p[1].sub(p[0].mul(p[0]))
765                .abs()
766                .could_be_less(Scalar::from_f64(1e-3))
767        );
768    }
769
770    /// Points of a true curve *between* the samples: an exact interpolant
771    /// through a few samples of a circle misses them (its drift isn't in its
772    /// width), the enclosing one contains every one of them.
773    #[test]
774    fn interpolate_enclosing_contains_the_true_curve() {
775        use crate::contains::curve::curve_could_contain;
776        let circle = |a: f64| v3(a.cos(), a.sin(), 0.);
777        let n = 8;
778        let angle = |i: usize, of: usize| std::f64::consts::FRAC_PI_2 * i as f64 / of as f64;
779        let points: Vec<_> = (0..=n).map(|i| circle(angle(i, n))).collect();
780        let between: Vec<Vec<_>> = (0..n)
781            .map(|i| {
782                true_point_fractions(i, n)
783                    .iter()
784                    .map(|&(a, b)| circle(angle(i * b as usize + a as usize, n * b as usize)))
785                    .collect()
786            })
787            .collect();
788
789        let exact = NurbCurve::<4>::interpolate(&points, 3).unwrap();
790        let enclosing = NurbCurve::<4>::interpolate_enclosing(&points, &between, 3).unwrap();
791        let eps = Scalar::from_f64(1e-6);
792        let dense: Vec<_> = (0..=200).map(|i| circle(angle(i, 200))).collect();
793        let found = |c: &NurbCurve<4>| {
794            dense
795                .iter()
796                .filter(|q| curve_could_contain(c, q, 5000, eps).unwrap().is_some())
797                .count()
798        };
799        assert!(
800            found(&exact) < dense.len(),
801            "the exact interpolant should drift"
802        );
803        let missed: Vec<(usize, Result<Option<Scalar>, String>)> = dense
804            .iter()
805            .enumerate()
806            .map(|(i, q)| {
807                (
808                    i,
809                    curve_could_contain(&enclosing, q, 5000, eps).map_err(|e| format!("{e}")),
810                )
811            })
812            .filter(|(_, r)| !matches!(r, Ok(Some(_))))
813            .collect();
814        assert!(missed.is_empty(), "true points not enclosed: {missed:?}");
815    }
816
817    /// A cubic spline passes through every point, leaves and arrives along
818    /// the tangents it is given, and is straight at a free end.
819    #[test]
820    fn cubic_spline_meets_its_conditions() {
821        let points = [
822            v3(0.0, 0.0, 0.0),
823            v3(1.0, 0.5, 0.2),
824            v3(2.0, 0.3, 1.0),
825            v3(2.5, 1.5, 1.2),
826        ];
827        let parallel = |a: Vector3, b: Vector3| {
828            let c = a.prod_cross(&b);
829            (0..3).all(|k| c[k].abs().to_f64() < 1e-9) && a.prod_dot(&b).to_f64() > 0.0
830        };
831        let start = v3(0.0, 0.0, 1.0);
832        let curve = NurbCurve::<4>::cubic_spline(&points, Some(start), None).unwrap();
833        let (t0, t1) = curve.domain();
834        // Through every point: the ends exactly, the rest at their knots.
835        assert!(curve.evaluate(t0).unwrap().could_be_equal(&points[0]));
836        assert!(curve.evaluate(t1).unwrap().could_be_equal(&points[3]));
837        for (k, &p) in points.iter().enumerate().take(3).skip(1) {
838            let at = curve.knot_vector[3 + k];
839            let on = curve.evaluate(at).unwrap();
840            assert!(
841                on.could_be_equal(&p),
842                "point {k}: {on:?} at {at:?}, not {p:?}"
843            );
844        }
845        assert!(parallel(curve.tangent(t0).unwrap(), start));
846        // Free at the end: no second derivative.
847        let bend = curve.second_derivative(t1).unwrap();
848        assert!(
849            (0..3).all(|k| bend[k].could_be_equal(Scalar::ZERO)),
850            "{bend:?}"
851        );
852
853        let end = v3(1.0, 0.0, 0.0);
854        let curve = NurbCurve::<4>::cubic_spline(&points, None, Some(end)).unwrap();
855        assert!(parallel(curve.tangent(Scalar::ONE).unwrap(), end));
856        let bend = curve.second_derivative(Scalar::ZERO).unwrap();
857        assert!(
858            (0..3).all(|k| bend[k].could_be_equal(Scalar::ZERO)),
859            "{bend:?}"
860        );
861    }
862
863    /// Two points and no tangents: the line between them.
864    #[test]
865    fn cubic_spline_of_two_points_is_straight() {
866        let (a, b) = (v3(0.0, 1.0, 2.0), v3(3.0, 1.0, -2.0));
867        let curve = NurbCurve::<4>::cubic_spline(&[a, b], None, None).unwrap();
868        let middle = curve.evaluate(Scalar::from_f64(0.5)).unwrap();
869        assert!(middle.could_be_equal(&v3(1.5, 1.0, 0.0)), "{middle:?}");
870    }
871}