1use nalgebra::Vector3;
10use rand::rngs::StdRng;
11use rand::Rng;
12use rand::SeedableRng;
13
14use crate::n_joint_arm_3d::NLinkArm3D;
15
16#[derive(Debug, Clone)]
18pub struct SphereObstacle {
19 pub center: Vector3<f64>,
20 pub radius: f64,
21}
22
23#[derive(Debug, Clone)]
25pub struct RRTStarArmConfig {
26 pub num_joints: usize,
28 pub link_lengths: Vec<f64>,
30 pub joint_limits: Vec<(f64, f64)>,
32 pub max_iterations: usize,
34 pub step_size: f64,
36 pub goal_bias: f64,
38 pub rewire_radius: f64,
40 pub collision_resolution: usize,
42 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
63struct Node {
65 angles: Vec<f64>,
66 parent: Option<usize>,
67 cost: f64,
68}
69
70pub struct RRTStarArmPath {
72 pub waypoints: Vec<Vec<f64>>,
74 pub cost: f64,
76}
77
78pub struct RRTStarArmPlanner {
80 config: RRTStarArmConfig,
81 obstacles: Vec<SphereObstacle>,
82}
83
84impl RRTStarArmPlanner {
85 pub fn new(config: RRTStarArmConfig, obstacles: Vec<SphereObstacle>) -> Self {
87 Self { config, obstacles }
88 }
89
90 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 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 let nearest_idx = self.nearest(&tree, &sample);
123 let new_angles = self.steer(&tree[nearest_idx].angles, &sample);
124
125 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
288fn 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 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 #[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 #[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 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 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 for wp in &path.waypoints {
381 assert!(
382 planner.config_collision_free(wp),
383 "Waypoint must not collide with obstacle"
384 );
385 }
386
387 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 #[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 assert!(segment_sphere_intersects(&a, &b, ¢er, 0.5));
404
405 assert!(!segment_sphere_intersects(&a, &b, ¢er, 0.2));
407
408 let far_center = Vector3::new(3.0, 0.0, 0.0);
410 assert!(!segment_sphere_intersects(&a, &b, &far_center, 0.5));
411
412 let behind = Vector3::new(-1.0, 0.0, 0.0);
414 assert!(!segment_sphere_intersects(&a, &b, &behind, 0.5));
415
416 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 #[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 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 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 assert!(
464 cost_rewire <= cost_no + 1e-9,
465 "Rewiring should not increase cost: rewire={cost_rewire}, none={cost_no}"
466 );
467 }
468}