Skip to main content

rust_robotics_planning/
coverage_planning.rs

1//! Coverage path planning algorithms
2//!
3//! Implements two coverage strategies:
4//! - Boustrophedon (back-and-forth sweep) coverage
5//! - Spiral coverage
6
7/// A 2D grid cell position.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub struct Cell {
10    pub x: i32,
11    pub y: i32,
12}
13
14impl Cell {
15    pub fn new(x: i32, y: i32) -> Self {
16        Self { x, y }
17    }
18}
19
20/// Obstacle grid: true = blocked, false = free.
21pub struct CoverageGrid {
22    pub width: i32,
23    pub height: i32,
24    blocked: Vec<bool>,
25}
26
27impl CoverageGrid {
28    pub fn new(width: i32, height: i32) -> Self {
29        Self {
30            width,
31            height,
32            blocked: vec![false; (width * height) as usize],
33        }
34    }
35
36    pub fn set_blocked(&mut self, x: i32, y: i32, val: bool) {
37        if self.in_bounds(x, y) {
38            self.blocked[(y * self.width + x) as usize] = val;
39        }
40    }
41
42    pub fn is_blocked(&self, x: i32, y: i32) -> bool {
43        if !self.in_bounds(x, y) {
44            return true;
45        }
46        self.blocked[(y * self.width + x) as usize]
47    }
48
49    pub fn in_bounds(&self, x: i32, y: i32) -> bool {
50        x >= 0 && x < self.width && y >= 0 && y < self.height
51    }
52
53    pub fn is_free(&self, x: i32, y: i32) -> bool {
54        self.in_bounds(x, y) && !self.is_blocked(x, y)
55    }
56
57    pub fn free_cell_count(&self) -> usize {
58        self.blocked.iter().filter(|&&b| !b).count()
59    }
60}
61
62/// Boustrophedon (back-and-forth sweep) coverage planner.
63///
64/// Sweeps column by column, alternating direction each column.
65/// Skips blocked cells.
66pub fn boustrophedon_coverage(grid: &CoverageGrid) -> Vec<Cell> {
67    let mut path = Vec::new();
68    let mut going_up = true;
69
70    for x in 0..grid.width {
71        let ys: Box<dyn Iterator<Item = i32>> = if going_up {
72            Box::new(0..grid.height)
73        } else {
74            Box::new((0..grid.height).rev())
75        };
76
77        let mut column_has_free = false;
78        for y in ys {
79            if grid.is_free(x, y) {
80                path.push(Cell::new(x, y));
81                column_has_free = true;
82            }
83        }
84
85        if column_has_free {
86            going_up = !going_up;
87        }
88    }
89
90    path
91}
92
93/// Spiral coverage planner.
94///
95/// Starts from the given position and spirals outward in a clockwise pattern.
96/// Uses a right-hand-wall-following approach.
97pub fn spiral_coverage(grid: &CoverageGrid, start_x: i32, start_y: i32) -> Vec<Cell> {
98    let mut path = Vec::new();
99    let mut visited = vec![false; (grid.width * grid.height) as usize];
100
101    if !grid.is_free(start_x, start_y) {
102        return path;
103    }
104
105    // Directions: right, down, left, up (clockwise)
106    let dx = [1, 0, -1, 0];
107    let dy = [0, 1, 0, -1];
108
109    let mut x = start_x;
110    let mut y = start_y;
111    let mut dir = 0usize; // start going right
112
113    let visit = |x: i32, y: i32, visited: &mut Vec<bool>| {
114        visited[(y * grid.width + x) as usize] = true;
115    };
116
117    let is_visited = |x: i32, y: i32, visited: &Vec<bool>| -> bool {
118        if !grid.in_bounds(x, y) {
119            return true;
120        }
121        visited[(y * grid.width + x) as usize]
122    };
123
124    path.push(Cell::new(x, y));
125    visit(x, y, &mut visited);
126
127    let total_free = grid.free_cell_count();
128    let mut stuck_count = 0;
129
130    while path.len() < total_free && stuck_count < 4 {
131        let nx = x + dx[dir];
132        let ny = y + dy[dir];
133
134        if grid.is_free(nx, ny) && !is_visited(nx, ny, &visited) {
135            x = nx;
136            y = ny;
137            path.push(Cell::new(x, y));
138            visit(x, y, &mut visited);
139            stuck_count = 0;
140        } else {
141            // Turn clockwise
142            dir = (dir + 1) % 4;
143            stuck_count += 1;
144        }
145    }
146
147    // If spiral gets stuck, do a second pass to pick up remaining cells
148    if path.len() < total_free {
149        for cy in 0..grid.height {
150            for cx in 0..grid.width {
151                if grid.is_free(cx, cy) && !is_visited(cx, cy, &visited) {
152                    path.push(Cell::new(cx, cy));
153                    visit(cx, cy, &mut visited);
154                }
155            }
156        }
157    }
158
159    path
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_boustrophedon_covers_all_free_cells() {
168        let grid = CoverageGrid::new(5, 5);
169        let path = boustrophedon_coverage(&grid);
170
171        assert_eq!(path.len(), 25);
172        // All cells should be visited exactly once
173        let mut seen = std::collections::HashSet::new();
174        for cell in &path {
175            assert!(seen.insert(*cell), "duplicate cell: {:?}", cell);
176        }
177    }
178
179    #[test]
180    fn test_boustrophedon_skips_obstacles() {
181        let mut grid = CoverageGrid::new(4, 4);
182        grid.set_blocked(1, 1, true);
183        grid.set_blocked(2, 2, true);
184
185        let path = boustrophedon_coverage(&grid);
186        assert_eq!(path.len(), 14); // 16 - 2 blocked
187
188        for cell in &path {
189            assert!(!grid.is_blocked(cell.x, cell.y));
190        }
191    }
192
193    #[test]
194    fn test_boustrophedon_path_is_connected_within_columns() {
195        let grid = CoverageGrid::new(3, 4);
196        let path = boustrophedon_coverage(&grid);
197
198        // Within each column, y values should be consecutive
199        assert_eq!(path.len(), 12);
200    }
201
202    #[test]
203    fn test_spiral_covers_all_free_cells() {
204        let grid = CoverageGrid::new(5, 5);
205        let path = spiral_coverage(&grid, 2, 2);
206
207        assert_eq!(path.len(), 25);
208        let mut seen = std::collections::HashSet::new();
209        for cell in &path {
210            assert!(seen.insert(*cell), "duplicate cell: {:?}", cell);
211        }
212    }
213
214    #[test]
215    fn test_spiral_with_obstacles() {
216        let mut grid = CoverageGrid::new(5, 5);
217        grid.set_blocked(1, 1, true);
218        grid.set_blocked(3, 3, true);
219
220        let path = spiral_coverage(&grid, 0, 0);
221        assert_eq!(path.len(), 23); // 25 - 2 blocked
222
223        for cell in &path {
224            assert!(!grid.is_blocked(cell.x, cell.y));
225        }
226    }
227
228    #[test]
229    fn test_spiral_from_corner() {
230        let grid = CoverageGrid::new(3, 3);
231        let path = spiral_coverage(&grid, 0, 0);
232
233        assert_eq!(path.len(), 9);
234        assert_eq!(path[0], Cell::new(0, 0));
235    }
236
237    #[test]
238    fn test_spiral_blocked_start() {
239        let mut grid = CoverageGrid::new(3, 3);
240        grid.set_blocked(1, 1, true);
241        let path = spiral_coverage(&grid, 1, 1);
242        assert!(path.is_empty());
243    }
244
245    #[test]
246    fn test_empty_grid() {
247        let grid = CoverageGrid::new(0, 0);
248        let path = boustrophedon_coverage(&grid);
249        assert!(path.is_empty());
250    }
251}