Skip to main content

spliny/
spline.rs

1use super::Result;
2#[cfg(feature="plot")]
3use super::plot::plot_base;
4
5
6/// Maximum supported spline degree plus one. Used as a fixed array size for the de Boor
7/// working buffer. Spline degrees 1 through 5 (linear through quintic) are supported.
8const DE_BOOR_SIZE: usize = 6;
9
10/// A B-spline curve of degree `K` in `N`-dimensional space.
11///
12/// The curve is stored in its knot/coefficient form:
13/// - `t`: the full clamped knot vector (length `n`); the first and last `K` values must repeat
14///   the boundary knots so the curve interpolates its endpoints.
15/// - `c`: the B-spline control coefficients, stored dimension-major: all `n-K-1` x-values first,
16///   then all y-values, etc. Total length must be `N * (n - K - 1)`.
17///
18/// Use the type aliases in [`crate`] (e.g. [`crate::CubicSpline2D`]) for the most common
19/// degree/dimension combinations.
20#[derive(Debug, Clone)]
21pub struct SplineCurve<const K: usize, const N: usize> {
22    pub t: Vec<f64>, // knot vector (clamped; length n)
23    pub c: Vec<f64>, // B-spline coefficients (dimension-major; length N*(n-K-1))
24    i: Option<usize>, // cached knot interval index from the last `eval` call
25}
26
27impl<const K: usize, const N: usize> SplineCurve<K, N> {
28    /// Creates a spline from a knot vector `t` and coefficient array `c` without validating
29    /// the coefficient count against the knot vector length.
30    ///
31    /// Prefer [`try_new`](Self::try_new) when constructing splines from untrusted input.
32    ///
33    /// # Panics
34    /// Panics if `K >= 6` (degrees above quintic are not supported).
35    pub fn new(t: Vec<f64>, c: Vec<f64>) -> Self {
36        assert!(K < DE_BOOR_SIZE, "Spline degree K={} exceeds maximum supported degree {}", K, DE_BOOR_SIZE - 1);
37        Self { t, c, i: None }
38    }
39
40    /// Creates a spline from a knot vector `t` and coefficient array `c`, returning an error
41    /// if there are fewer than `N*(K+1)` coefficients.
42    ///
43    /// # Panics
44    /// Panics if `K >= 6` (degrees above quintic are not supported).
45    pub fn try_new(t: Vec<f64>, c: Vec<f64>) -> std::result::Result<Self, Box<dyn std::error::Error>> {
46        assert!(K < DE_BOOR_SIZE, "Spline degree K={} exceeds maximum supported degree {}", K, DE_BOOR_SIZE - 1);
47        let nc = c.len() / N;
48        if nc < (K+1) {
49            return Err(format!("Need at least {} coefficients for a degree-{} spline", N*(K+1), K).into());
50        }
51        Ok(Self { t, c, i: None })
52    }
53
54    /// Plots the spline curve to a PNG file.
55    ///
56    /// Only supported for 1D (`N=1`) and 2D (`N=2`) curves.
57    /// For 1D curves, the parameter is used as the x-axis and the spline value as the y-axis.
58    /// For 2D curves, the first dimension is used as x and the second as y.
59    ///
60    /// # Panics
61    /// Panics if `N > 2`.
62    #[cfg(feature="plot")]
63    pub fn plot(&self, filepath: &str, wxh: (u32, u32)) -> Result<()> {
64        assert!(N <= 2, "Plotting is only supported for 1D and 2D curves (N=1 or N=2), got N={}", N);
65        Ok(plot_base(self.clone(), filepath, wxh, None, None, false)?)
66    }
67
68    /// Plots the spline curve with explicit parameter values highlighted.
69    ///
70    /// Only supported for 1D (`N=1`) and 2D (`N=2`) curves. See [`plot`](Self::plot) for details.
71    ///
72    /// # Panics
73    /// Panics if `N > 2`.
74    #[cfg(feature="plot")]
75    pub fn plot_with_parameter(&self, filepath: &str, wxh: (u32, u32), u:Option<&[f64]>) -> Result<()> {
76        assert!(N <= 2, "Plotting is only supported for 1D and 2D curves (N=1 or N=2), got N={}", N);
77        Ok(plot_base(self.clone(), filepath, wxh, u, None, false)?)
78    }
79
80    /// Plots the spline curve together with its control points.
81    ///
82    /// Only supported for 1D (`N=1`) and 2D (`N=2`) curves. See [`plot`](Self::plot) for details.
83    ///
84    /// # Panics
85    /// Panics if `N > 2`.
86    #[cfg(feature="plot")]
87    pub fn plot_with_control_points(&self, filepath: &str, wxh: (u32, u32)) -> Result<()> {
88        assert!(N <= 2, "Plotting is only supported for 1D and 2D curves (N=1 or N=2), got N={}", N);
89        Ok(plot_base(self.clone(), filepath, wxh, None, None, true)?)
90    }
91
92    /// Plots the spline curve together with reference data points.
93    ///
94    /// Only supported for 1D (`N=1`) and 2D (`N=2`) curves. See [`plot`](Self::plot) for details.
95    ///
96    /// # Panics
97    /// Panics if `N > 2`.
98    #[cfg(feature="plot")]
99    pub fn plot_with_data(&self, filepath: &str, wxh: (u32, u32), xy: &[f64]) -> Result<()> {
100        assert!(N <= 2, "Plotting is only supported for 1D and 2D curves (N=1 or N=2), got N={}", N);
101        Ok(plot_base(self.clone(), filepath, wxh, None, Some(xy), false)?)
102    }
103
104    /// Plots the spline curve together with its control points and reference data points.
105    ///
106    /// Only supported for 1D (`N=1`) and 2D (`N=2`) curves. See [`plot`](Self::plot) for details.
107    ///
108    /// # Panics
109    /// Panics if `N > 2`.
110    #[cfg(feature="plot")]
111    pub fn plot_with_control_points_and_data(&self, filepath: &str, wxh: (u32, u32), xy: &[f64]) -> Result<()> {
112        assert!(N <= 2, "Plotting is only supported for 1D and 2D curves (N=1 or N=2), got N={}", N);
113        Ok(plot_base(self.clone(), filepath, wxh, None, Some(xy), true)?)
114    }
115
116    /// Calculates spline coordinates for a collection of parameter values.
117    ///
118    /// `u` must be sorted in strictly increasing order. Values outside the knot range are clamped
119    /// to the nearest boundary.
120    ///
121    /// The output is a flat interleaved array: the N coordinates of the first point, then the N
122    /// coordinates of the second, and so on. For a 2D curve: `[x0, y0, x1, y1, x2, y2, ...]`.
123    /// Use [`crate::transpose`] to split the result into per-dimension vectors.
124    ///
125    /// # Errors
126    /// Returns an error if the coefficient count does not match the knot vector, or if `u` is not
127    /// strictly increasing.
128    pub fn evaluate(&self, u: &[f64]) -> Result<Vec<f64>> {
129        let n = self.t.len();
130        let nc = self.c.len() / N;
131        if nc < (K+1) {
132            return Err(format!("Need at least {} coefficients for a degree-{} spline", N*(K+1), K).into());
133        }
134        if nc != n-(K+1) {
135            return Err(format!("Expected {} coefficient values, got {}", N*(n - K - 1), N*nc).into());
136        }
137        let mut v: Vec<f64> = Vec::with_capacity(u.len() * N);
138
139        let mut i = K;
140        let mut u_prev = f64::NEG_INFINITY;
141        // Ideally [f64; K+1], but const generic expressions in array lengths are not yet stable.
142        let mut d = [0.0; DE_BOOR_SIZE];
143
144        for &t in u {
145            if t <= u_prev {
146                return Err("parameter values must be in strictly increasing order".into());
147            } else {
148                u_prev = t;
149            };
150
151            // clamp to the valid knot interval
152            let arg = if t < self.t[K] || t > self.t[n - K - 1] {
153                t.clamp(self.t[K], self.t[n - K - 1])
154            } else {
155                t
156            };
157
158            // find knot interval which contains arg
159            while !(arg >= self.t[i] && arg <= self.t[i + 1]) {
160                i += 1
161            }
162
163            // calculate spline values
164            for dim in 0..N {
165                // copy relevant c values into d
166                for (j, dm) in d.iter_mut().enumerate().take(K + 1) {
167                    *dm = self.c[dim * nc + j + i - K];
168                }
169
170                v.push(self.deboor(i, arg, &mut d))
171            }
172        }
173        Ok(v)
174    }
175
176
177    /// Evaluates a single parameter value on the spline.
178    ///
179    /// Returns `Ok(values)` with N-dimensional output, or `Err(distance)` if `t` is outside the
180    /// knot range. Only indices `0..N` of the returned array are populated; the rest are zero.
181    ///
182    /// Note: the return array length is fixed at `DE_BOOR_SIZE` (= 6) due to a current compiler
183    /// limitation preventing `[f64; N]` as a return type. Read only `values[0..N]`.
184    pub fn eval(&mut self, t: f64) -> std::result::Result<[f64; DE_BOOR_SIZE], f64> {
185        let n = self.t.len();
186        let nc = self.c.len() / N;
187
188        if t < self.t[K] {
189            Err(t - self.t[K])
190        } else if t > self.t[n - K - 1] {
191            Err(t - self.t[n - K - 1])
192        } else {
193            let mut i = if let Some(i_prev) = self.i {
194                if t > self.t[i_prev] {
195                    i_prev
196                } else {
197                    K
198                }
199            } else {
200                K
201            };
202
203            while !(t >= self.t[i] && t <= self.t[i + 1]) {
204                i += 1
205            }
206            self.i = Some(i);
207
208            let mut result = [0.0; DE_BOOR_SIZE];
209            let mut d = [0.0; DE_BOOR_SIZE];
210
211            for dim in 0..N {
212                for (j, dm) in d.iter_mut().enumerate().take(K + 1) {
213                    *dm = self.c[dim * nc + j + i - K];
214                }
215                result[dim] = self.deboor(i, t, &mut d);
216            }
217            Ok(result)
218        }
219    }
220
221    /// Evaluates one dimension of the B-spline at parameter `x` using the de Boor algorithm.
222    ///
223    /// `i` is the knot interval index such that `t[i] <= x <= t[i+1]`. `d` must be pre-loaded
224    /// with the K+1 relevant coefficients for the dimension being evaluated; it is modified
225    /// in-place and the result is returned as `d[K]`.
226    pub(crate) fn deboor(&self, i: usize, x: f64, d: &mut [f64; DE_BOOR_SIZE]) -> f64 {
227
228        for r in 1..K + 1 {
229            for j in (r..=K).into_iter().rev() {
230                let alpha =
231                    (x - self.t[j + i - K]) / (self.t[j + 1 + i - r] - self.t[j + i - K]);
232                d[j] = (1.0 - alpha) * d[j - 1] + alpha * d[j]
233            }
234        }
235        d[K]
236    }
237
238}
239
240/// Splits an interleaved coordinate array (as returned by [`SplineCurve::evaluate`]) into
241/// per-dimension vectors.
242///
243/// `n` is the number of dimensions. For example, an input `&[x0, y0, x1, y1, x2, y2]` with
244/// `n=2` returns `vec![vec![x0, x1, x2], vec![y0, y1, y2]]`.
245pub fn transpose(xyn: &[f64], n: usize) -> Vec<Vec<f64>>{
246    let m = xyn.len()/n;
247    let mut vn: Vec<Vec<f64>> = std::iter::repeat(Vec::with_capacity(m)).take(n).collect();
248    for v in xyn.chunks(n) {
249        for (i,x) in v.iter().enumerate() {
250            vn[i].push(*x);
251        }
252    }
253    vn
254}
255
256#[cfg(test)]
257mod tests {
258    use super::{transpose, SplineCurve};
259    use approx::assert_abs_diff_eq;
260
261    // spline test values from https://docs.rs/bspline/1.0.0/bspline/index.html crate
262
263    // --- error path tests ---
264
265    #[test]
266    fn try_new_rejects_too_few_coefficients() {
267        // K=3 requires at least K+1=4 coefficients per dimension
268        let result: std::result::Result<SplineCurve<3, 1>, _> = SplineCurve::try_new(
269            vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
270            vec![0.0, 0.0, 0.0], // only 3, need ≥ 4
271        );
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn evaluate_rejects_non_monotonic_params() {
277        let s: SplineCurve<1, 1> = SplineCurve::new(vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 1.0]);
278        assert!(s.evaluate(&[0.2, 0.5, 0.4]).is_err()); // 0.4 < 0.5 is not strictly increasing
279    }
280
281    #[test]
282    fn evaluate_rejects_coefficient_mismatch() {
283        // n=6 knots, K=1 → expects nc = n-(K+1) = 4 coefficients; providing 3 should error
284        let s: SplineCurve<1, 1> = SplineCurve::new(
285            vec![0.0, 0.0, 1.0, 2.0, 3.0, 3.0],
286            vec![0.0, 1.0, 2.0],
287        );
288        assert!(s.evaluate(&[0.5]).is_err());
289    }
290
291    // --- clamping test ---
292
293    #[test]
294    fn evaluate_clamps_out_of_range_params() {
295        // Linear spline from y=0 at t=0 to y=1 at t=1; params outside [0,1] should clamp
296        let s: SplineCurve<1, 1> = SplineCurve::new(vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 1.0]);
297        let yt = s.evaluate(&[-0.5, 1.5]).unwrap();
298        assert_abs_diff_eq!(yt[0], 0.0, epsilon = 1E-10); // clamped to t=0
299        assert_abs_diff_eq!(yt[1], 1.0, epsilon = 1E-10); // clamped to t=1
300    }
301
302    // --- eval() tests ---
303
304    #[test]
305    fn eval_returns_err_with_signed_distance_when_out_of_range() {
306        let mut s: SplineCurve<3, 1> = SplineCurve::new(
307            vec![-2.0, -2.0, -2.0, -2.0, -1.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0],
308            vec![0.0, 0.0, 0.0, 6.0, 0.0, 0.0, 0.0],
309        );
310        // Below range: distance = t - t_min = -3 - (-2) = -1
311        assert_abs_diff_eq!(s.eval(-3.0).unwrap_err(), -1.0, epsilon = 1E-10);
312        // Above range: distance = t - t_max = 3 - 2 = 1
313        assert_abs_diff_eq!(s.eval(3.0).unwrap_err(), 1.0, epsilon = 1E-10);
314    }
315
316    #[test]
317    fn eval_cache_resets_correctly_on_backward_step() {
318        let mut s: SplineCurve<3, 1> = SplineCurve::new(
319            vec![-2.0, -2.0, -2.0, -2.0, -1.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0],
320            vec![0.0, 0.0, 0.0, 6.0, 0.0, 0.0, 0.0],
321        );
322        // Forward: populates cache to a later interval
323        assert_abs_diff_eq!(s.eval(0.5).unwrap()[0], 2.875, epsilon = 1E-7);
324        // Backward: must reset cache; result must still be correct
325        assert_abs_diff_eq!(s.eval(-1.0).unwrap()[0], 1.0, epsilon = 1E-7);
326        // Forward again: cache reset again, result must be correct
327        assert_abs_diff_eq!(s.eval(0.5).unwrap()[0], 2.875, epsilon = 1E-7);
328    }
329
330    // --- 2D curve test ---
331
332    #[test]
333    fn linear_2d_spline() {
334        // Straight-line parametric curve from (0,0) to (1,1)
335        // Coefficients: first half = x-controls [0,1], second half = y-controls [0,1]
336        let s: SplineCurve<1, 2> = SplineCurve::new(
337            vec![0.0, 0.0, 1.0, 1.0],
338            vec![0.0, 1.0, 0.0, 1.0],
339        );
340        let r = s.evaluate(&[0.0, 0.5, 1.0]).unwrap();
341        // output is interleaved: [x0, y0, x1, y1, x2, y2]
342        assert_abs_diff_eq!(r[0], 0.0, epsilon = 1E-10); // x at t=0
343        assert_abs_diff_eq!(r[1], 0.0, epsilon = 1E-10); // y at t=0
344        assert_abs_diff_eq!(r[2], 0.5, epsilon = 1E-10); // x at t=0.5
345        assert_abs_diff_eq!(r[3], 0.5, epsilon = 1E-10); // y at t=0.5
346        assert_abs_diff_eq!(r[4], 1.0, epsilon = 1E-10); // x at t=1
347        assert_abs_diff_eq!(r[5], 1.0, epsilon = 1E-10); // y at t=1
348    }
349
350    // --- transpose tests ---
351
352    #[test]
353    fn transpose_splits_2d_coordinates() {
354        let flat = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0]; // [x0,y0, x1,y1, x2,y2]
355        let vecs = transpose(&flat, 2);
356        assert_eq!(vecs[0], vec![1.0, 3.0, 5.0]); // x values
357        assert_eq!(vecs[1], vec![2.0, 4.0, 6.0]); // y values
358    }
359
360    #[test]
361    fn transpose_splits_3d_coordinates() {
362        let flat = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0]; // [x0,y0,z0, x1,y1,z1]
363        let vecs = transpose(&flat, 3);
364        assert_eq!(vecs[0], vec![1.0, 4.0]);
365        assert_eq!(vecs[1], vec![2.0, 5.0]);
366        assert_eq!(vecs[2], vec![3.0, 6.0]);
367    }
368
369    #[test]
370    fn linear_bspline() {
371        let x = vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0];
372        let y = vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0];
373
374        let s: SplineCurve<1, 1> = SplineCurve::new(vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 1.0]);
375        let yt = s.evaluate(&x).unwrap();
376        y.iter()
377            .zip(yt.iter())
378            .for_each(|(&a, &b)| assert_abs_diff_eq!(a, b, epsilon = 1E-8));
379
380        #[cfg(feature = "plot")]
381        s.plot("test.png", (1500,1000)).unwrap();
382    }
383    #[test]
384    fn quadratic_bspline() {
385        let x = [0.0, 0.5, 1.0, 1.4, 1.5, 1.6, 2.0, 2.5, 3.0];
386        let y = [0.0, 0.125, 0.5, 0.74, 0.75, 0.74, 0.5, 0.125, 0.0];
387
388        let s: SplineCurve<2, 1> = SplineCurve::new(
389            vec![0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 3.0, 3.0],
390            vec![0.0, 0.0, 1.0, 0.0, 0.0],
391        );
392        let yt = s.evaluate(&x).unwrap();
393        y.iter()
394            .zip(yt.iter())
395            .for_each(|(&a, &b)| assert_abs_diff_eq!(a, b, epsilon = 1E-8));
396
397        #[cfg(feature = "plot")]
398        s.plot("test.png", (1500,1000)).unwrap();
399    }
400
401    #[test]
402    fn cubic_bspline() {
403        // expected
404        let x = vec![-2.0, -1.5, -1.0, -0.6, 0.0, 0.5, 1.5, 2.0];
405        let y = vec![0.0, 0.125, 1.0, 2.488, 4.0, 2.875, 0.12500001, 0.0];
406
407        let s: SplineCurve<3, 1> = SplineCurve::new(
408            vec![-2.0, -2.0, -2.0, -2.0, -1.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0],
409            vec![0.0, 0.0, 0.0, 6.0, 0.0, 0.0, 0.0],
410        );
411
412        let yt = s.evaluate(&x).unwrap();
413        y.iter()
414            .zip(yt.iter())
415            .for_each(|(&a, &b)| assert_abs_diff_eq!(a, b, epsilon = 1E-7));
416
417        #[cfg(feature = "plot")]
418        s.plot("test.png", (1000,1000)).unwrap();
419
420    }
421
422    #[test]
423    fn cubic_bspline_single_values() {
424        // expected
425        let x = vec![-2.0, -1.5, -1.0, -0.6, 0.0, 0.5, 1.5, 2.0];
426        let y = vec![0.0, 0.125, 1.0, 2.488, 4.0, 2.875, 0.12500001, 0.0];
427
428        let mut s: SplineCurve<3, 1> = SplineCurve::new(
429            vec![-2.0, -2.0, -2.0, -2.0, -1.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0],
430            vec![0.0, 0.0, 0.0, 6.0, 0.0, 0.0, 0.0],
431        );
432
433        let yt: Vec<f64> = x.into_iter().map(|x|s.eval(x).unwrap()[0]).collect();
434        y.iter()
435            .zip(yt.iter())
436            .for_each(|(&a, &b)| assert_abs_diff_eq!(a, b, epsilon = 1E-7));
437
438        #[cfg(feature = "plot")]
439        s.plot("test.png", (1000,1000)).unwrap();
440
441    }
442
443
444    #[test]
445    fn quartic_bspline() {
446        let x = vec![0.0, 0.4, 1.0, 1.5, 2.0, 2.5, 3.0, 3.2, 4.1, 4.5, 5.0];
447        let y = vec![
448            0.0,
449            0.0010666668,
450            0.041666668,
451            0.19791667,
452            0.4583333,
453            0.5989583,
454            0.4583333,
455            0.35206667,
456            0.02733751,
457            0.002604167,
458            0.0,
459        ];
460        let s: SplineCurve<4, 1> = SplineCurve::new(
461            vec![
462                0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 5.0, 5.0, 5.0, 5.0,
463            ],
464            vec![0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
465        );
466        let yt = s.evaluate(&x).unwrap();
467        y.iter()
468            .zip(yt.iter())
469            .for_each(|(&a, &b)| assert_abs_diff_eq!(a, b, epsilon = 1E-7));
470
471        #[cfg(feature = "plot")]
472        s.plot("test.png", (2000,1000)).unwrap();
473    }
474}