Skip to main content

rust_robotics_planning/
clothoid_path.rs

1//! Clothoid (Euler Spiral) Path Planner
2//!
3//! A clothoid is a curve whose curvature changes linearly with arc length.
4//! It is the industry standard for road geometry design in highway engineering
5//! and autonomous driving, providing smooth curvature transitions.
6//!
7//! The clothoid is parametrized by:
8//!   `kappa(s) = kappa0 + kappa1 * s`
9//!
10//! where `kappa0` is the initial curvature, `kappa1` is the curvature rate
11//! (sharpness), and `s` is the arc length.
12//!
13//! The position along the curve is obtained by integrating:
14//!   `x(s) = integral cos(theta(t)) dt`
15//!   `y(s) = integral sin(theta(t)) dt`
16//!
17//! where `theta(s) = theta0 + kappa0 * s + 0.5 * kappa1 * s^2`.
18//!
19//! # References
20//!
21//! * Bertolazzi, E. & Frego, M. (2015). "G1 fitting with clothoids".
22//!   *Mathematical Methods in the Applied Sciences*, 38(5), 881-897.
23
24use std::f64::consts::PI;
25
26use rust_robotics_core::error::{RoboticsError, RoboticsResult};
27use rust_robotics_core::types::{Path2D, Pose2D};
28
29// --- Fresnel integrals -------------------------------------------------------
30
31/// Evaluate the Fresnel integrals C(t) and S(t) defined as:
32///   C(t) = integral_0^t cos(pi/2 * u^2) du
33///   S(t) = integral_0^t sin(pi/2 * u^2) du
34///
35/// Uses a power-series expansion, which converges well for moderate |t|.
36pub fn fresnel(t: f64) -> (f64, f64) {
37    let t_abs = t.abs();
38
39    if t_abs < 1e-15 {
40        return (0.0, 0.0);
41    }
42
43    // For large arguments, use asymptotic approximation
44    if t_abs > 6.0 {
45        let (c, s) = fresnel_asymptotic(t_abs);
46        if t < 0.0 {
47            return (-c, -s);
48        }
49        return (c, s);
50    }
51
52    // Power-series expansion
53    let (c, s) = fresnel_series(t_abs);
54    if t < 0.0 {
55        (-c, -s)
56    } else {
57        (c, s)
58    }
59}
60
61/// Fresnel integrals via power-series expansion.
62/// Good convergence for |t| <= ~6.
63fn fresnel_series(t: f64) -> (f64, f64) {
64    let half_pi = PI / 2.0;
65    let x = half_pi * t * t;
66
67    let mut c_sum = 0.0;
68    let mut s_sum = 0.0;
69    let mut term = t; // first term of C series: t
70    let mut sign = 1.0_f64;
71
72    // C(t) = sum_{k=0}^{inf} (-1)^k * (pi/2)^{2k} * t^{4k+1} / ((4k+1) * (2k)!)
73    // S(t) = sum_{k=0}^{inf} (-1)^k * (pi/2)^{2k+1} * t^{4k+3} / ((4k+3) * (2k+1)!)
74    for k in 0..25 {
75        let c_term = sign * term / (4 * k + 1) as f64;
76        c_sum += c_term;
77
78        let s_term = sign * term * x / (4 * k + 3) as f64 / (2 * k + 1) as f64;
79        s_sum += s_term;
80
81        if c_term.abs() < 1e-16 && s_term.abs() < 1e-16 {
82            break;
83        }
84
85        // Update term: multiply by (pi/2)^2 * t^4 / ((2k+1)*(2k+2))
86        term *= x * x / ((2 * k + 1) as f64 * (2 * k + 2) as f64);
87        sign = -sign;
88    }
89
90    (c_sum, s_sum)
91}
92
93/// Asymptotic expansion for large arguments.
94fn fresnel_asymptotic(t: f64) -> (f64, f64) {
95    let x = PI * t * t;
96    let sin_x = (x / 2.0).sin();
97    let cos_x = (x / 2.0).cos();
98    let inv_x = 1.0 / (PI * t);
99
100    // f(t) ~ 1/(pi*t) * (1 - 3/(pi*t)^4 + ...)
101    // g(t) ~ 1/(pi*t)^2 * (1 - 15/(pi*t)^4 + ...)
102    let pi_t2 = (PI * t).powi(2);
103    let f = inv_x * (1.0 - 3.0 / (pi_t2 * pi_t2));
104    let g = inv_x / (PI * t) * (1.0 - 15.0 / (pi_t2 * pi_t2));
105
106    let c = 0.5 + f * sin_x - g * cos_x;
107    let s = 0.5 - f * cos_x - g * sin_x;
108    (c, s)
109}
110
111// --- Clothoid primitives -----------------------------------------------------
112
113/// Evaluate a clothoid curve at arc length `s`, starting from the origin
114/// with heading `theta0`, initial curvature `kappa0`, and sharpness `kappa1`.
115///
116/// Returns `(x, y, theta)` at arc length `s`.
117///
118/// Integration is performed via Simpson's rule for numerical stability.
119pub fn clothoid_eval(theta0: f64, kappa0: f64, kappa1: f64, s: f64) -> (f64, f64, f64) {
120    // Integrate position by numerical quadrature (Simpson's rule)
121    let n = 100; // number of intervals (must be even)
122    let ds = s / n as f64;
123    let mut x = 0.0;
124    let mut y = 0.0;
125
126    for i in 0..=n {
127        let si = ds * i as f64;
128        let theta = theta0 + kappa0 * si + 0.5 * kappa1 * si * si;
129        let weight = if i == 0 || i == n {
130            1.0
131        } else if i % 2 == 1 {
132            4.0
133        } else {
134            2.0
135        };
136        x += weight * theta.cos();
137        y += weight * theta.sin();
138    }
139    x *= ds / 3.0;
140    y *= ds / 3.0;
141
142    let theta_end = theta0 + kappa0 * s + 0.5 * kappa1 * s * s;
143    (x, y, theta_end)
144}
145
146/// Sample `n_points` along a clothoid starting at pose `(x0, y0, theta0)`,
147/// with initial curvature `kappa0`, sharpness `kappa1`, and total arc length `length`.
148///
149/// Returns `(xs, ys, thetas, kappas)` vectors at each sample point.
150pub fn clothoid_sample(
151    x0: f64,
152    y0: f64,
153    theta0: f64,
154    kappa0: f64,
155    kappa1: f64,
156    length: f64,
157    n_points: usize,
158) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
159    let mut xs = Vec::with_capacity(n_points);
160    let mut ys = Vec::with_capacity(n_points);
161    let mut thetas = Vec::with_capacity(n_points);
162    let mut kappas = Vec::with_capacity(n_points);
163
164    let cos0 = theta0.cos();
165    let sin0 = theta0.sin();
166
167    for i in 0..n_points {
168        let s = if n_points > 1 {
169            length * i as f64 / (n_points - 1) as f64
170        } else {
171            0.0
172        };
173
174        let (dx, dy, theta) = clothoid_eval(0.0, kappa0, kappa1, s);
175        // Rotate by theta0 and translate
176        let x = x0 + cos0 * dx - sin0 * dy;
177        let y = y0 + sin0 * dx + cos0 * dy;
178        let kappa = kappa0 + kappa1 * s;
179
180        xs.push(x);
181        ys.push(y);
182        thetas.push(theta0 + theta);
183        kappas.push(kappa);
184    }
185
186    (xs, ys, thetas, kappas)
187}
188
189// --- G1 Hermite interpolation (clothoid fitting) ----------------------------
190
191/// Normalize angle to [-pi, pi].
192fn normalize_angle(mut angle: f64) -> f64 {
193    while angle > PI {
194        angle -= 2.0 * PI;
195    }
196    while angle < -PI {
197        angle += 2.0 * PI;
198    }
199    angle
200}
201
202/// Solve the G1 Hermite interpolation problem: find a clothoid connecting
203/// two poses `(x0, y0, theta0)` and `(x1, y1, theta1)`.
204///
205/// Returns `(kappa0, kappa1, length)` -- initial curvature, sharpness, arc length.
206///
207/// Uses an iterative Newton-Raphson method on the clothoid parameters.
208pub fn clothoid_g1_fit(start: &Pose2D, goal: &Pose2D) -> RoboticsResult<(f64, f64, f64)> {
209    let dx = goal.x - start.x;
210    let dy = goal.y - start.y;
211    let chord = (dx * dx + dy * dy).sqrt();
212
213    if chord < 1e-12 {
214        return Err(RoboticsError::InvalidParameter(
215            "start and goal poses are coincident".to_string(),
216        ));
217    }
218
219    let phi = dy.atan2(dx);
220    let alpha = normalize_angle(start.yaw - phi);
221    let beta = normalize_angle(goal.yaw - phi);
222
223    // Initial guess for the length
224    let mut length = chord;
225
226    // Newton iteration to find kappa0, kappa1, and length.
227    // In the rotated frame the clothoid starts at (0,0) with heading alpha
228    // and must reach (chord, 0) with heading beta.
229
230    let max_iter = 50;
231    let tol = 1e-12;
232
233    let mut k0 = (alpha + beta) / length;
234    let mut k1 = 2.0 * (beta - alpha) / (length * length);
235
236    for _iter in 0..max_iter {
237        // Evaluate clothoid at current parameters
238        let (ex, ey, _etheta) = clothoid_eval(alpha, k0, k1, length);
239
240        // Residuals: endpoint should be at (chord, 0) with heading beta
241        let rx = ex - chord;
242        let ry = ey;
243        let r_theta = alpha + k0 * length + 0.5 * k1 * length * length - beta;
244
245        if rx.abs() < tol && ry.abs() < tol && r_theta.abs() < tol {
246            break;
247        }
248
249        // Compute Jacobian numerically
250        let eps = 1e-8;
251
252        let (ex_dk0, ey_dk0, _) = clothoid_eval(alpha, k0 + eps, k1, length);
253        let dex_dk0 = (ex_dk0 - ex) / eps;
254        let dey_dk0 = (ey_dk0 - ey) / eps;
255
256        let (ex_dk1, ey_dk1, _) = clothoid_eval(alpha, k0, k1 + eps, length);
257        let dex_dk1 = (ex_dk1 - ex) / eps;
258        let dey_dk1 = (ey_dk1 - ey) / eps;
259
260        let (ex_dl, ey_dl, _) = clothoid_eval(alpha, k0, k1, length + eps);
261        let dex_dl = (ex_dl - ex) / eps;
262        let dey_dl = (ey_dl - ey) / eps;
263
264        // 3x3 Jacobian for (rx, ry, r_theta) w.r.t. (k0, k1, length)
265        let dtheta_dk0 = length;
266        let dtheta_dk1 = 0.5 * length * length;
267        let dtheta_dl = k0 + k1 * length;
268
269        // Solve 3x3 system using nalgebra
270        let jac = nalgebra::Matrix3::new(
271            dex_dk0, dex_dk1, dex_dl, dey_dk0, dey_dk1, dey_dl, dtheta_dk0, dtheta_dk1, dtheta_dl,
272        );
273
274        let residual = nalgebra::Vector3::new(rx, ry, r_theta);
275
276        let Some(delta) = jac.try_inverse().map(|inv| inv * residual) else {
277            return Err(RoboticsError::NumericalError(
278                "Jacobian is singular during clothoid fitting".to_string(),
279            ));
280        };
281
282        k0 -= delta[0];
283        k1 -= delta[1];
284        length -= delta[2];
285
286        // Keep length positive
287        if length < 1e-10 {
288            length = chord * 0.5;
289        }
290    }
291
292    // Verify solution
293    let (ex, ey, _) = clothoid_eval(alpha, k0, k1, length);
294    let err = ((ex - chord).powi(2) + ey.powi(2)).sqrt();
295    if err > 1e-6 {
296        return Err(RoboticsError::NumericalError(format!(
297            "clothoid G1 fitting did not converge (residual={err:.6e})"
298        )));
299    }
300
301    Ok((k0, k1, length))
302}
303
304// --- ClothoidPath struct -----------------------------------------------------
305
306/// A clothoid (Euler spiral) path connecting two poses.
307#[derive(Debug, Clone)]
308pub struct ClothoidPath {
309    /// Start pose
310    pub start: Pose2D,
311    /// End pose
312    pub goal: Pose2D,
313    /// Initial curvature
314    pub kappa0: f64,
315    /// Curvature rate (sharpness)
316    pub kappa1: f64,
317    /// Total arc length
318    pub length: f64,
319    /// Sampled path points
320    pub path: Path2D,
321    /// Heading at each sample point
322    pub yaw: Vec<f64>,
323    /// Curvature at each sample point
324    pub curvature: Vec<f64>,
325}
326
327/// Configuration for the clothoid path planner.
328#[derive(Debug, Clone)]
329pub struct ClothoidConfig {
330    /// Number of sample points along the path
331    pub n_points: usize,
332}
333
334impl Default for ClothoidConfig {
335    fn default() -> Self {
336        Self { n_points: 100 }
337    }
338}
339
340/// Clothoid path planner.
341///
342/// Computes a clothoid curve (Euler spiral) that connects two poses with
343/// G1 continuity (matching position and heading at both endpoints).
344#[derive(Debug, Clone)]
345pub struct ClothoidPlanner {
346    pub config: ClothoidConfig,
347}
348
349impl ClothoidPlanner {
350    pub fn new(config: ClothoidConfig) -> Self {
351        Self { config }
352    }
353
354    /// Plan a clothoid path from `start` to `goal`.
355    pub fn plan(&self, start: &Pose2D, goal: &Pose2D) -> RoboticsResult<ClothoidPath> {
356        let (kappa0, kappa1, length) = clothoid_g1_fit(start, goal)?;
357
358        let (xs, ys, yaw, curvature) = clothoid_sample(
359            start.x,
360            start.y,
361            start.yaw,
362            kappa0,
363            kappa1,
364            length,
365            self.config.n_points,
366        );
367
368        let path = Path2D::from_xy(&xs, &ys);
369
370        Ok(ClothoidPath {
371            start: *start,
372            goal: *goal,
373            kappa0,
374            kappa1,
375            length,
376            path,
377            yaw,
378            curvature,
379        })
380    }
381
382    /// Plan a clothoid path from raw coordinates.
383    pub fn plan_from_xy(
384        &self,
385        x0: f64,
386        y0: f64,
387        yaw0: f64,
388        x1: f64,
389        y1: f64,
390        yaw1: f64,
391    ) -> RoboticsResult<ClothoidPath> {
392        self.plan(&Pose2D::new(x0, y0, yaw0), &Pose2D::new(x1, y1, yaw1))
393    }
394}
395
396// --- Tests -------------------------------------------------------------------
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::f64::consts::FRAC_PI_2;
402
403    const TOL: f64 = 1e-6;
404
405    fn assert_close(actual: f64, expected: f64, tol: f64, label: &str) {
406        assert!(
407            (actual - expected).abs() < tol,
408            "{label}: actual={actual}, expected={expected}, diff={}",
409            (actual - expected).abs()
410        );
411    }
412
413    // -- Fresnel integral tests -----------------------------------------------
414
415    #[test]
416    fn test_fresnel_zero() {
417        let (c, s) = fresnel(0.0);
418        assert_close(c, 0.0, 1e-15, "C(0)");
419        assert_close(s, 0.0, 1e-15, "S(0)");
420    }
421
422    #[test]
423    fn test_fresnel_known_values() {
424        // C(1) ~ 0.7798934, S(1) ~ 0.4382591
425        let (c, s) = fresnel(1.0);
426        assert_close(c, 0.7798934003768228, 1e-10, "C(1)");
427        assert_close(s, 0.4382591473903548, 1e-10, "S(1)");
428    }
429
430    #[test]
431    fn test_fresnel_odd_symmetry() {
432        let (c_pos, s_pos) = fresnel(2.0);
433        let (c_neg, s_neg) = fresnel(-2.0);
434        assert_close(c_neg, -c_pos, 1e-14, "C(-t) = -C(t)");
435        assert_close(s_neg, -s_pos, 1e-14, "S(-t) = -S(t)");
436    }
437
438    #[test]
439    fn test_fresnel_large_argument_limit() {
440        // As t -> inf, C(t) -> 0.5, S(t) -> 0.5
441        let (c, s) = fresnel(100.0);
442        assert_close(c, 0.5, 0.005, "C(inf)");
443        assert_close(s, 0.5, 0.005, "S(inf)");
444    }
445
446    // -- Clothoid evaluation tests --------------------------------------------
447
448    #[test]
449    fn test_clothoid_eval_straight_line() {
450        // Zero curvature, heading = 0 -> straight line along x
451        let (x, y, theta) = clothoid_eval(0.0, 0.0, 0.0, 5.0);
452        assert_close(x, 5.0, TOL, "x straight");
453        assert_close(y, 0.0, TOL, "y straight");
454        assert_close(theta, 0.0, TOL, "theta straight");
455    }
456
457    #[test]
458    fn test_clothoid_eval_circular_arc() {
459        // Constant curvature (kappa1 = 0) -> circular arc
460        let radius = 10.0;
461        let kappa0 = 1.0 / radius;
462        let arc_length = FRAC_PI_2 * radius; // quarter circle
463
464        let (x, y, theta) = clothoid_eval(0.0, kappa0, 0.0, arc_length);
465        // Quarter circle: endpoint should be at (R, R) approximately
466        assert_close(x, radius, 0.01, "x circular arc");
467        assert_close(y, radius, 0.01, "y circular arc");
468        assert_close(theta, FRAC_PI_2, TOL, "theta circular arc");
469    }
470
471    #[test]
472    fn test_clothoid_eval_with_sharpness() {
473        // Non-zero kappa1: curvature grows linearly
474        let (x, y, theta) = clothoid_eval(0.0, 0.0, 0.1, 3.0);
475        // theta(3) = 0 + 0*3 + 0.5*0.1*9 = 0.45
476        assert_close(theta, 0.45, TOL, "theta with sharpness");
477        assert!(x.is_finite(), "x should be finite");
478        assert!(y.is_finite(), "y should be finite");
479        assert!(y > 0.0, "curve should bend left (positive y)");
480    }
481
482    // -- Clothoid sampling tests ----------------------------------------------
483
484    #[test]
485    fn test_clothoid_sample_straight() {
486        let (xs, ys, thetas, kappas) = clothoid_sample(0.0, 0.0, 0.0, 0.0, 0.0, 10.0, 11);
487
488        assert_eq!(xs.len(), 11);
489        for i in 0..11 {
490            let s = i as f64;
491            assert_close(xs[i], s, TOL, &format!("x[{i}]"));
492            assert_close(ys[i], 0.0, TOL, &format!("y[{i}]"));
493            assert_close(thetas[i], 0.0, TOL, &format!("theta[{i}]"));
494            assert_close(kappas[i], 0.0, TOL, &format!("kappa[{i}]"));
495        }
496    }
497
498    #[test]
499    fn test_clothoid_sample_rotated_straight() {
500        // Straight line at 45 degrees
501        let angle = PI / 4.0;
502        let length = 10.0;
503        let (xs, ys, _thetas, _kappas) = clothoid_sample(1.0, 2.0, angle, 0.0, 0.0, length, 11);
504
505        let end_x = 1.0 + length * angle.cos();
506        let end_y = 2.0 + length * angle.sin();
507        assert_close(*xs.last().unwrap(), end_x, TOL, "end x");
508        assert_close(*ys.last().unwrap(), end_y, TOL, "end y");
509    }
510
511    // -- G1 fitting tests -----------------------------------------------------
512
513    #[test]
514    fn test_g1_fit_straight_line() {
515        let start = Pose2D::new(0.0, 0.0, 0.0);
516        let goal = Pose2D::new(10.0, 0.0, 0.0);
517
518        let (kappa0, kappa1, length) = clothoid_g1_fit(&start, &goal).unwrap();
519        assert_close(kappa0, 0.0, 1e-4, "kappa0 straight");
520        assert_close(kappa1, 0.0, 1e-4, "kappa1 straight");
521        assert_close(length, 10.0, 0.1, "length straight");
522    }
523
524    #[test]
525    fn test_g1_fit_simple_curve() {
526        let start = Pose2D::new(0.0, 0.0, 0.0);
527        let goal = Pose2D::new(5.0, 3.0, PI / 4.0);
528
529        let result = clothoid_g1_fit(&start, &goal);
530        assert!(result.is_ok(), "G1 fit should converge");
531
532        let (kappa0, kappa1, length) = result.unwrap();
533        assert!(length > 0.0, "length should be positive");
534
535        // Verify the endpoint matches
536        let (xs, ys, thetas, _) =
537            clothoid_sample(start.x, start.y, start.yaw, kappa0, kappa1, length, 100);
538
539        let end_x = *xs.last().unwrap();
540        let end_y = *ys.last().unwrap();
541        let end_theta = *thetas.last().unwrap();
542
543        assert_close(end_x, goal.x, 0.01, "fitted end x");
544        assert_close(end_y, goal.y, 0.01, "fitted end y");
545        assert_close(
546            normalize_angle(end_theta),
547            normalize_angle(goal.yaw),
548            0.01,
549            "fitted end yaw",
550        );
551    }
552
553    #[test]
554    fn test_g1_fit_coincident_points_error() {
555        let start = Pose2D::new(1.0, 2.0, 0.0);
556        let goal = Pose2D::new(1.0, 2.0, PI / 2.0);
557
558        let result = clothoid_g1_fit(&start, &goal);
559        assert!(result.is_err(), "coincident points should fail");
560    }
561
562    #[test]
563    fn test_g1_fit_symmetry() {
564        // Symmetric case: start at (0,0,0), end at (10,0,0)
565        // Should produce a straight line with near-zero curvature
566        let start = Pose2D::new(0.0, 0.0, 0.0);
567        let goal = Pose2D::new(10.0, 0.0, 0.0);
568
569        let (kappa0, kappa1, length) = clothoid_g1_fit(&start, &goal).unwrap();
570
571        assert_close(kappa0, 0.0, 1e-4, "symmetric kappa0");
572        assert_close(kappa1, 0.0, 1e-4, "symmetric kappa1");
573        assert!(length > 0.0, "length positive");
574    }
575
576    // -- Planner integration tests --------------------------------------------
577
578    #[test]
579    fn test_planner_basic() {
580        let planner = ClothoidPlanner::new(ClothoidConfig { n_points: 50 });
581        let start = Pose2D::new(0.0, 0.0, 0.0);
582        let goal = Pose2D::new(8.0, 4.0, PI / 3.0);
583
584        let result = planner.plan(&start, &goal);
585        assert!(result.is_ok(), "planner should succeed");
586
587        let clothoid = result.unwrap();
588        assert_eq!(clothoid.path.len(), 50);
589        assert_eq!(clothoid.yaw.len(), 50);
590        assert_eq!(clothoid.curvature.len(), 50);
591
592        // First point should match start
593        assert_close(clothoid.path.points[0].x, start.x, TOL, "start x");
594        assert_close(clothoid.path.points[0].y, start.y, TOL, "start y");
595
596        // Curvature should change linearly
597        let first_k = clothoid.curvature[0];
598        let last_k = *clothoid.curvature.last().unwrap();
599        let mid_k = clothoid.curvature[25];
600        let expected_mid = (first_k + last_k) / 2.0;
601        assert_close(mid_k, expected_mid, 0.01, "linear curvature at midpoint");
602    }
603
604    #[test]
605    fn test_planner_from_xy() {
606        let planner = ClothoidPlanner::new(ClothoidConfig::default());
607        let result = planner.plan_from_xy(0.0, 0.0, 0.0, 5.0, 0.0, 0.0);
608        assert!(result.is_ok());
609        assert_eq!(result.unwrap().path.len(), 100);
610    }
611
612    #[test]
613    fn test_curvature_linearity() {
614        let planner = ClothoidPlanner::new(ClothoidConfig { n_points: 101 });
615        let start = Pose2D::new(0.0, 0.0, 0.0);
616        let goal = Pose2D::new(6.0, 2.0, 0.5);
617
618        let clothoid = planner.plan(&start, &goal).unwrap();
619
620        // The curvature profile should be linear: kappa(s) = kappa0 + kappa1 * s
621        // Check that equally spaced samples form an arithmetic progression
622        let n = clothoid.curvature.len();
623        let step = (clothoid.curvature[n - 1] - clothoid.curvature[0]) / (n - 1) as f64;
624        for i in 0..n {
625            let expected = clothoid.curvature[0] + step * i as f64;
626            assert_close(
627                clothoid.curvature[i],
628                expected,
629                1e-10,
630                &format!("curvature linearity at {i}"),
631            );
632        }
633    }
634}