rust_robotics_planning/tangent_bug.rs
1//! Tangent Bug path planning algorithm.
2//!
3//! An improvement over the basic Bug algorithms that uses a finite-range
4//! sensor to detect obstacle boundaries and selects the tangent point that
5//! minimises the heuristic distance to the goal.
6//!
7//! # Algorithm summary
8//!
9//! 1. **Motion-to-Goal**: move one step toward the goal on the integer grid.
10//! If the next cell is an obstacle, enter Boundary-Following mode.
11//! 2. **Boundary-Following**: scan all boundary cells within `sensor_range`,
12//! pick the one with the minimum Euclidean distance to goal as the *tangent
13//! point*. Walk along the obstacle boundary (4-connected free cells
14//! adjacent to at least one obstacle cell) in the direction that makes
15//! progress toward the tangent point. Switch back to Motion-to-Goal when
16//! the direct path to the goal is unobstructed **and** the current distance
17//! to the goal is less than `d_reach` (the distance recorded when
18//! boundary-following began).
19//!
20//! Reference: Kamon & Rivlin (1997), *Sensory-based motion planning with
21//! global proofs*, IEEE Trans. Robotics & Automation.
22
23use std::collections::HashSet;
24
25// ── Configuration ────────────────────────────────────────────────────────────
26
27/// Configuration for the [`TangentBugPlanner`].
28#[derive(Debug, Clone)]
29pub struct TangentBugConfig {
30 /// Sensor range used to discover boundary cells \[grid units\].
31 pub sensor_range: f64,
32 /// Safety limit on the total number of iterations.
33 pub max_iterations: usize,
34}
35
36impl Default for TangentBugConfig {
37 fn default() -> Self {
38 Self {
39 sensor_range: 10.0,
40 max_iterations: 1_000_000,
41 }
42 }
43}
44
45// ── Result ────────────────────────────────────────────────────────────────────
46
47/// The planned path returned by [`TangentBugPlanner::plan`].
48#[derive(Debug, Clone)]
49pub struct TangentBugResult {
50 /// X coordinates of the path, from start to goal.
51 pub path_x: Vec<f64>,
52 /// Y coordinates of the path, from start to goal.
53 pub path_y: Vec<f64>,
54}
55
56// ── Planner ───────────────────────────────────────────────────────────────────
57
58/// Tangent Bug planner operating on an integer grid.
59///
60/// Obstacles are specified as a set of occupied grid cells. The planner
61/// pre-computes *boundary* cells (free cells that are 8-connected neighbours
62/// of at least one obstacle cell) and uses a finite sensor range to pick
63/// tangent waypoints.
64pub struct TangentBugPlanner {
65 goal_x: f64,
66 goal_y: f64,
67 obs: HashSet<(i64, i64)>,
68 boundary: HashSet<(i64, i64)>,
69 path_x: Vec<f64>,
70 path_y: Vec<f64>,
71 sensor_range: f64,
72}
73
74impl TangentBugPlanner {
75 /// Create a new planner instance.
76 ///
77 /// # Arguments
78 ///
79 /// * `start_x`, `start_y` – starting position.
80 /// * `goal_x`, `goal_y` – goal position.
81 /// * `obs_x`, `obs_y` – coordinates of every occupied grid cell.
82 /// * `sensor_range` – maximum sensing radius \[grid units\].
83 pub fn new(
84 start_x: f64,
85 start_y: f64,
86 goal_x: f64,
87 goal_y: f64,
88 obs_x: &[f64],
89 obs_y: &[f64],
90 sensor_range: f64,
91 ) -> Self {
92 let obs: HashSet<(i64, i64)> = obs_x
93 .iter()
94 .zip(obs_y.iter())
95 .map(|(&x, &y)| (x as i64, y as i64))
96 .collect();
97
98 let dirs: [(i64, i64); 8] = [
99 (1, 1),
100 (0, 1),
101 (-1, 1),
102 (-1, 0),
103 (-1, -1),
104 (0, -1),
105 (1, -1),
106 (1, 0),
107 ];
108 let mut boundary = HashSet::new();
109 for &(ox, oy) in &obs {
110 for &(dx, dy) in &dirs {
111 let cx = ox + dx;
112 let cy = oy + dy;
113 if !obs.contains(&(cx, cy)) {
114 boundary.insert((cx, cy));
115 }
116 }
117 }
118
119 Self {
120 goal_x,
121 goal_y,
122 obs,
123 boundary,
124 path_x: vec![start_x],
125 path_y: vec![start_y],
126 sensor_range,
127 }
128 }
129
130 /// Run the Tangent Bug algorithm.
131 ///
132 /// Returns `Some(TangentBugResult)` when the goal is reached, or `None`
133 /// when the iteration limit is exhausted without reaching the goal.
134 pub fn plan(mut self, config: &TangentBugConfig) -> Option<TangentBugResult> {
135 // Take the larger of constructor-provided and config-provided range so
136 // the config acts as an override rather than a mandatory reset.
137 let sensor_range = config.sensor_range.max(self.sensor_range);
138 if self.run(config.max_iterations, sensor_range) {
139 Some(TangentBugResult {
140 path_x: self.path_x,
141 path_y: self.path_y,
142 })
143 } else {
144 None
145 }
146 }
147
148 // ── internal helpers ──────────────────────────────────────────────────────
149
150 fn cur(&self) -> (f64, f64) {
151 (*self.path_x.last().unwrap(), *self.path_y.last().unwrap())
152 }
153
154 fn push(&mut self, x: f64, y: f64) {
155 self.path_x.push(x);
156 self.path_y.push(y);
157 }
158
159 fn at_goal(&self) -> bool {
160 let (cx, cy) = self.cur();
161 cx == self.goal_x && cy == self.goal_y
162 }
163
164 /// One-cell greedy step toward the goal (diagonal allowed).
165 fn step_toward_goal(&self) -> (f64, f64) {
166 let (cx, cy) = self.cur();
167 (cx + sign(self.goal_x - cx), cy + sign(self.goal_y - cy))
168 }
169
170 fn is_obs(&self, x: i64, y: i64) -> bool {
171 self.obs.contains(&(x, y))
172 }
173
174 fn is_boundary_cell(&self, x: i64, y: i64) -> bool {
175 self.boundary.contains(&(x, y))
176 }
177
178 fn dist_to_goal(&self, x: f64, y: f64) -> f64 {
179 ((x - self.goal_x).powi(2) + (y - self.goal_y).powi(2)).sqrt()
180 }
181
182 /// Scan boundary cells within `sensor_range` of the current position and
183 /// return the one that minimises the Euclidean distance to the goal.
184 /// Returns `None` if no boundary cell is in range.
185 fn best_tangent_point(&self, sensor_range: f64) -> Option<(f64, f64)> {
186 let (cx, cy) = self.cur();
187 let sr2 = sensor_range * sensor_range;
188 let mut best_dist = f64::INFINITY;
189 let mut best: Option<(f64, f64)> = None;
190
191 for &(bx, by) in &self.boundary {
192 let bxf = bx as f64;
193 let byf = by as f64;
194 let d2 = (bxf - cx).powi(2) + (byf - cy).powi(2);
195 if d2 <= sr2 {
196 let dg = self.dist_to_goal(bxf, byf);
197 if dg < best_dist {
198 best_dist = dg;
199 best = Some((bxf, byf));
200 }
201 }
202 }
203 best
204 }
205
206 /// Check whether the direct path from current position to the goal is
207 /// clear of obstacles (no obstacle cell along the sign-based walk).
208 fn direct_path_clear(&self) -> bool {
209 let (mut lx, mut ly) = self.cur();
210 loop {
211 if lx == self.goal_x && ly == self.goal_y {
212 return true;
213 }
214 let nx = lx + sign(self.goal_x - lx);
215 let ny = ly + sign(self.goal_y - ly);
216 if self.is_obs(nx as i64, ny as i64) {
217 return false;
218 }
219 lx = nx;
220 ly = ny;
221 }
222 }
223
224 /// Walk one step along the obstacle boundary toward the tangent point
225 /// `(tx, ty)`. Boundary cells (free cells adjacent to at least one
226 /// obstacle) are preferred; any free neighbour is used as a fallback.
227 /// The neighbour with the smallest Euclidean distance to `(tx, ty)` that
228 /// has not been visited in the current boundary-following episode is
229 /// chosen. If all neighbours have been visited, the closest unvisited
230 /// free neighbour is chosen regardless.
231 ///
232 /// Returns `true` if a step was made.
233 fn boundary_follow_step(&mut self, tx: f64, ty: f64, visited: &HashSet<(i64, i64)>) -> bool {
234 let (cx, cy) = self.cur();
235 let cxi = cx as i64;
236 let cyi = cy as i64;
237
238 // 4-connected neighbours, tried in order: prefer boundary, unvisited,
239 // closest to target.
240 let mut best_dist = f64::INFINITY;
241 let mut best: Option<(f64, f64)> = None;
242 let mut best_dist_any = f64::INFINITY;
243 let mut best_any: Option<(f64, f64)> = None;
244
245 for &(dx, dy) in &[(1i64, 0i64), (0, 1), (-1, 0), (0, -1)] {
246 let nx = cxi + dx;
247 let ny = cyi + dy;
248 if self.is_obs(nx, ny) {
249 continue;
250 }
251 let nxf = nx as f64;
252 let nyf = ny as f64;
253 let d = ((nxf - tx).powi(2) + (nyf - ty).powi(2)).sqrt();
254
255 // Track closest free cell overall (for fallback).
256 if d < best_dist_any {
257 best_dist_any = d;
258 best_any = Some((nxf, nyf));
259 }
260
261 // Prefer boundary cells that haven't been visited yet.
262 if self.is_boundary_cell(nx, ny) && !visited.contains(&(nx, ny)) && d < best_dist {
263 best_dist = d;
264 best = Some((nxf, nyf));
265 }
266 }
267
268 // Fall back to any free unvisited neighbour, then any free neighbour.
269 let chosen = best.or_else(|| {
270 // Any unvisited free neighbour closest to target.
271 let (mut bd, mut bst) = (f64::INFINITY, None);
272 let cxi = cx as i64;
273 let cyi = cy as i64;
274 for &(dx, dy) in &[(1i64, 0i64), (0, 1), (-1, 0), (0, -1)] {
275 let nx = cxi + dx;
276 let ny = cyi + dy;
277 if !self.is_obs(nx, ny) && !visited.contains(&(nx, ny)) {
278 let d = ((nx as f64 - tx).powi(2) + (ny as f64 - ty).powi(2)).sqrt();
279 if d < bd {
280 bd = d;
281 bst = Some((nx as f64, ny as f64));
282 }
283 }
284 }
285 bst
286 });
287
288 let chosen = chosen.or(best_any);
289
290 if let Some((bx, by)) = chosen {
291 self.push(bx, by);
292 true
293 } else {
294 false
295 }
296 }
297
298 // ── main loop ─────────────────────────────────────────────────────────────
299
300 fn run(&mut self, max_iter: usize, sensor_range: f64) -> bool {
301 #[derive(Clone, Copy, PartialEq, Eq)]
302 enum Mode {
303 /// Greedy motion toward the goal.
304 ToGoal,
305 /// Following the obstacle boundary via tangent points.
306 Boundary,
307 }
308
309 let mut mode = Mode::ToGoal;
310 // Distance to goal recorded when boundary-following began.
311 let mut d_reach = f64::INFINITY;
312 // Cells visited during the current boundary-following episode.
313 let mut visited: HashSet<(i64, i64)> = HashSet::new();
314
315 for _ in 0..max_iter {
316 if self.at_goal() {
317 return true;
318 }
319
320 match mode {
321 Mode::ToGoal => {
322 let (nx, ny) = self.step_toward_goal();
323 if self.is_obs(nx as i64, ny as i64) {
324 // Obstacle ahead – switch to boundary-following.
325 d_reach = self.dist_to_goal(self.cur().0, self.cur().1);
326 visited.clear();
327 let (cx, cy) = self.cur();
328 visited.insert((cx as i64, cy as i64));
329 mode = Mode::Boundary;
330 // Do not move this iteration; re-evaluate next cycle.
331 } else {
332 self.push(nx, ny);
333 }
334 }
335
336 Mode::Boundary => {
337 // Check exit condition: direct path clear AND closer to
338 // goal than when we started following the boundary.
339 let (cx, cy) = self.cur();
340 let d_now = self.dist_to_goal(cx, cy);
341 if self.direct_path_clear() && d_now < d_reach {
342 visited.clear();
343 mode = Mode::ToGoal;
344 continue;
345 }
346
347 // Pick the best tangent point visible within sensor range.
348 if let Some((tx, ty)) = self.best_tangent_point(sensor_range) {
349 let moved = self.boundary_follow_step(tx, ty, &visited);
350 if moved {
351 let (nx, ny) = self.cur();
352 visited.insert((nx as i64, ny as i64));
353 } else {
354 // Truly stuck – give up.
355 return false;
356 }
357 } else {
358 // No boundary in sensor range – resume greedy motion.
359 visited.clear();
360 mode = Mode::ToGoal;
361 }
362 }
363 }
364 }
365 false
366 }
367}
368
369// ── helpers ───────────────────────────────────────────────────────────────────
370
371/// Signum returning -1.0, 0.0, or 1.0.
372fn sign(v: f64) -> f64 {
373 if v > 0.0 {
374 1.0
375 } else if v < 0.0 {
376 -1.0
377 } else {
378 0.0
379 }
380}
381
382// ── Tests ─────────────────────────────────────────────────────────────────────
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_straight_path_no_obstacles() {
390 let planner = TangentBugPlanner::new(0.0, 0.0, 5.0, 5.0, &[], &[], 10.0);
391 let config = TangentBugConfig::default();
392 let result = planner.plan(&config).expect("should reach goal");
393 assert_eq!(*result.path_x.last().unwrap(), 5.0);
394 assert_eq!(*result.path_y.last().unwrap(), 5.0);
395 // Diagonal steps: 5 steps + start = 6 points.
396 assert_eq!(result.path_x.len(), 6);
397 }
398
399 #[test]
400 fn test_path_around_simple_obstacle() {
401 // Vertical wall at x=3, y=-2..=6. Robot at (0,0), goal at (6,0).
402 // The robot must navigate around the top or bottom of the wall.
403 let mut obs_x = Vec::new();
404 let mut obs_y = Vec::new();
405 for j in -2i32..=6 {
406 obs_x.push(3.0f64);
407 obs_y.push(j as f64);
408 }
409
410 let planner = TangentBugPlanner::new(0.0, 0.0, 6.0, 0.0, &obs_x, &obs_y, 8.0);
411 let config = TangentBugConfig::default();
412 let result = planner
413 .plan(&config)
414 .expect("should reach goal despite obstacle");
415 assert_eq!(*result.path_x.last().unwrap(), 6.0);
416 assert_eq!(*result.path_y.last().unwrap(), 0.0);
417 // Path must be longer than the 6-step straight-line route.
418 assert!(result.path_x.len() > 7);
419 }
420
421 #[test]
422 fn test_config_defaults() {
423 let config = TangentBugConfig::default();
424 assert_eq!(config.sensor_range, 10.0);
425 assert_eq!(config.max_iterations, 1_000_000);
426 }
427}