Skip to main content

rust_robotics_planning/
bspline_path.rs

1#![allow(dead_code, clippy::needless_range_loop, clippy::type_complexity)]
2
3//! B-Spline path generation
4//!
5//! Generates smooth paths through or near control points using B-spline curves.
6//! Supports configurable degree and number of output sample points.
7//!
8//! Reference:
9//! - PythonRobotics BSplinePath by Atsushi Sakai
10
11/// Result of B-spline path generation.
12#[derive(Debug, Clone)]
13pub struct BSplinePath {
14    /// Sampled (x, y) positions along the path.
15    pub path: Vec<(f64, f64)>,
16    /// Heading angle \[rad\] at each sampled point.
17    pub heading: Vec<f64>,
18    /// Curvature at each sampled point.
19    pub curvature: Vec<f64>,
20}
21
22/// Generate a B-spline path from control points.
23///
24/// # Arguments
25/// * `control_x` - x coordinates of control points
26/// * `control_y` - y coordinates of control points
27/// * `n_path_points` - number of points to sample along the path
28/// * `degree` - B-spline degree (must satisfy 1 <= degree <= n_control_points - 1)
29///
30/// # Returns
31/// A `BSplinePath` containing the sampled path, heading, and curvature,
32/// or `None` if the inputs are invalid.
33pub fn generate_bspline_path(
34    control_x: &[f64],
35    control_y: &[f64],
36    n_path_points: usize,
37    degree: usize,
38) -> Option<BSplinePath> {
39    let n = control_x.len();
40    if n != control_y.len() || n < 2 || n_path_points == 0 {
41        return None;
42    }
43    let degree = degree.min(n - 1);
44    if degree == 0 {
45        return None;
46    }
47
48    let knots = generate_clamped_knot_vector(n, degree);
49
50    let t_min = knots[degree];
51    let t_max = knots[n]; // = knots[n + degree + 1 - (degree + 1)]
52
53    let mut path = Vec::with_capacity(n_path_points);
54    let mut heading = Vec::with_capacity(n_path_points);
55    let mut curvature = Vec::with_capacity(n_path_points);
56
57    for i in 0..n_path_points {
58        let t = if n_path_points == 1 {
59            t_min
60        } else {
61            t_min + (t_max - t_min) * (i as f64) / ((n_path_points - 1) as f64)
62        };
63
64        // Clamp t to valid range to avoid numerical issues at the boundary
65        let t = t.min(t_max - 1e-10);
66
67        let (x, y) = eval_bspline(t, control_x, control_y, degree, &knots);
68        let (dx, dy) = eval_bspline_derivative(t, control_x, control_y, degree, &knots, 1);
69        let (ddx, ddy) = eval_bspline_derivative(t, control_x, control_y, degree, &knots, 2);
70
71        path.push((x, y));
72        heading.push(dy.atan2(dx));
73
74        let denom = (dx * dx + dy * dy).powf(2.0 / 3.0);
75        let k = if denom.abs() > 1e-15 {
76            (ddy * dx - ddx * dy) / denom
77        } else {
78            0.0
79        };
80        curvature.push(k);
81    }
82
83    Some(BSplinePath {
84        path,
85        heading,
86        curvature,
87    })
88}
89
90/// Generate an interpolating B-spline path.
91///
92/// Uses chord-length parameterisation so the resulting curve passes through
93/// all given waypoints (like `interpolate_b_spline_path` in PythonRobotics).
94///
95/// # Arguments
96/// * `x` - x coordinates of waypoints
97/// * `y` - y coordinates of waypoints
98/// * `n_path_points` - number of output sample points
99/// * `degree` - B-spline degree (must satisfy 1 <= degree <= n_waypoints - 1)
100///
101/// # Returns
102/// A `BSplinePath` or `None` if the inputs are invalid.
103pub fn interpolate_bspline_path(
104    x: &[f64],
105    y: &[f64],
106    n_path_points: usize,
107    degree: usize,
108) -> Option<BSplinePath> {
109    let n = x.len();
110    if n != y.len() || n < 2 || n_path_points == 0 {
111        return None;
112    }
113    let degree = degree.min(n - 1);
114    if degree == 0 {
115        return None;
116    }
117
118    // Compute normalised chord-length parameters for each waypoint
119    let distances = calc_normalised_cumulative_distances(x, y);
120
121    // Fit independent B-spline curves for x(t) and y(t) by interpolation
122    let ctrl_x = fit_bspline_interpolation(&distances, x, degree)?;
123    let ctrl_y = fit_bspline_interpolation(&distances, y, degree)?;
124
125    generate_bspline_path(&ctrl_x, &ctrl_y, n_path_points, degree)
126}
127
128// ---------------------------------------------------------------------------
129// Internal helpers
130// ---------------------------------------------------------------------------
131
132/// Generate a clamped (open) uniform knot vector.
133///
134/// For `n` control points and given `degree`, the knot vector has
135/// `n + degree + 1` elements with `degree + 1` repeated values at each end.
136fn generate_clamped_knot_vector(n: usize, degree: usize) -> Vec<f64> {
137    let m = n + degree + 1;
138    let mut knots = Vec::with_capacity(m);
139    for i in 0..m {
140        if i <= degree {
141            knots.push(0.0);
142        } else if i >= n {
143            knots.push((n - degree) as f64);
144        } else {
145            knots.push((i - degree) as f64);
146        }
147    }
148    knots
149}
150
151/// Evaluate the B-spline curve at parameter `t` using De Boor's algorithm.
152fn eval_bspline(t: f64, cx: &[f64], cy: &[f64], degree: usize, knots: &[f64]) -> (f64, f64) {
153    let n = cx.len();
154    let mut x = 0.0;
155    let mut y = 0.0;
156    for i in 0..n {
157        let b = bspline_basis(i, degree, t, knots);
158        x += b * cx[i];
159        y += b * cy[i];
160    }
161    (x, y)
162}
163
164/// Evaluate the `order`-th derivative of the B-spline curve at parameter `t`.
165fn eval_bspline_derivative(
166    t: f64,
167    cx: &[f64],
168    cy: &[f64],
169    degree: usize,
170    knots: &[f64],
171    order: usize,
172) -> (f64, f64) {
173    if order == 0 {
174        return eval_bspline(t, cx, cy, degree, knots);
175    }
176    if order > degree {
177        return (0.0, 0.0);
178    }
179
180    // Compute derivative control points iteratively
181    let mut dx: Vec<f64> = cx.to_vec();
182    let mut dy: Vec<f64> = cy.to_vec();
183    let mut current_knots = knots.to_vec();
184    let mut current_degree = degree;
185
186    for _ in 0..order {
187        let nn = dx.len();
188        if nn <= 1 {
189            return (0.0, 0.0);
190        }
191        let mut new_dx = Vec::with_capacity(nn - 1);
192        let mut new_dy = Vec::with_capacity(nn - 1);
193        for i in 0..nn - 1 {
194            let denom = current_knots[i + current_degree + 1] - current_knots[i + 1];
195            let factor = if denom.abs() > 1e-15 {
196                current_degree as f64 / denom
197            } else {
198                0.0
199            };
200            new_dx.push(factor * (dx[i + 1] - dx[i]));
201            new_dy.push(factor * (dy[i + 1] - dy[i]));
202        }
203        dx = new_dx;
204        dy = new_dy;
205        // Remove first and last knot for the derivative knot vector
206        current_knots = current_knots[1..current_knots.len() - 1].to_vec();
207        current_degree -= 1;
208    }
209
210    eval_bspline(t, &dx, &dy, current_degree, &current_knots)
211}
212
213/// Evaluate the B-spline basis function N_{i,p}(t) using the Cox-de Boor recursion.
214fn bspline_basis(i: usize, degree: usize, t: f64, knots: &[f64]) -> f64 {
215    if degree == 0 {
216        return if knots[i] <= t && t < knots[i + 1] {
217            1.0
218        } else {
219            0.0
220        };
221    }
222
223    let mut left = 0.0;
224    let denom_l = knots[i + degree] - knots[i];
225    if denom_l.abs() > 1e-15 {
226        left = (t - knots[i]) / denom_l * bspline_basis(i, degree - 1, t, knots);
227    }
228
229    let mut right = 0.0;
230    let denom_r = knots[i + degree + 1] - knots[i + 1];
231    if denom_r.abs() > 1e-15 {
232        right = (knots[i + degree + 1] - t) / denom_r * bspline_basis(i + 1, degree - 1, t, knots);
233    }
234
235    left + right
236}
237
238/// Compute normalised cumulative chord-length distances.
239fn calc_normalised_cumulative_distances(x: &[f64], y: &[f64]) -> Vec<f64> {
240    let n = x.len();
241    let mut dists = Vec::with_capacity(n);
242    dists.push(0.0);
243    for i in 1..n {
244        let dx = x[i] - x[i - 1];
245        let dy = y[i] - y[i - 1];
246        dists.push(dists[i - 1] + (dx * dx + dy * dy).sqrt());
247    }
248    let total = *dists.last().unwrap();
249    if total > 1e-15 {
250        for d in &mut dists {
251            *d /= total;
252        }
253    }
254    dists
255}
256
257/// Fit B-spline control points so the curve interpolates the given data points.
258///
259/// Solves `N * P = D` where `N` is the basis function collocation matrix.
260/// Uses the clamped knot vector with knots placed at the data parameter values.
261fn fit_bspline_interpolation(params: &[f64], values: &[f64], degree: usize) -> Option<Vec<f64>> {
262    let n = params.len();
263
264    // Build knot vector for interpolation.
265    // For n data points and degree p, we need n + p + 1 knots.
266    // Use averaging method for interior knots.
267    let knots = build_interpolation_knot_vector(params, n, degree);
268
269    // Build collocation matrix
270    let mut mat = nalgebra::DMatrix::zeros(n, n);
271    for row in 0..n {
272        let t = if row == n - 1 {
273            params[row] - 1e-10 // avoid landing exactly on the last knot
274        } else {
275            params[row]
276        };
277        for col in 0..n {
278            mat[(row, col)] = bspline_basis(col, degree, t, &knots);
279        }
280    }
281
282    let rhs = nalgebra::DVector::from_column_slice(values);
283    // Solve the linear system
284    let decomp = mat.lu();
285    let solution = decomp.solve(&rhs)?;
286
287    Some(solution.as_slice().to_vec())
288}
289
290/// Build a knot vector for B-spline interpolation using the averaging method.
291fn build_interpolation_knot_vector(params: &[f64], n: usize, degree: usize) -> Vec<f64> {
292    let m = n + degree + 1;
293    let mut knots = vec![0.0; m];
294
295    // First degree+1 knots = 0
296    // Last degree+1 knots = 1 (since params are normalised to [0,1])
297    let last_val = *params.last().unwrap();
298    for i in (m - degree - 1)..m {
299        knots[i] = last_val;
300    }
301
302    // Interior knots by averaging
303    for j in 1..(n - degree) {
304        let mut sum = 0.0;
305        for i in j..(j + degree) {
306            sum += params[i];
307        }
308        knots[j + degree] = sum / degree as f64;
309    }
310
311    knots
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
319        (a - b).abs() < tol
320    }
321
322    #[test]
323    fn test_clamped_knot_vector() {
324        let knots = generate_clamped_knot_vector(5, 3);
325        // 5 + 3 + 1 = 9 knots
326        assert_eq!(knots.len(), 9);
327        // First degree+1 values are 0
328        assert_eq!(&knots[..4], &[0.0, 0.0, 0.0, 0.0]);
329        // Last degree+1 values equal n - degree = 2
330        assert_eq!(&knots[5..], &[2.0, 2.0, 2.0, 2.0]);
331        assert_eq!(knots[4], 1.0);
332    }
333
334    #[test]
335    fn test_basis_partition_of_unity() {
336        let n = 5;
337        let degree = 3;
338        let knots = generate_clamped_knot_vector(n, degree);
339
340        // Basis functions should sum to 1 at any parameter value in [t_min, t_max)
341        for k in 0..20 {
342            let t = (k as f64) * 0.1;
343            if t >= (n - degree) as f64 {
344                continue;
345            }
346            let sum: f64 = (0..n).map(|i| bspline_basis(i, degree, t, &knots)).sum();
347            assert!(
348                approx_eq(sum, 1.0, 1e-12),
349                "Partition of unity failed at t={t}: sum={sum}"
350            );
351        }
352    }
353
354    #[test]
355    fn test_generate_bspline_path_basic() {
356        let cx = vec![-1.0, 3.0, 4.0, 2.0, 1.0];
357        let cy = vec![0.0, -3.0, 1.0, 1.0, 3.0];
358        let result = generate_bspline_path(&cx, &cy, 50, 3);
359        assert!(result.is_some());
360        let bsp = result.unwrap();
361        assert_eq!(bsp.path.len(), 50);
362        assert_eq!(bsp.heading.len(), 50);
363        assert_eq!(bsp.curvature.len(), 50);
364    }
365
366    #[test]
367    fn test_path_starts_and_ends_at_control_endpoints() {
368        let cx = vec![-1.0, 3.0, 4.0, 2.0, 1.0];
369        let cy = vec![0.0, -3.0, 1.0, 1.0, 3.0];
370        let bsp = generate_bspline_path(&cx, &cy, 100, 3).unwrap();
371
372        // With clamped knot vector, the curve starts at first and ends at last
373        // control point.
374        let first = bsp.path[0];
375        let last = bsp.path[bsp.path.len() - 1];
376        assert!(approx_eq(first.0, cx[0], 1e-9), "Start x mismatch");
377        assert!(approx_eq(first.1, cy[0], 1e-9), "Start y mismatch");
378        assert!(
379            approx_eq(last.0, *cx.last().unwrap(), 1e-9),
380            "End x mismatch"
381        );
382        assert!(
383            approx_eq(last.1, *cy.last().unwrap(), 1e-9),
384            "End y mismatch"
385        );
386    }
387
388    #[test]
389    fn test_different_degrees() {
390        let cx = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
391        let cy = vec![0.0, 2.0, -1.0, 3.0, 0.0, 1.0];
392
393        for degree in 1..=5 {
394            let result = generate_bspline_path(&cx, &cy, 30, degree);
395            assert!(result.is_some(), "Failed for degree={degree}");
396            let bsp = result.unwrap();
397            assert_eq!(bsp.path.len(), 30);
398
399            // Start/end should match first/last control point
400            let first = bsp.path[0];
401            let last = bsp.path[bsp.path.len() - 1];
402            assert!(approx_eq(first.0, cx[0], 1e-9), "degree={degree} start x");
403            assert!(approx_eq(first.1, cy[0], 1e-9), "degree={degree} start y");
404            assert!(
405                approx_eq(last.0, *cx.last().unwrap(), 1e-9),
406                "degree={degree} end x"
407            );
408            assert!(
409                approx_eq(last.1, *cy.last().unwrap(), 1e-9),
410                "degree={degree} end y"
411            );
412        }
413    }
414
415    #[test]
416    fn test_degree_clamped_to_valid_range() {
417        // degree > n-1 should be clamped
418        let cx = vec![0.0, 1.0, 2.0];
419        let cy = vec![0.0, 1.0, 0.0];
420        let result = generate_bspline_path(&cx, &cy, 20, 10);
421        assert!(result.is_some());
422    }
423
424    #[test]
425    fn test_invalid_inputs() {
426        assert!(generate_bspline_path(&[], &[], 10, 3).is_none());
427        assert!(generate_bspline_path(&[1.0], &[2.0], 10, 3).is_none());
428        assert!(generate_bspline_path(&[1.0, 2.0], &[1.0], 10, 3).is_none());
429        assert!(generate_bspline_path(&[1.0, 2.0], &[1.0, 2.0], 0, 3).is_none());
430    }
431
432    #[test]
433    fn test_linear_degree1_is_straight() {
434        // Degree-1 B-spline through collinear points should be a straight line
435        let cx = vec![0.0, 1.0, 2.0, 3.0];
436        let cy = vec![0.0, 1.0, 2.0, 3.0];
437        let bsp = generate_bspline_path(&cx, &cy, 50, 1).unwrap();
438
439        for &(px, py) in &bsp.path {
440            assert!(
441                approx_eq(px, py, 1e-9),
442                "Point ({px}, {py}) deviates from y=x"
443            );
444        }
445    }
446
447    #[test]
448    fn test_heading_is_consistent_with_path() {
449        let cx = vec![0.0, 2.0, 4.0, 6.0];
450        let cy = vec![0.0, 3.0, 1.0, 4.0];
451        let bsp = generate_bspline_path(&cx, &cy, 100, 3).unwrap();
452
453        // The heading should roughly match the direction between consecutive points
454        for i in 1..bsp.path.len() - 1 {
455            let dx = bsp.path[i + 1].0 - bsp.path[i - 1].0;
456            let dy = bsp.path[i + 1].1 - bsp.path[i - 1].1;
457            let fd_heading = dy.atan2(dx);
458            let diff = (bsp.heading[i] - fd_heading).abs();
459            // Allow some tolerance since finite differences are approximate
460            assert!(
461                diff < 0.3 || (diff - std::f64::consts::TAU).abs() < 0.3,
462                "Heading mismatch at i={i}: analytic={}, fd={fd_heading}",
463                bsp.heading[i]
464            );
465        }
466    }
467
468    #[test]
469    fn test_interpolate_bspline_path_passes_through_waypoints() {
470        let wx = vec![-1.0, 3.0, 4.0, 2.0, 1.0];
471        let wy = vec![0.0, -3.0, 1.0, 1.0, 3.0];
472        let bsp = interpolate_bspline_path(&wx, &wy, 200, 3).unwrap();
473
474        // The interpolated curve should start and end at the first/last waypoints
475        let first = bsp.path[0];
476        let last = bsp.path[bsp.path.len() - 1];
477        assert!(approx_eq(first.0, wx[0], 1e-6), "Start x");
478        assert!(approx_eq(first.1, wy[0], 1e-6), "Start y");
479        assert!(approx_eq(last.0, *wx.last().unwrap(), 1e-6), "End x");
480        assert!(approx_eq(last.1, *wy.last().unwrap(), 1e-6), "End y");
481    }
482
483    #[test]
484    fn test_interpolate_bspline_path_invalid() {
485        assert!(interpolate_bspline_path(&[], &[], 10, 3).is_none());
486        assert!(interpolate_bspline_path(&[1.0], &[2.0], 10, 3).is_none());
487    }
488
489    #[test]
490    fn test_single_output_point() {
491        let cx = vec![0.0, 1.0, 2.0, 3.0];
492        let cy = vec![0.0, 1.0, 0.0, 1.0];
493        let bsp = generate_bspline_path(&cx, &cy, 1, 3).unwrap();
494        assert_eq!(bsp.path.len(), 1);
495        // Single point should be the start of the curve
496        assert!(approx_eq(bsp.path[0].0, 0.0, 1e-9));
497        assert!(approx_eq(bsp.path[0].1, 0.0, 1e-9));
498    }
499
500    #[test]
501    fn test_two_control_points() {
502        let cx = vec![0.0, 5.0];
503        let cy = vec![0.0, 3.0];
504        let bsp = generate_bspline_path(&cx, &cy, 10, 1).unwrap();
505        assert_eq!(bsp.path.len(), 10);
506        // Should be a straight line from (0,0) to (5,3)
507        assert!(approx_eq(bsp.path[0].0, 0.0, 1e-9));
508        assert!(approx_eq(bsp.path[0].1, 0.0, 1e-9));
509        assert!(approx_eq(bsp.path[9].0, 5.0, 1e-9));
510        assert!(approx_eq(bsp.path[9].1, 3.0, 1e-9));
511    }
512
513    #[test]
514    fn test_curvature_zero_for_straight_line() {
515        let cx = vec![0.0, 1.0, 2.0, 3.0, 4.0];
516        let cy = vec![0.0, 0.0, 0.0, 0.0, 0.0];
517        let bsp = generate_bspline_path(&cx, &cy, 50, 3).unwrap();
518        for (i, &k) in bsp.curvature.iter().enumerate() {
519            assert!(
520                k.abs() < 1e-6,
521                "Curvature should be ~0 for straight line, got {k} at index {i}"
522            );
523        }
524    }
525}