Skip to main content

rust_robotics_control/
rrt_star_seven_joint_arm.rs

1//! RRT\* path planner for 7-DOF robotic arms
2//!
3//! Plans collision-free paths in joint space using RRT\* with:
4//! - Random sampling in joint-angle space with configurable limits
5//! - Collision checking via forward kinematics + sphere obstacle model
6//! - Near-node rewiring for asymptotic optimality
7//! - Goal biasing for faster convergence
8
9use nalgebra::Vector3;
10use rand::rngs::StdRng;
11use rand::Rng;
12use rand::SeedableRng;
13
14use crate::n_joint_arm_3d::NLinkArm3D;
15
16/// Spherical obstacle in 3D workspace.
17#[derive(Debug, Clone)]
18pub struct SphereObstacle {
19    pub center: Vector3<f64>,
20    pub radius: f64,
21}
22
23/// Configuration for the RRT\* arm planner.
24#[derive(Debug, Clone)]
25pub struct RRTStarArmConfig {
26    /// Number of DOF (typically 7).
27    pub num_joints: usize,
28    /// Length of each link.
29    pub link_lengths: Vec<f64>,
30    /// (min, max) angle per joint in radians.
31    pub joint_limits: Vec<(f64, f64)>,
32    /// Maximum number of tree expansion iterations.
33    pub max_iterations: usize,
34    /// Maximum joint-space step per extend.
35    pub step_size: f64,
36    /// Probability of sampling the goal configuration \[0..1\].
37    pub goal_bias: f64,
38    /// Neighbourhood radius for rewiring.
39    pub rewire_radius: f64,
40    /// Number of intermediate collision checks per edge.
41    pub collision_resolution: usize,
42    /// RNG seed for reproducibility.
43    pub seed: u64,
44}
45
46impl Default for RRTStarArmConfig {
47    fn default() -> Self {
48        let n = 7;
49        Self {
50            num_joints: n,
51            link_lengths: vec![1.0; n],
52            joint_limits: vec![(-std::f64::consts::PI, std::f64::consts::PI); n],
53            max_iterations: 5000,
54            step_size: 0.3,
55            goal_bias: 0.1,
56            rewire_radius: 1.0,
57            collision_resolution: 10,
58            seed: 42,
59        }
60    }
61}
62
63/// Internal tree node.
64struct Node {
65    angles: Vec<f64>,
66    parent: Option<usize>,
67    cost: f64,
68}
69
70/// Result of a successful RRT\* plan.
71pub struct RRTStarArmPath {
72    /// Sequence of joint-angle configurations from start to goal.
73    pub waypoints: Vec<Vec<f64>>,
74    /// Total joint-space cost of the path.
75    pub cost: f64,
76}
77
78/// RRT\* planner operating in joint space with FK-based collision checking.
79pub struct RRTStarArmPlanner {
80    config: RRTStarArmConfig,
81    obstacles: Vec<SphereObstacle>,
82}
83
84impl RRTStarArmPlanner {
85    /// Creates a new planner with the given configuration and obstacle set.
86    pub fn new(config: RRTStarArmConfig, obstacles: Vec<SphereObstacle>) -> Self {
87        Self { config, obstacles }
88    }
89
90    /// Plans a collision-free path from `start` to `goal` joint configurations.
91    ///
92    /// Returns `None` if no path is found within `max_iterations`.
93    pub fn plan(&self, start: &[f64], goal: &[f64]) -> Option<RRTStarArmPath> {
94        assert_eq!(start.len(), self.config.num_joints);
95        assert_eq!(goal.len(), self.config.num_joints);
96
97        if !self.config_collision_free(start) || !self.config_collision_free(goal) {
98            return None;
99        }
100
101        let mut rng = StdRng::seed_from_u64(self.config.seed);
102
103        let mut tree = vec![Node {
104            angles: start.to_vec(),
105            parent: None,
106            cost: 0.0,
107        }];
108
109        let mut best_goal_idx: Option<usize> = None;
110        let mut best_goal_cost = f64::INFINITY;
111        let goal_threshold = self.config.step_size;
112
113        for _ in 0..self.config.max_iterations {
114            // Sample random config or goal with bias
115            let sample = if rng.random::<f64>() < self.config.goal_bias {
116                goal.to_vec()
117            } else {
118                self.sample_random(&mut rng)
119            };
120
121            // Find nearest node
122            let nearest_idx = self.nearest(&tree, &sample);
123            let new_angles = self.steer(&tree[nearest_idx].angles, &sample);
124
125            // Check collision along edge
126            if !self.collision_free(&tree[nearest_idx].angles, &new_angles) {
127                continue;
128            }
129
130            let edge_cost = self.joint_distance(&tree[nearest_idx].angles, &new_angles);
131            let mut min_cost = tree[nearest_idx].cost + edge_cost;
132            let mut min_parent = nearest_idx;
133
134            // Find near nodes for potential better parent
135            let near_indices = self.near_nodes(&tree, &new_angles);
136            for &ni in &near_indices {
137                let c = tree[ni].cost + self.joint_distance(&tree[ni].angles, &new_angles);
138                if c < min_cost && self.collision_free(&tree[ni].angles, &new_angles) {
139                    min_cost = c;
140                    min_parent = ni;
141                }
142            }
143
144            // Add new node
145            let new_idx = tree.len();
146            tree.push(Node {
147                angles: new_angles.clone(),
148                parent: Some(min_parent),
149                cost: min_cost,
150            });
151
152            // Rewire nearby nodes through the new node
153            for &ni in &near_indices {
154                let rewire_cost = min_cost + self.joint_distance(&new_angles, &tree[ni].angles);
155                if rewire_cost < tree[ni].cost && self.collision_free(&new_angles, &tree[ni].angles)
156                {
157                    tree[ni].parent = Some(new_idx);
158                    tree[ni].cost = rewire_cost;
159                }
160            }
161
162            // Check if new node is close to goal
163            let dist_to_goal = self.joint_distance(&new_angles, goal);
164            if dist_to_goal < goal_threshold {
165                let total = min_cost + dist_to_goal;
166                if total < best_goal_cost {
167                    // Add goal node connected through new node
168                    let goal_idx = tree.len();
169                    tree.push(Node {
170                        angles: goal.to_vec(),
171                        parent: Some(new_idx),
172                        cost: total,
173                    });
174                    if self.collision_free(&new_angles, goal) {
175                        best_goal_idx = Some(goal_idx);
176                        best_goal_cost = total;
177                    }
178                }
179            }
180        }
181
182        // Extract path
183        let goal_idx = best_goal_idx?;
184        let mut path = Vec::new();
185        let mut idx = goal_idx;
186        loop {
187            path.push(tree[idx].angles.clone());
188            match tree[idx].parent {
189                Some(p) => idx = p,
190                None => break,
191            }
192        }
193        path.reverse();
194
195        Some(RRTStarArmPath {
196            cost: best_goal_cost,
197            waypoints: path,
198        })
199    }
200
201    /// Samples a random joint configuration within the configured limits.
202    fn sample_random(&self, rng: &mut impl Rng) -> Vec<f64> {
203        self.config
204            .joint_limits
205            .iter()
206            .map(|&(lo, hi)| rng.random_range(lo..=hi))
207            .collect()
208    }
209
210    /// Returns the index of the tree node nearest to `sample` in joint space.
211    fn nearest(&self, tree: &[Node], sample: &[f64]) -> usize {
212        tree.iter()
213            .enumerate()
214            .map(|(i, n)| (i, self.joint_distance(&n.angles, sample)))
215            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
216            .unwrap()
217            .0
218    }
219
220    /// Steers from `from` toward `to`, clamping the step to `step_size`.
221    fn steer(&self, from: &[f64], to: &[f64]) -> Vec<f64> {
222        let dist = self.joint_distance(from, to);
223        if dist <= self.config.step_size {
224            return to.to_vec();
225        }
226        let ratio = self.config.step_size / dist;
227        from.iter()
228            .zip(to.iter())
229            .map(|(&f, &t)| f + ratio * (t - f))
230            .collect()
231    }
232
233    /// Computes the L2 distance between two joint configurations.
234    fn joint_distance(&self, a: &[f64], b: &[f64]) -> f64 {
235        a.iter()
236            .zip(b.iter())
237            .map(|(&ai, &bi)| (ai - bi).powi(2))
238            .sum::<f64>()
239            .sqrt()
240    }
241
242    /// Checks whether the straight-line edge in joint space between `from`
243    /// and `to` is collision-free by interpolating intermediate configurations.
244    fn collision_free(&self, from: &[f64], to: &[f64]) -> bool {
245        let n = self.config.collision_resolution;
246        for i in 0..=n {
247            let t = i as f64 / n as f64;
248            let interp: Vec<f64> = from
249                .iter()
250                .zip(to.iter())
251                .map(|(&f, &tt)| f + t * (tt - f))
252                .collect();
253            if !self.config_collision_free(&interp) {
254                return false;
255            }
256        }
257        true
258    }
259
260    /// Checks whether a single joint configuration is collision-free by
261    /// computing forward kinematics and testing every link segment against
262    /// every sphere obstacle.
263    fn config_collision_free(&self, angles: &[f64]) -> bool {
264        let arm = NLinkArm3D::with_angles(self.config.link_lengths.clone(), angles.to_vec());
265        let points = arm.forward_kinematics();
266
267        for obs in &self.obstacles {
268            // Check each link segment (between consecutive joint positions)
269            for seg in points.windows(2) {
270                if segment_sphere_intersects(&seg[0], &seg[1], &obs.center, obs.radius) {
271                    return false;
272                }
273            }
274        }
275        true
276    }
277
278    /// Returns indices of tree nodes within `rewire_radius` of `point`.
279    fn near_nodes(&self, tree: &[Node], point: &[f64]) -> Vec<usize> {
280        tree.iter()
281            .enumerate()
282            .filter(|(_, n)| self.joint_distance(&n.angles, point) <= self.config.rewire_radius)
283            .map(|(i, _)| i)
284            .collect()
285    }
286}
287
288/// Tests whether a line segment (from `a` to `b`) intersects a sphere
289/// centred at `center` with the given `radius`.
290///
291/// The closest point on the segment to the sphere centre is found by
292/// projecting onto the line and clamping to \[0, 1\].
293fn segment_sphere_intersects(
294    a: &Vector3<f64>,
295    b: &Vector3<f64>,
296    center: &Vector3<f64>,
297    radius: f64,
298) -> bool {
299    let ab = b - a;
300    let ac = center - a;
301    let ab_sq = ab.dot(&ab);
302
303    if ab_sq < 1e-12 {
304        // Degenerate segment (zero-length link)
305        return ac.norm() < radius;
306    }
307
308    let t = (ac.dot(&ab) / ab_sq).clamp(0.0, 1.0);
309    let closest = a + t * ab;
310    (closest - center).norm() < radius
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    /// With no obstacles the planner should find a path quickly.
318    #[test]
319    fn test_plan_no_obstacles() {
320        let config = RRTStarArmConfig {
321            num_joints: 7,
322            link_lengths: vec![1.0; 7],
323            max_iterations: 3000,
324            step_size: 0.5,
325            goal_bias: 0.15,
326            rewire_radius: 1.5,
327            collision_resolution: 5,
328            seed: 123,
329            ..Default::default()
330        };
331
332        let planner = RRTStarArmPlanner::new(config, vec![]);
333        let start = vec![0.0; 7];
334        let goal = vec![0.3, -0.2, 0.1, 0.4, -0.1, 0.2, 0.0];
335
336        let result = planner.plan(&start, &goal);
337        assert!(result.is_some(), "Should find a path with no obstacles");
338        let path = result.unwrap();
339        assert!(
340            path.waypoints.len() >= 2,
341            "Path must have at least start and goal"
342        );
343        assert_eq!(path.waypoints.first().unwrap(), &start);
344        assert_eq!(path.waypoints.last().unwrap(), &goal);
345        assert!(path.cost > 0.0);
346    }
347
348    /// Place an obstacle in the workspace and verify the planner still finds
349    /// a path (which must avoid the obstacle).
350    #[test]
351    fn test_plan_with_obstacle() {
352        let config = RRTStarArmConfig {
353            num_joints: 7,
354            link_lengths: vec![1.0; 7],
355            max_iterations: 8000,
356            step_size: 0.4,
357            goal_bias: 0.1,
358            rewire_radius: 1.2,
359            collision_resolution: 10,
360            seed: 77,
361            ..Default::default()
362        };
363
364        // Place a small sphere obstacle off to the side (not blocking too much)
365        let obs = SphereObstacle {
366            center: Vector3::new(2.0, 2.0, 0.0),
367            radius: 0.5,
368        };
369
370        let planner = RRTStarArmPlanner::new(config.clone(), vec![obs.clone()]);
371        let start = vec![0.0; 7];
372        // Goal: arm rotated to a different configuration
373        let goal = vec![0.5, -0.3, 0.5, -0.3, 0.5, -0.3, 0.5];
374
375        let result = planner.plan(&start, &goal);
376        assert!(result.is_some(), "Should find a path around the obstacle");
377        let path = result.unwrap();
378
379        // Every waypoint along the path must be collision-free.
380        for wp in &path.waypoints {
381            assert!(
382                planner.config_collision_free(wp),
383                "Waypoint must not collide with obstacle"
384            );
385        }
386
387        // Verify the path cost is positive and reasonable
388        assert!(path.cost > 0.0, "Path should have positive cost");
389        assert!(
390            path.waypoints.len() >= 2,
391            "Path should have at least start and goal"
392        );
393    }
394
395    /// Direct test of segment-sphere intersection geometry.
396    #[test]
397    fn test_collision_check() {
398        let a = Vector3::new(0.0, 0.0, 0.0);
399        let b = Vector3::new(2.0, 0.0, 0.0);
400        let center = Vector3::new(1.0, 0.3, 0.0);
401
402        // Sphere radius large enough to touch the segment
403        assert!(segment_sphere_intersects(&a, &b, &center, 0.5));
404
405        // Sphere radius too small to touch the segment
406        assert!(!segment_sphere_intersects(&a, &b, &center, 0.2));
407
408        // Sphere beyond the segment endpoint
409        let far_center = Vector3::new(3.0, 0.0, 0.0);
410        assert!(!segment_sphere_intersects(&a, &b, &far_center, 0.5));
411
412        // Sphere before the segment start
413        let behind = Vector3::new(-1.0, 0.0, 0.0);
414        assert!(!segment_sphere_intersects(&a, &b, &behind, 0.5));
415
416        // Sphere exactly touching (boundary)
417        let on_segment = Vector3::new(1.0, 0.0, 0.0);
418        assert!(segment_sphere_intersects(&a, &b, &on_segment, 0.1));
419    }
420
421    /// Verify that rewiring actually improves cost compared to a planner
422    /// with zero rewire radius (effectively no rewiring).
423    #[test]
424    fn test_rewiring_improves_cost() {
425        let base = RRTStarArmConfig {
426            num_joints: 7,
427            link_lengths: vec![1.0; 7],
428            max_iterations: 4000,
429            step_size: 0.4,
430            goal_bias: 0.1,
431            collision_resolution: 5,
432            seed: 99,
433            ..Default::default()
434        };
435
436        let start = vec![0.0; 7];
437        let goal = vec![0.5, -0.3, 0.2, 0.4, -0.2, 0.3, 0.1];
438
439        // Planner with rewiring enabled
440        let config_rewire = RRTStarArmConfig {
441            rewire_radius: 1.5,
442            ..base.clone()
443        };
444        let planner_rewire = RRTStarArmPlanner::new(config_rewire, vec![]);
445        let result_rewire = planner_rewire.plan(&start, &goal);
446
447        // Planner with effectively no rewiring
448        let config_no_rewire = RRTStarArmConfig {
449            rewire_radius: 0.0,
450            ..base
451        };
452        let planner_no = RRTStarArmPlanner::new(config_no_rewire, vec![]);
453        let result_no = planner_no.plan(&start, &goal);
454
455        assert!(result_rewire.is_some(), "Rewiring planner should find path");
456        assert!(result_no.is_some(), "No-rewire planner should find path");
457
458        let cost_rewire = result_rewire.unwrap().cost;
459        let cost_no = result_no.unwrap().cost;
460
461        // With the same seed and enough iterations, rewiring should yield
462        // equal or better cost.
463        assert!(
464            cost_rewire <= cost_no + 1e-9,
465            "Rewiring should not increase cost: rewire={cost_rewire}, none={cost_no}"
466        );
467    }
468}