Skip to main content

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}