Skip to main content

rust_robotics_planning/
rrt_dubins.rs

1//! RRT-Dubins path planner
2//!
3//! Combines the RRT (Rapidly-exploring Random Tree) algorithm with Dubins
4//! curve connections. Instead of straight-line steering between nodes, this
5//! planner uses Dubins paths that respect a minimum turning radius constraint.
6//!
7//! Each node carries a heading angle (x, y, yaw), and collision checking is
8//! performed along the sampled Dubins curve.
9//!
10//! # References
11//!
12//! * LaValle, S.M. (1998). "Rapidly-exploring random trees: A new tool for
13//!   path planning."
14//! * Dubins, L.E. (1957). "On Curves of Minimal Length with a Constraint on
15//!   Average Curvature."
16
17use std::f64::consts::PI;
18
19use rand::Rng;
20
21use rust_robotics_core::types::Pose2D;
22
23use crate::dubins_path::DubinsPlanner;
24use crate::rrt::{AreaBounds, CircleObstacle};
25
26/// A node in the RRT-Dubins tree, carrying position and heading.
27#[derive(Debug, Clone)]
28pub struct RRTDubinsNode {
29    pub x: f64,
30    pub y: f64,
31    pub yaw: f64,
32    pub path_x: Vec<f64>,
33    pub path_y: Vec<f64>,
34    pub path_yaw: Vec<f64>,
35    pub cost: f64,
36    pub parent: Option<usize>,
37}
38
39impl RRTDubinsNode {
40    pub fn new(x: f64, y: f64, yaw: f64) -> Self {
41        Self {
42            x,
43            y,
44            yaw,
45            path_x: Vec::new(),
46            path_y: Vec::new(),
47            path_yaw: Vec::new(),
48            cost: 0.0,
49            parent: None,
50        }
51    }
52}
53
54/// Configuration for the RRT-Dubins planner.
55#[derive(Debug, Clone)]
56pub struct RRTDubinsConfig {
57    /// Curvature = 1 / minimum turning radius \[1/m\].
58    pub curvature: f64,
59    /// Goal-biased sampling rate (0..100). Higher means more goal samples.
60    pub goal_sample_rate: i32,
61    /// Maximum number of RRT iterations.
62    pub max_iter: usize,
63    /// Distance threshold to consider a node at the goal position \[m\].
64    pub goal_xy_threshold: f64,
65    /// Yaw threshold to consider a node at the goal heading \[rad\].
66    pub goal_yaw_threshold: f64,
67    /// Robot radius for collision checking \[m\].
68    pub robot_radius: f64,
69    /// Step size for sampling points along Dubins curves \[m\].
70    pub path_resolution: f64,
71}
72
73impl Default for RRTDubinsConfig {
74    fn default() -> Self {
75        Self {
76            curvature: 1.0,
77            goal_sample_rate: 10,
78            max_iter: 200,
79            goal_xy_threshold: 0.5,
80            goal_yaw_threshold: 1.0_f64.to_radians(),
81            robot_radius: 0.0,
82            path_resolution: 0.1,
83        }
84    }
85}
86
87/// Result of a successful RRT-Dubins planning run.
88#[derive(Debug, Clone)]
89pub struct RRTDubinsPath {
90    /// Sampled waypoints along the path: (x, y, yaw).
91    pub poses: Vec<Pose2D>,
92}
93
94/// RRT path planner using Dubins curves for steering.
95pub struct RRTDubinsPlanner {
96    config: RRTDubinsConfig,
97    obstacles: Vec<CircleObstacle>,
98    rand_area: AreaBounds,
99    dubins: DubinsPlanner,
100    node_list: Vec<RRTDubinsNode>,
101    start: RRTDubinsNode,
102    goal: RRTDubinsNode,
103}
104
105impl RRTDubinsPlanner {
106    /// Create a new RRT-Dubins planner.
107    pub fn new(
108        obstacles: Vec<CircleObstacle>,
109        rand_area: AreaBounds,
110        config: RRTDubinsConfig,
111    ) -> Self {
112        let dubins = DubinsPlanner::new(config.curvature);
113        Self {
114            config,
115            obstacles,
116            rand_area,
117            dubins,
118            node_list: Vec::new(),
119            start: RRTDubinsNode::new(0.0, 0.0, 0.0),
120            goal: RRTDubinsNode::new(0.0, 0.0, 0.0),
121        }
122    }
123
124    /// Plan a path from `start` to `goal`.
125    ///
126    /// Returns `None` if no path is found within `max_iter` iterations.
127    pub fn planning(&mut self, start: Pose2D, goal: Pose2D) -> Option<RRTDubinsPath> {
128        self.start = RRTDubinsNode::new(start.x, start.y, start.yaw);
129        self.goal = RRTDubinsNode::new(goal.x, goal.y, goal.yaw);
130        self.node_list = vec![self.start.clone()];
131
132        for _ in 0..self.config.max_iter {
133            let rnd = self.get_random_node();
134            let nearest_ind = self.get_nearest_node_index(&rnd);
135
136            if let Some(new_node) =
137                self.steer(&self.node_list[nearest_ind].clone(), &rnd, nearest_ind)
138            {
139                if self.check_collision(&new_node) {
140                    self.node_list.push(new_node);
141
142                    // Check if we reached the goal
143                    if let Some(goal_idx) = self.search_best_goal_node() {
144                        return Some(self.generate_final_course(goal_idx));
145                    }
146                }
147            }
148        }
149
150        // Final attempt to find a goal node
151        self.search_best_goal_node()
152            .map(|idx| self.generate_final_course(idx))
153    }
154
155    /// Plan with a deterministic node sampler (for testing).
156    pub fn plan_with_sampler<F>(
157        &mut self,
158        start: Pose2D,
159        goal: Pose2D,
160        mut sample_node: F,
161    ) -> Option<RRTDubinsPath>
162    where
163        F: FnMut(&Self) -> RRTDubinsNode,
164    {
165        self.start = RRTDubinsNode::new(start.x, start.y, start.yaw);
166        self.goal = RRTDubinsNode::new(goal.x, goal.y, goal.yaw);
167        self.node_list = vec![self.start.clone()];
168
169        for _ in 0..self.config.max_iter {
170            let rnd = sample_node(self);
171            let nearest_ind = self.get_nearest_node_index(&rnd);
172
173            if let Some(new_node) =
174                self.steer(&self.node_list[nearest_ind].clone(), &rnd, nearest_ind)
175            {
176                if self.check_collision(&new_node) {
177                    self.node_list.push(new_node);
178                }
179            }
180
181            if let Some(goal_idx) = self.search_best_goal_node() {
182                return Some(self.generate_final_course(goal_idx));
183            }
184        }
185
186        self.search_best_goal_node()
187            .map(|idx| self.generate_final_course(idx))
188    }
189
190    /// Access the internal tree for inspection or visualization.
191    pub fn get_tree(&self) -> &[RRTDubinsNode] {
192        &self.node_list
193    }
194
195    fn get_random_node(&self) -> RRTDubinsNode {
196        let mut rng = rand::rng();
197        if rng.random_range(0..=100) > self.config.goal_sample_rate {
198            RRTDubinsNode::new(
199                rng.random_range(self.rand_area.xmin..=self.rand_area.xmax),
200                rng.random_range(self.rand_area.ymin..=self.rand_area.ymax),
201                rng.random_range(-PI..=PI),
202            )
203        } else {
204            RRTDubinsNode::new(self.goal.x, self.goal.y, self.goal.yaw)
205        }
206    }
207
208    fn get_nearest_node_index(&self, rnd_node: &RRTDubinsNode) -> usize {
209        let mut min_dist = f64::INFINITY;
210        let mut min_ind = 0;
211        for (i, node) in self.node_list.iter().enumerate() {
212            let dist = (node.x - rnd_node.x).powi(2) + (node.y - rnd_node.y).powi(2);
213            if dist < min_dist {
214                min_dist = dist;
215                min_ind = i;
216            }
217        }
218        min_ind
219    }
220
221    /// Steer from `from_node` toward `to_node` using a Dubins curve.
222    ///
223    /// `from_index` is the index of `from_node` in `self.node_list`, used to
224    /// set the parent pointer on the resulting node.
225    ///
226    /// Returns `None` if no valid Dubins path exists.
227    fn steer(
228        &self,
229        from_node: &RRTDubinsNode,
230        to_node: &RRTDubinsNode,
231        from_index: usize,
232    ) -> Option<RRTDubinsNode> {
233        let from_pose = Pose2D::new(from_node.x, from_node.y, from_node.yaw);
234        let to_pose = Pose2D::new(to_node.x, to_node.y, to_node.yaw);
235
236        let dubins_path = self.dubins.plan(from_pose, to_pose).ok()?;
237
238        // Sample points along the Dubins path, including yaw
239        let (px, py, pyaw) = sample_dubins_with_yaw(&dubins_path, self.config.path_resolution);
240
241        if px.len() <= 1 {
242            return None;
243        }
244
245        let mut new_node = from_node.clone();
246        new_node.x = *px.last().unwrap();
247        new_node.y = *py.last().unwrap();
248        new_node.yaw = *pyaw.last().unwrap();
249        new_node.path_x = px;
250        new_node.path_y = py;
251        new_node.path_yaw = pyaw;
252        new_node.cost += dubins_path.total_length;
253        new_node.parent = Some(from_index);
254
255        Some(new_node)
256    }
257
258    /// Check that a node's path does not collide with any obstacle.
259    ///
260    /// Returns `true` if collision-free.
261    fn check_collision(&self, node: &RRTDubinsNode) -> bool {
262        for obs in &self.obstacles {
263            for (&px, &py) in node.path_x.iter().zip(node.path_y.iter()) {
264                let dx = obs.x - px;
265                let dy = obs.y - py;
266                let d = (dx * dx + dy * dy).sqrt();
267                if d <= obs.radius + self.config.robot_radius {
268                    return false;
269                }
270            }
271        }
272        true
273    }
274
275    /// Search for the best (lowest-cost) node that is close enough to the goal
276    /// in both position and heading.
277    fn search_best_goal_node(&self) -> Option<usize> {
278        let mut candidates: Vec<usize> = Vec::new();
279
280        for (i, node) in self.node_list.iter().enumerate() {
281            let dx = node.x - self.goal.x;
282            let dy = node.y - self.goal.y;
283            let dist = (dx * dx + dy * dy).sqrt();
284            if dist > self.config.goal_xy_threshold {
285                continue;
286            }
287            let dyaw = angle_diff(node.yaw, self.goal.yaw).abs();
288            if dyaw > self.config.goal_yaw_threshold {
289                continue;
290            }
291            candidates.push(i);
292        }
293
294        if candidates.is_empty() {
295            return None;
296        }
297
298        // Return the candidate with minimum cost
299        candidates.into_iter().min_by(|&a, &b| {
300            self.node_list[a]
301                .cost
302                .partial_cmp(&self.node_list[b].cost)
303                .unwrap_or(std::cmp::Ordering::Equal)
304        })
305    }
306
307    /// Trace back from a goal node to the start, collecting all waypoints.
308    fn generate_final_course(&self, goal_index: usize) -> RRTDubinsPath {
309        let mut poses: Vec<Pose2D> = vec![Pose2D::new(self.goal.x, self.goal.y, self.goal.yaw)];
310
311        let mut node = &self.node_list[goal_index];
312        while node.parent.is_some() {
313            for ((&px, &py), &pyaw) in node
314                .path_x
315                .iter()
316                .rev()
317                .zip(node.path_y.iter().rev())
318                .zip(node.path_yaw.iter().rev())
319            {
320                poses.push(Pose2D::new(px, py, pyaw));
321            }
322            node = &self.node_list[node.parent.unwrap()];
323        }
324
325        poses.push(Pose2D::new(self.start.x, self.start.y, self.start.yaw));
326        poses.reverse();
327
328        RRTDubinsPath { poses }
329    }
330}
331
332// ---------------------------------------------------------------------------
333// Helpers
334// ---------------------------------------------------------------------------
335
336/// Compute the shortest signed angular difference.
337fn angle_diff(a: f64, b: f64) -> f64 {
338    let mut d = a - b;
339    while d > PI {
340        d -= 2.0 * PI;
341    }
342    while d < -PI {
343        d += 2.0 * PI;
344    }
345    d
346}
347
348/// Sample a Dubins path returning separate x, y, yaw vectors.
349///
350/// This mirrors `DubinsPlanner::sample_path` but also tracks the yaw at each
351/// sample point.
352pub fn sample_dubins_with_yaw(
353    path: &crate::dubins_path::DubinsPath,
354    step: f64,
355) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
356    use crate::dubins_path::SegmentType;
357    use rust_robotics_core::types::Point2D;
358
359    let step = if step <= 0.0 { 0.1 } else { step };
360    let segments = path.path_type.segments();
361
362    let mut xs = vec![path.start.x];
363    let mut ys = vec![path.start.y];
364    let mut yaws = vec![path.start.yaw];
365
366    // Track the cumulative yaw at the start of each segment
367    let mut current_yaw = path.start.yaw;
368
369    for (i, &seg) in segments.iter().enumerate() {
370        let seg_len = path.lengths[i] * path.curvature; // normalised length
371        if seg_len.abs() < 1e-12 {
372            continue;
373        }
374
375        let origin = Point2D::new(*xs.last().unwrap(), *ys.last().unwrap());
376        let origin_yaw = current_yaw;
377
378        let mut cl = step;
379        while (cl + step).abs() <= seg_len.abs() {
380            let (x, y, yaw) = interpolate_segment(cl, seg, path.curvature, origin, origin_yaw);
381            xs.push(x);
382            ys.push(y);
383            yaws.push(yaw);
384            cl += step;
385        }
386
387        // End of segment
388        let (x, y, yaw) = interpolate_segment(seg_len, seg, path.curvature, origin, origin_yaw);
389        xs.push(x);
390        ys.push(y);
391        yaws.push(yaw);
392
393        // Update current_yaw for the next segment
394        current_yaw = match seg {
395            SegmentType::Straight => current_yaw,
396            SegmentType::Left => current_yaw + seg_len,
397            SegmentType::Right => current_yaw - seg_len,
398        };
399    }
400
401    (xs, ys, yaws)
402}
403
404/// Interpolate a point at a given normalised arc-length within one segment.
405///
406/// Duplicated from `dubins_path` module internals since those are private.
407fn interpolate_segment(
408    length: f64,
409    seg: crate::dubins_path::SegmentType,
410    curvature: f64,
411    origin: rust_robotics_core::types::Point2D,
412    origin_yaw: f64,
413) -> (f64, f64, f64) {
414    use crate::dubins_path::SegmentType;
415
416    match seg {
417        SegmentType::Straight => {
418            let nx = origin.x + length / curvature * origin_yaw.cos();
419            let ny = origin.y + length / curvature * origin_yaw.sin();
420            (nx, ny, origin_yaw)
421        }
422        SegmentType::Left => {
423            let r = 1.0 / curvature;
424            let ldx = length.sin() * r;
425            let ldy = (1.0 - length.cos()) * r;
426            let gdx = origin_yaw.cos() * ldx - origin_yaw.sin() * ldy;
427            let gdy = origin_yaw.sin() * ldx + origin_yaw.cos() * ldy;
428            (origin.x + gdx, origin.y + gdy, origin_yaw + length)
429        }
430        SegmentType::Right => {
431            let r = 1.0 / curvature;
432            let ldx = length.sin() * r;
433            let ldy = -(1.0 - length.cos()) * r;
434            let gdx = origin_yaw.cos() * ldx - origin_yaw.sin() * ldy;
435            let gdy = origin_yaw.sin() * ldx + origin_yaw.cos() * ldy;
436            (origin.x + gdx, origin.y + gdy, origin_yaw - length)
437        }
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Tests
443// ---------------------------------------------------------------------------
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
450        (a - b).abs() < tol
451    }
452
453    fn create_obstacle_list() -> Vec<CircleObstacle> {
454        vec![
455            CircleObstacle::new(5.0, 5.0, 1.0),
456            CircleObstacle::new(3.0, 6.0, 2.0),
457            CircleObstacle::new(3.0, 8.0, 2.0),
458            CircleObstacle::new(3.0, 10.0, 2.0),
459            CircleObstacle::new(7.0, 5.0, 2.0),
460            CircleObstacle::new(9.0, 5.0, 2.0),
461        ]
462    }
463
464    #[test]
465    fn test_rrt_dubins_node_creation() {
466        let node = RRTDubinsNode::new(1.0, 2.0, 0.5);
467        assert_eq!(node.x, 1.0);
468        assert_eq!(node.y, 2.0);
469        assert_eq!(node.yaw, 0.5);
470        assert_eq!(node.cost, 0.0);
471        assert!(node.parent.is_none());
472        assert!(node.path_x.is_empty());
473    }
474
475    #[test]
476    fn test_rrt_dubins_config_default() {
477        let config = RRTDubinsConfig::default();
478        assert_eq!(config.curvature, 1.0);
479        assert_eq!(config.max_iter, 200);
480        assert_eq!(config.goal_sample_rate, 10);
481        assert!(approx_eq(config.goal_xy_threshold, 0.5, 1e-12));
482    }
483
484    #[test]
485    fn test_angle_diff() {
486        assert!(approx_eq(angle_diff(0.0, 0.0), 0.0, 1e-12));
487        assert!(approx_eq(angle_diff(PI, 0.0), PI, 1e-12));
488        assert!(approx_eq(angle_diff(0.0, PI), -PI, 1e-12));
489        // Wrap-around
490        assert!(approx_eq(angle_diff(3.0 * PI / 2.0, -PI / 2.0), 0.0, 1e-12));
491    }
492
493    #[test]
494    fn test_collision_check_no_obstacles() {
495        let obstacles = vec![];
496        let config = RRTDubinsConfig::default();
497        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
498        let planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
499
500        let mut node = RRTDubinsNode::new(1.0, 1.0, 0.0);
501        node.path_x = vec![0.0, 0.5, 1.0];
502        node.path_y = vec![0.0, 0.5, 1.0];
503        assert!(planner.check_collision(&node));
504    }
505
506    #[test]
507    fn test_collision_check_with_obstacle() {
508        let obstacles = vec![CircleObstacle::new(0.5, 0.5, 0.3)];
509        let config = RRTDubinsConfig::default();
510        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
511        let planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
512
513        let mut node = RRTDubinsNode::new(1.0, 1.0, 0.0);
514        node.path_x = vec![0.0, 0.5, 1.0];
515        node.path_y = vec![0.0, 0.5, 1.0];
516        // The path passes through (0.5, 0.5), right on the obstacle center
517        assert!(!planner.check_collision(&node));
518    }
519
520    #[test]
521    fn test_steer_produces_dubins_path() {
522        let obstacles = vec![];
523        let config = RRTDubinsConfig::default();
524        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
525        let mut planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
526        planner.start = RRTDubinsNode::new(0.0, 0.0, 0.0);
527        planner.goal = RRTDubinsNode::new(10.0, 10.0, 0.0);
528        planner.node_list = vec![planner.start.clone()];
529
530        let from_node = RRTDubinsNode::new(0.0, 0.0, 0.0);
531        let to_node = RRTDubinsNode::new(5.0, 5.0, PI / 2.0);
532
533        let result = planner.steer(&from_node, &to_node, 0);
534        assert!(result.is_some());
535
536        let new_node = result.unwrap();
537        assert!(!new_node.path_x.is_empty());
538        assert_eq!(new_node.path_x.len(), new_node.path_y.len());
539        assert_eq!(new_node.path_x.len(), new_node.path_yaw.len());
540        assert!(new_node.cost > 0.0);
541        assert!(new_node.parent.is_some());
542    }
543
544    #[test]
545    fn test_steer_endpoint_matches_dubins_goal() {
546        let obstacles = vec![];
547        let config = RRTDubinsConfig::default();
548        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
549        let mut planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
550        planner.start = RRTDubinsNode::new(0.0, 0.0, 0.0);
551        planner.goal = RRTDubinsNode::new(10.0, 10.0, 0.0);
552        planner.node_list = vec![planner.start.clone()];
553
554        let from = RRTDubinsNode::new(0.0, 0.0, 0.0);
555        let to = RRTDubinsNode::new(5.0, 0.0, 0.0);
556        let result = planner.steer(&from, &to, 0).unwrap();
557
558        // Endpoint should be close to the target
559        assert!(approx_eq(result.x, 5.0, 0.3));
560        assert!(approx_eq(result.y, 0.0, 0.3));
561    }
562
563    #[test]
564    fn test_nearest_node_index() {
565        let obstacles = vec![];
566        let config = RRTDubinsConfig::default();
567        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
568        let mut planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
569        planner.node_list = vec![
570            RRTDubinsNode::new(0.0, 0.0, 0.0),
571            RRTDubinsNode::new(5.0, 5.0, 0.0),
572            RRTDubinsNode::new(10.0, 10.0, 0.0),
573        ];
574
575        let query = RRTDubinsNode::new(4.0, 4.0, 0.0);
576        assert_eq!(planner.get_nearest_node_index(&query), 1);
577
578        let query = RRTDubinsNode::new(9.0, 9.0, 0.0);
579        assert_eq!(planner.get_nearest_node_index(&query), 2);
580    }
581
582    #[test]
583    fn test_search_best_goal_node() {
584        let obstacles = vec![];
585        let config = RRTDubinsConfig {
586            goal_xy_threshold: 1.0,
587            goal_yaw_threshold: 0.5,
588            ..Default::default()
589        };
590        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
591        let mut planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
592        planner.goal = RRTDubinsNode::new(10.0, 10.0, 0.0);
593
594        // No nodes near goal
595        planner.node_list = vec![RRTDubinsNode::new(0.0, 0.0, 0.0)];
596        assert!(planner.search_best_goal_node().is_none());
597
598        // Node near goal in position but wrong yaw
599        let mut n = RRTDubinsNode::new(10.0, 10.0, PI);
600        n.cost = 5.0;
601        planner.node_list.push(n);
602        assert!(planner.search_best_goal_node().is_none());
603
604        // Node near goal in both position and yaw
605        let mut n = RRTDubinsNode::new(10.2, 10.2, 0.1);
606        n.cost = 8.0;
607        planner.node_list.push(n);
608        assert_eq!(planner.search_best_goal_node(), Some(2));
609    }
610
611    #[test]
612    fn test_deterministic_planning_no_obstacles() {
613        let obstacles = vec![];
614        let config = RRTDubinsConfig {
615            curvature: 1.0,
616            goal_sample_rate: 10,
617            max_iter: 500,
618            goal_xy_threshold: 1.5,
619            goal_yaw_threshold: 0.5,
620            robot_radius: 0.0,
621            path_resolution: 0.3,
622        };
623        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
624        let mut planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
625
626        let start = Pose2D::new(0.0, 0.0, 0.0);
627        let goal = Pose2D::new(5.0, 5.0, 0.0);
628
629        // Feed the goal as every sample to guarantee convergence
630        let result = planner.plan_with_sampler(start, goal, |p| {
631            RRTDubinsNode::new(p.goal.x, p.goal.y, p.goal.yaw)
632        });
633
634        assert!(result.is_some(), "Should find a path with no obstacles");
635        let path = result.unwrap();
636        assert!(path.poses.len() >= 2);
637
638        // First and last poses should be near start and goal
639        let first = &path.poses[0];
640        let last = &path.poses[path.poses.len() - 1];
641        assert!(approx_eq(first.x, 0.0, 0.5));
642        assert!(approx_eq(first.y, 0.0, 0.5));
643        assert!(approx_eq(last.x, 5.0, 1.5));
644        assert!(approx_eq(last.y, 5.0, 1.5));
645    }
646
647    #[test]
648    fn test_planning_with_obstacles() {
649        let obstacles = create_obstacle_list();
650        let config = RRTDubinsConfig {
651            curvature: 1.0,
652            goal_sample_rate: 10,
653            max_iter: 1000,
654            goal_xy_threshold: 1.5,
655            goal_yaw_threshold: 1.0,
656            robot_radius: 0.0,
657            path_resolution: 0.3,
658        };
659        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
660        let mut planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
661
662        let start = Pose2D::new(0.0, 0.0, 0.0);
663        let goal = Pose2D::new(10.0, 10.0, 0.0);
664
665        let result = planner.planning(start, goal);
666        // Due to randomness, the planner may or may not find a path.
667        // We just verify it runs without panicking and returns a valid
668        // structure if it succeeds.
669        if let Some(path) = result {
670            assert!(path.poses.len() >= 2);
671            // Verify all path points are collision-free (no NaN)
672            for pose in &path.poses {
673                assert!(pose.x.is_finite());
674                assert!(pose.y.is_finite());
675                assert!(pose.yaw.is_finite());
676            }
677        }
678    }
679
680    #[test]
681    fn test_sample_dubins_with_yaw_straight() {
682        let planner = DubinsPlanner::new(1.0);
683        let start = Pose2D::new(0.0, 0.0, 0.0);
684        let goal = Pose2D::new(5.0, 0.0, 0.0);
685        let path = planner.plan(start, goal).unwrap();
686
687        let (xs, ys, yaws) = sample_dubins_with_yaw(&path, 0.5);
688
689        assert!(xs.len() > 2);
690        assert_eq!(xs.len(), ys.len());
691        assert_eq!(xs.len(), yaws.len());
692
693        // Start point
694        assert!(approx_eq(xs[0], 0.0, 1e-12));
695        assert!(approx_eq(ys[0], 0.0, 1e-12));
696
697        // End point should be near goal
698        assert!(approx_eq(*xs.last().unwrap(), 5.0, 0.3));
699        assert!(approx_eq(*ys.last().unwrap(), 0.0, 0.3));
700
701        // Yaw should stay near 0 for a straight path
702        for &yaw in &yaws {
703            assert!(approx_eq(yaw, 0.0, 0.1));
704        }
705    }
706
707    #[test]
708    fn test_sample_dubins_with_yaw_turn() {
709        let planner = DubinsPlanner::new(1.0);
710        let start = Pose2D::new(0.0, 0.0, 0.0);
711        let goal = Pose2D::new(3.0, 3.0, PI / 2.0);
712        let path = planner.plan(start, goal).unwrap();
713
714        let (xs, _ys, yaws) = sample_dubins_with_yaw(&path, 0.1);
715
716        assert!(xs.len() > 2);
717        // End yaw should be near PI/2
718        let end_yaw = *yaws.last().unwrap();
719        assert!(
720            approx_eq(angle_diff(end_yaw, PI / 2.0).abs(), 0.0, 0.3),
721            "end yaw {} should be near PI/2",
722            end_yaw
723        );
724    }
725
726    #[test]
727    fn test_generate_final_course() {
728        let obstacles = vec![];
729        let config = RRTDubinsConfig::default();
730        let rand_area = AreaBounds::new(-2.0, 15.0, -2.0, 15.0);
731        let mut planner = RRTDubinsPlanner::new(obstacles, rand_area, config);
732        planner.start = RRTDubinsNode::new(0.0, 0.0, 0.0);
733        planner.goal = RRTDubinsNode::new(10.0, 10.0, 0.0);
734
735        // Build a simple two-node tree
736        let root = RRTDubinsNode::new(0.0, 0.0, 0.0);
737        let mut child = RRTDubinsNode::new(5.0, 5.0, 0.5);
738        child.parent = Some(0);
739        child.path_x = vec![0.0, 2.5, 5.0];
740        child.path_y = vec![0.0, 2.5, 5.0];
741        child.path_yaw = vec![0.0, 0.25, 0.5];
742
743        planner.node_list = vec![root, child];
744
745        let course = planner.generate_final_course(1);
746        // Should include start + path points (reversed) + goal
747        assert!(course.poses.len() >= 3);
748        // First pose should be start
749        assert!(approx_eq(course.poses[0].x, 0.0, 1e-12));
750        // Last pose should be goal
751        let last = course.poses.last().unwrap();
752        assert!(approx_eq(last.x, 10.0, 1e-12));
753        assert!(approx_eq(last.y, 10.0, 1e-12));
754    }
755}