1use rand::Rng;
13use rust_robotics_core::{Path2D, PathPlanner, Point2D, RoboticsError, RoboticsResult};
14
15#[derive(Debug, Clone)]
17pub struct CircleObstacle {
18 pub x: f64,
19 pub y: f64,
20 pub radius: f64,
21}
22
23impl CircleObstacle {
24 pub fn new(x: f64, y: f64, radius: f64) -> Self {
26 Self { x, y, radius }
27 }
28}
29
30#[derive(Debug, Clone)]
32pub struct AreaBounds {
33 pub xmin: f64,
34 pub xmax: f64,
35 pub ymin: f64,
36 pub ymax: f64,
37}
38
39impl AreaBounds {
40 pub fn new(xmin: f64, xmax: f64, ymin: f64, ymax: f64) -> Self {
42 Self {
43 xmin,
44 xmax,
45 ymin,
46 ymax,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy)]
53pub struct FMTStarConfig {
54 pub n_samples: usize,
56 pub connection_radius: f64,
58 pub robot_radius: f64,
60}
61
62impl Default for FMTStarConfig {
63 fn default() -> Self {
64 Self {
65 n_samples: 500,
66 connection_radius: 5.0,
67 robot_radius: 0.8,
68 }
69 }
70}
71
72impl FMTStarConfig {
73 pub fn validate(&self) -> RoboticsResult<()> {
75 if self.n_samples == 0 {
76 return Err(RoboticsError::InvalidParameter(
77 "FMT*: n_samples must be greater than zero".to_string(),
78 ));
79 }
80 if self.connection_radius <= 0.0 {
81 return Err(RoboticsError::InvalidParameter(
82 "FMT*: connection_radius must be positive".to_string(),
83 ));
84 }
85 if self.robot_radius < 0.0 {
86 return Err(RoboticsError::InvalidParameter(
87 "FMT*: robot_radius cannot be negative".to_string(),
88 ));
89 }
90 Ok(())
91 }
92}
93
94pub struct FMTStarPlanner {
96 obstacles: Vec<CircleObstacle>,
97 rand_area: AreaBounds,
98 config: FMTStarConfig,
99}
100
101impl FMTStarPlanner {
102 pub fn new(
104 obstacles: Vec<CircleObstacle>,
105 rand_area: AreaBounds,
106 config: FMTStarConfig,
107 ) -> Self {
108 Self {
109 obstacles,
110 rand_area,
111 config,
112 }
113 }
114
115 fn plan_with_rng<R: Rng + ?Sized>(
116 &self,
117 start: Point2D,
118 goal: Point2D,
119 rng: &mut R,
120 ) -> RoboticsResult<Path2D> {
121 self.config.validate()?;
122 self.validate_query_point(start, "start")?;
123 self.validate_query_point(goal, "goal")?;
124
125 let points = self.sample_points(start, goal, rng);
126 let neighbors = self.build_neighbors(&points);
127 let goal_index = points.len() - 1;
128
129 let mut parents = vec![None; points.len()];
130 let mut costs = vec![f64::INFINITY; points.len()];
131 let mut is_open = vec![false; points.len()];
132 let mut is_unvisited = vec![true; points.len()];
133 let mut open_vertices = vec![0usize];
134
135 costs[0] = 0.0;
136 is_open[0] = true;
137 is_unvisited[0] = false;
138
139 while !open_vertices.is_empty() {
140 let z_pos = open_vertices
141 .iter()
142 .enumerate()
143 .min_by(|(_, lhs), (_, rhs)| costs[**lhs].total_cmp(&costs[**rhs]))
144 .map(|(idx, _)| idx)
145 .expect("open set is non-empty");
146 let z = open_vertices[z_pos];
147
148 if z == goal_index {
149 return Ok(self.extract_path(&points, &parents, goal_index));
150 }
151
152 let mut newly_open = Vec::new();
153 for &x in &neighbors[z] {
154 if !is_unvisited[x] {
155 continue;
156 }
157
158 let best_parent = neighbors[x]
159 .iter()
160 .copied()
161 .filter(|&candidate| is_open[candidate])
162 .map(|candidate| {
163 let candidate_cost =
164 costs[candidate] + points[candidate].distance(&points[x]);
165 (candidate, candidate_cost)
166 })
167 .min_by(|lhs, rhs| lhs.1.total_cmp(&rhs.1));
168
169 let Some((parent, best_cost)) = best_parent else {
170 continue;
171 };
172
173 if self.is_segment_collision_free(points[parent], points[x]) {
174 parents[x] = Some(parent);
175 costs[x] = best_cost;
176 newly_open.push(x);
177 }
178 }
179
180 open_vertices.swap_remove(z_pos);
181 is_open[z] = false;
182
183 for idx in newly_open {
184 if is_unvisited[idx] {
185 is_unvisited[idx] = false;
186 is_open[idx] = true;
187 open_vertices.push(idx);
188 }
189 }
190 }
191
192 Err(RoboticsError::PlanningError(
193 "FMT*: Cannot find path with current samples".to_string(),
194 ))
195 }
196
197 fn validate_query_point(&self, point: Point2D, label: &str) -> RoboticsResult<()> {
198 let inside_bounds = point.x >= self.rand_area.xmin
199 && point.x <= self.rand_area.xmax
200 && point.y >= self.rand_area.ymin
201 && point.y <= self.rand_area.ymax;
202 if !inside_bounds {
203 return Err(RoboticsError::InvalidParameter(format!(
204 "FMT*: {label} is outside sampling bounds"
205 )));
206 }
207 if !self.is_state_collision_free(point) {
208 return Err(RoboticsError::InvalidParameter(format!(
209 "FMT*: {label} lies inside an obstacle"
210 )));
211 }
212 Ok(())
213 }
214
215 fn sample_points<R: Rng + ?Sized>(
216 &self,
217 start: Point2D,
218 goal: Point2D,
219 rng: &mut R,
220 ) -> Vec<Point2D> {
221 let mut points = Vec::with_capacity(self.config.n_samples + 2);
222 points.push(start);
223
224 while points.len() < self.config.n_samples + 1 {
225 let point = Point2D::new(
226 rng.random_range(self.rand_area.xmin..=self.rand_area.xmax),
227 rng.random_range(self.rand_area.ymin..=self.rand_area.ymax),
228 );
229 if self.is_state_collision_free(point) {
230 points.push(point);
231 }
232 }
233
234 points.push(goal);
235 points
236 }
237
238 fn build_neighbors(&self, points: &[Point2D]) -> Vec<Vec<usize>> {
239 let mut neighbors = vec![Vec::new(); points.len()];
240 for i in 0..points.len() {
241 for j in (i + 1)..points.len() {
242 if points[i].distance(&points[j]) <= self.config.connection_radius {
243 neighbors[i].push(j);
244 neighbors[j].push(i);
245 }
246 }
247 }
248 neighbors
249 }
250
251 fn extract_path(
252 &self,
253 points: &[Point2D],
254 parents: &[Option<usize>],
255 goal_index: usize,
256 ) -> Path2D {
257 let mut path = vec![points[goal_index]];
258 let mut current = goal_index;
259
260 while let Some(parent) = parents[current] {
261 path.push(points[parent]);
262 current = parent;
263 }
264
265 path.reverse();
266 Path2D::from_points(path)
267 }
268
269 fn is_state_collision_free(&self, point: Point2D) -> bool {
270 self.obstacles.iter().all(|obstacle| {
271 let dx = point.x - obstacle.x;
272 let dy = point.y - obstacle.y;
273 (dx * dx + dy * dy).sqrt() > obstacle.radius + self.config.robot_radius
274 })
275 }
276
277 fn is_segment_collision_free(&self, start: Point2D, end: Point2D) -> bool {
278 let distance = start.distance(&end);
279 if distance <= f64::EPSILON {
280 return self.is_state_collision_free(start);
281 }
282
283 let step = (self.config.robot_radius * 0.5).max(0.05);
284 let n_steps = (distance / step).ceil() as usize;
285
286 for i in 0..=n_steps {
287 let t = i as f64 / n_steps as f64;
288 let point = Point2D::new(
289 start.x + (end.x - start.x) * t,
290 start.y + (end.y - start.y) * t,
291 );
292 if !self.is_state_collision_free(point) {
293 return false;
294 }
295 }
296
297 true
298 }
299}
300
301impl PathPlanner for FMTStarPlanner {
302 fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
303 let mut rng = rand::rng();
304 self.plan_with_rng(start, goal, &mut rng)
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use rand::{rngs::StdRng, SeedableRng};
312
313 fn path_length(path: &Path2D) -> f64 {
314 path.points
315 .windows(2)
316 .map(|pair| pair[0].distance(&pair[1]))
317 .sum()
318 }
319
320 fn assert_close(actual: f64, expected: f64, tolerance: f64) {
321 assert!(
322 (actual - expected).abs() <= tolerance,
323 "expected {expected:.12}, got {actual:.12}, tolerance {tolerance:.3e}"
324 );
325 }
326
327 #[test]
328 fn test_fmt_star_config_defaults() {
329 let config = FMTStarConfig::default();
330 assert_eq!(config.n_samples, 500);
331 assert_eq!(config.connection_radius, 5.0);
332 assert_eq!(config.robot_radius, 0.8);
333 }
334
335 #[test]
336 fn test_fmt_star_finds_path_without_obstacles() {
337 let planner = FMTStarPlanner::new(
338 Vec::new(),
339 AreaBounds::new(-1.0, 5.0, -1.0, 5.0),
340 FMTStarConfig {
341 n_samples: 50,
342 connection_radius: 10.0,
343 robot_radius: 0.1,
344 },
345 );
346 let mut rng = StdRng::seed_from_u64(7);
347 let path = planner
348 .plan_with_rng(Point2D::new(0.0, 0.0), Point2D::new(4.0, 4.0), &mut rng)
349 .expect("planner should find a path");
350
351 assert!(!path.is_empty());
352 assert_eq!(path.points.first().expect("path").x, 0.0);
353 assert_eq!(path.points.first().expect("path").y, 0.0);
354 assert_eq!(path.points.last().expect("path").x, 4.0);
355 assert_eq!(path.points.last().expect("path").y, 4.0);
356 }
357
358 #[test]
359 fn test_fmt_star_finds_path_with_obstacles() {
360 let planner = FMTStarPlanner::new(
361 vec![CircleObstacle::new(2.5, 2.5, 0.9)],
362 AreaBounds::new(-1.0, 6.0, -1.0, 6.0),
363 FMTStarConfig {
364 n_samples: 400,
365 connection_radius: 2.5,
366 robot_radius: 0.2,
367 },
368 );
369 let mut rng = StdRng::seed_from_u64(19);
370 let path = planner
371 .plan_with_rng(Point2D::new(0.0, 0.0), Point2D::new(5.0, 5.0), &mut rng)
372 .expect("planner should find a path around the obstacle");
373
374 assert!(path.points.len() >= 2);
375 assert_eq!(path.points.first().expect("path").x, 0.0);
376 assert_eq!(path.points.first().expect("path").y, 0.0);
377 assert_eq!(path.points.last().expect("path").x, 5.0);
378 assert_eq!(path.points.last().expect("path").y, 5.0);
379 assert!(path
380 .points
381 .iter()
382 .all(|point| { point.distance(&Point2D::new(2.5, 2.5)) > 1.1 }));
383 }
384
385 #[test]
386 fn test_fmt_star_obstacle_case_regression() {
387 let planner = FMTStarPlanner::new(
388 vec![CircleObstacle::new(2.5, 2.5, 0.9)],
389 AreaBounds::new(-1.0, 6.0, -1.0, 6.0),
390 FMTStarConfig {
391 n_samples: 400,
392 connection_radius: 2.5,
393 robot_radius: 0.2,
394 },
395 );
396 let mut rng = StdRng::seed_from_u64(19);
397 let path = planner
398 .plan_with_rng(Point2D::new(0.0, 0.0), Point2D::new(5.0, 5.0), &mut rng)
399 .expect("planner should find the regression path");
400
401 assert_eq!(path.points.len(), 5);
402 assert_close(path_length(&path), 7.535322534477, 1.0e-9);
403 assert_close(path.points[1].x, 0.726415085720, 1.0e-9);
404 assert_close(path.points[1].y, 1.726691311279, 1.0e-9);
405 assert_close(path.points[2].x, 1.269433984463, 1.0e-9);
406 assert_close(path.points[2].y, 3.074339829122, 1.0e-9);
407 assert_close(path.points[3].x, 3.149556108870, 1.0e-9);
408 assert_close(path.points[3].y, 4.214733441962, 1.0e-9);
409 }
410}