Skip to main content

rust_robotics_planning/
grid_based_sweep_cpp.rs

1//! Grid-based sweep coverage path planner (boustrophedon / lawn-mower pattern).
2//!
3//! Ported from PythonRobotics `GridBasedSweepCPP`.
4//!
5//! The algorithm:
6//! 1. Find the longest edge of the input polygon to determine the sweep direction.
7//! 2. Rotate the polygon so that the longest edge is axis-aligned.
8//! 3. Build a grid map from the rotated polygon boundary, marking cells outside
9//!    the polygon as occupied.
10//! 4. Expand obstacles by one cell to avoid boundary collisions.
11//! 5. Perform a boustrophedon (back-and-forth) sweep search on the grid.
12//! 6. Convert the resulting grid path back to global coordinates.
13
14// ---------------------------------------------------------------------------
15// Grid map (self-contained, float-valued)
16// ---------------------------------------------------------------------------
17
18/// A 2D grid map with floating-point cell values.
19///
20/// Coordinate system: the grid is centred at (`center_x`, `center_y`) in world
21/// coordinates. Cell (0, 0) sits at the lower-left corner.
22struct FloatGridMap {
23    width: usize,
24    height: usize,
25    resolution: f64,
26    _center_x: f64,
27    _center_y: f64,
28    left_lower_x: f64,
29    left_lower_y: f64,
30    data: Vec<f64>,
31}
32
33impl FloatGridMap {
34    fn new(width: usize, height: usize, resolution: f64, center_x: f64, center_y: f64) -> Self {
35        let left_lower_x = center_x - (width as f64) / 2.0 * resolution;
36        let left_lower_y = center_y - (height as f64) / 2.0 * resolution;
37        let n = width * height;
38        Self {
39            width,
40            height,
41            resolution,
42            _center_x: center_x,
43            _center_y: center_y,
44            left_lower_x,
45            left_lower_y,
46            data: vec![0.0; n],
47        }
48    }
49
50    fn grid_index(&self, xi: i32, yi: i32) -> Option<usize> {
51        if xi < 0 || yi < 0 || (xi as usize) >= self.width || (yi as usize) >= self.height {
52            return None;
53        }
54        Some(yi as usize * self.width + xi as usize)
55    }
56
57    fn get_value(&self, xi: i32, yi: i32) -> Option<f64> {
58        self.grid_index(xi, yi).map(|i| self.data[i])
59    }
60
61    fn set_value(&mut self, xi: i32, yi: i32, val: f64) -> bool {
62        if let Some(i) = self.grid_index(xi, yi) {
63            self.data[i] = val;
64            true
65        } else {
66            false
67        }
68    }
69
70    /// Return the world position of the centre of cell (`xi`, `yi`).
71    fn cell_center(&self, xi: i32, yi: i32) -> (f64, f64) {
72        let x = self.left_lower_x + (xi as f64) * self.resolution + self.resolution / 2.0;
73        let y = self.left_lower_y + (yi as f64) * self.resolution + self.resolution / 2.0;
74        (x, y)
75    }
76
77    /// True when the cell is occupied (value >= `threshold`) or out of bounds.
78    fn is_occupied(&self, xi: i32, yi: i32, threshold: f64) -> bool {
79        match self.get_value(xi, yi) {
80            Some(v) => v >= threshold,
81            None => true,
82        }
83    }
84
85    // -- polygon helpers ----------------------------------------------------
86
87    /// Mark cells that are *outside* (or *inside*) the given polygon with `val`.
88    fn set_value_from_polygon(&mut self, pol_x: &[f64], pol_y: &[f64], val: f64, inside: bool) {
89        for yi in 0..self.height as i32 {
90            for xi in 0..self.width as i32 {
91                let (px, py) = self.cell_center(xi, yi);
92                let is_inside = point_in_polygon(px, py, pol_x, pol_y);
93                if is_inside == inside {
94                    self.set_value(xi, yi, val);
95                }
96            }
97        }
98    }
99
100    /// Expand occupied cells by one grid step in 8-connected neighbourhood.
101    fn expand_grid(&mut self) {
102        let mut marks: Vec<(i32, i32, f64)> = Vec::new();
103        for yi in 0..self.height as i32 {
104            for xi in 0..self.width as i32 {
105                if self.is_occupied(xi, yi, 1.0) {
106                    if let Some(v) = self.get_value(xi, yi) {
107                        for &(dx, dy) in &[
108                            (1, 0),
109                            (0, 1),
110                            (1, 1),
111                            (-1, 0),
112                            (0, -1),
113                            (-1, -1),
114                            (1, -1),
115                            (-1, 1),
116                        ] {
117                            marks.push((xi + dx, yi + dy, v));
118                        }
119                    }
120                }
121            }
122        }
123        for (x, y, v) in marks {
124            self.set_value(x, y, v);
125        }
126    }
127}
128
129/// Ray-casting point-in-polygon test.
130fn point_in_polygon(px: f64, py: f64, poly_x: &[f64], poly_y: &[f64]) -> bool {
131    let n = poly_x.len() - 1; // last vertex == first vertex (closed ring)
132    let mut inside = false;
133    for i1 in 0..n {
134        let i2 = (i1 + 1) % (n + 1);
135        let (min_x, max_x) = if poly_x[i1] >= poly_x[i2] {
136            (poly_x[i2], poly_x[i1])
137        } else {
138            (poly_x[i1], poly_x[i2])
139        };
140        if !(min_x <= px && px < max_x) {
141            continue;
142        }
143        let slope = (poly_y[i2] - poly_y[i1]) / (poly_x[i2] - poly_x[i1]);
144        if (poly_y[i1] + slope * (px - poly_x[i1]) - py) > 0.0 {
145            inside = !inside;
146        }
147    }
148    inside
149}
150
151// ---------------------------------------------------------------------------
152// 2D rotation helper
153// ---------------------------------------------------------------------------
154
155/// Rotate points by angle `theta` (radians). Returns (rx, ry).
156fn rotate_points(x: &[f64], y: &[f64], theta: f64) -> (Vec<f64>, Vec<f64>) {
157    let c = theta.cos();
158    let s = theta.sin();
159    let rx: Vec<f64> = x
160        .iter()
161        .zip(y.iter())
162        .map(|(&xi, &yi)| c * xi + s * yi)
163        .collect();
164    let ry: Vec<f64> = x
165        .iter()
166        .zip(y.iter())
167        .map(|(&xi, &yi)| -s * xi + c * yi)
168        .collect();
169    (rx, ry)
170}
171
172// ---------------------------------------------------------------------------
173// Sweep searcher
174// ---------------------------------------------------------------------------
175
176/// Sweep (vertical) direction.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum SweepDirection {
179    Up = 1,
180    Down = -1,
181}
182
183/// Horizontal moving direction.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum MovingDirection {
186    Right = 1,
187    Left = -1,
188}
189
190struct SweepSearcher {
191    moving_dir: i32,
192    sweep_dir: i32,
193    turning_window: Vec<(i32, i32)>,
194    x_indexes_goal_y: Vec<i32>,
195    goal_y: i32,
196}
197
198impl SweepSearcher {
199    fn new(
200        moving: MovingDirection,
201        sweep: SweepDirection,
202        x_inds_goal_y: Vec<i32>,
203        goal_y: i32,
204    ) -> Self {
205        let moving_dir = moving as i32;
206        let sweep_dir = sweep as i32;
207        let turning_window = Self::build_turning_window(moving_dir, sweep_dir);
208        Self {
209            moving_dir,
210            sweep_dir,
211            turning_window,
212            x_indexes_goal_y: x_inds_goal_y,
213            goal_y,
214        }
215    }
216
217    fn build_turning_window(md: i32, sd: i32) -> Vec<(i32, i32)> {
218        vec![(md, 0), (md, sd), (0, sd), (-md, sd)]
219    }
220
221    fn swap_moving_direction(&mut self) {
222        self.moving_dir = -self.moving_dir;
223        self.turning_window = Self::build_turning_window(self.moving_dir, self.sweep_dir);
224    }
225
226    /// Advance one step. Returns `Some((nx, ny))` or `None` if stuck.
227    fn move_target_grid(&mut self, cx: i32, cy: i32, grid: &FloatGridMap) -> Option<(i32, i32)> {
228        let nx = cx + self.moving_dir;
229        let ny = cy;
230
231        // Try to move in the current moving direction.
232        if !grid.is_occupied(nx, ny, 0.5) {
233            return Some((nx, ny));
234        }
235
236        // Current direction blocked -- try a turning manoeuvre.
237        if let Some((mut tnx, tny)) = self.find_safe_turning_grid(cx, cy, grid) {
238            // Keep moving in the current direction until blocked.
239            while !grid.is_occupied(tnx + self.moving_dir, tny, 0.5) {
240                tnx += self.moving_dir;
241            }
242            self.swap_moving_direction();
243            Some((tnx, tny))
244        } else {
245            // Try moving backward one step.
246            let bx = cx - self.moving_dir;
247            let by = cy;
248            if grid.is_occupied(bx, by, 1.0) {
249                None // completely stuck
250            } else {
251                Some((bx, by))
252            }
253        }
254    }
255
256    fn find_safe_turning_grid(&self, cx: i32, cy: i32, grid: &FloatGridMap) -> Option<(i32, i32)> {
257        for &(dx, dy) in &self.turning_window {
258            let nx = cx + dx;
259            let ny = cy + dy;
260            if !grid.is_occupied(nx, ny, 0.5) {
261                return Some((nx, ny));
262            }
263        }
264        None
265    }
266
267    fn is_search_done(&self, grid: &FloatGridMap) -> bool {
268        for &ix in &self.x_indexes_goal_y {
269            if !grid.is_occupied(ix, self.goal_y, 0.5) {
270                return false;
271            }
272        }
273        true
274    }
275
276    fn search_start_grid(&self, grid: &FloatGridMap) -> Option<(i32, i32)> {
277        let (x_inds, y_ind) = match self.sweep_dir.cmp(&0) {
278            std::cmp::Ordering::Less => search_free_grid_index_at_edge_y(grid, true),
279            _ => search_free_grid_index_at_edge_y(grid, false),
280        };
281        let y_ind = y_ind?;
282        if x_inds.is_empty() {
283            return None;
284        }
285        let x_ind = if self.moving_dir > 0 {
286            *x_inds.iter().min().unwrap()
287        } else {
288            *x_inds.iter().max().unwrap()
289        };
290        Some((x_ind, y_ind))
291    }
292}
293
294/// Search for free cells along the topmost (or bottommost) row that contains
295/// any free cells.
296fn search_free_grid_index_at_edge_y(
297    grid: &FloatGridMap,
298    from_upper: bool,
299) -> (Vec<i32>, Option<i32>) {
300    let y_range: Box<dyn Iterator<Item = i32>> = if from_upper {
301        Box::new((0..grid.height as i32).rev())
302    } else {
303        Box::new(0..grid.height as i32)
304    };
305
306    for iy in y_range {
307        let mut x_inds = Vec::new();
308        let x_iter: Box<dyn Iterator<Item = i32>> = if from_upper {
309            Box::new((0..grid.width as i32).rev())
310        } else {
311            Box::new(0..grid.width as i32)
312        };
313        for ix in x_iter {
314            if !grid.is_occupied(ix, iy, 0.5) {
315                x_inds.push(ix);
316            }
317        }
318        if !x_inds.is_empty() {
319            return (x_inds, Some(iy));
320        }
321    }
322    (Vec::new(), None)
323}
324
325// ---------------------------------------------------------------------------
326// Grid map construction
327// ---------------------------------------------------------------------------
328
329fn setup_grid_map(
330    ox: &[f64],
331    oy: &[f64],
332    resolution: f64,
333    sweep_dir: SweepDirection,
334) -> (FloatGridMap, Vec<i32>, i32) {
335    let offset_grid: usize = 10;
336    let (min_x, max_x) = min_max(ox);
337    let (min_y, max_y) = min_max(oy);
338    let width = ((max_x - min_x) / resolution).ceil() as usize + offset_grid;
339    let height = ((max_y - min_y) / resolution).ceil() as usize + offset_grid;
340    let center_x = (max_x + min_x) / 2.0;
341    let center_y = (max_y + min_y) / 2.0;
342
343    let mut grid = FloatGridMap::new(width, height, resolution, center_x, center_y);
344    grid.set_value_from_polygon(ox, oy, 1.0, false); // mark outside as occupied
345    grid.expand_grid();
346
347    let (x_inds_goal_y, goal_y) = match sweep_dir {
348        SweepDirection::Up => search_free_grid_index_at_edge_y(&grid, true),
349        SweepDirection::Down => search_free_grid_index_at_edge_y(&grid, false),
350    };
351    let goal_y = goal_y.unwrap_or(0);
352
353    (grid, x_inds_goal_y, goal_y)
354}
355
356fn min_max(v: &[f64]) -> (f64, f64) {
357    let mut lo = f64::INFINITY;
358    let mut hi = f64::NEG_INFINITY;
359    for &x in v {
360        if x < lo {
361            lo = x;
362        }
363        if x > hi {
364            hi = x;
365        }
366    }
367    (lo, hi)
368}
369
370// ---------------------------------------------------------------------------
371// Sweep path search
372// ---------------------------------------------------------------------------
373
374fn sweep_path_search(
375    searcher: &mut SweepSearcher,
376    grid: &mut FloatGridMap,
377) -> (Vec<f64>, Vec<f64>) {
378    let (mut cx, mut cy) = match searcher.search_start_grid(grid) {
379        Some(p) => p,
380        None => return (Vec::new(), Vec::new()),
381    };
382
383    if !grid.set_value(cx, cy, 0.5) {
384        return (Vec::new(), Vec::new());
385    }
386
387    let (x, y) = grid.cell_center(cx, cy);
388    let mut px = vec![x];
389    let mut py = vec![y];
390
391    while let Some((nx, ny)) = searcher.move_target_grid(cx, cy, grid) {
392        cx = nx;
393        cy = ny;
394
395        if searcher.is_search_done(grid) {
396            break;
397        }
398
399        let (x, y) = grid.cell_center(cx, cy);
400        px.push(x);
401        py.push(y);
402
403        grid.set_value(cx, cy, 0.5);
404    }
405
406    (px, py)
407}
408
409// ---------------------------------------------------------------------------
410// Coordinate conversion
411// ---------------------------------------------------------------------------
412
413/// Find the longest polygon edge and return its direction vector and start
414/// vertex.
415fn find_sweep_direction_and_start(ox: &[f64], oy: &[f64]) -> ([f64; 2], [f64; 2]) {
416    let mut max_dist = 0.0_f64;
417    let mut vec = [0.0, 0.0];
418    let mut start = [0.0, 0.0];
419    for i in 0..ox.len() - 1 {
420        let dx = ox[i + 1] - ox[i];
421        let dy = oy[i + 1] - oy[i];
422        let d = (dx * dx + dy * dy).sqrt();
423        if d > max_dist {
424            max_dist = d;
425            vec = [dx, dy];
426            start = [ox[i], oy[i]];
427        }
428    }
429    (vec, start)
430}
431
432fn convert_grid_coordinate(
433    ox: &[f64],
434    oy: &[f64],
435    sweep_vec: [f64; 2],
436    sweep_start: [f64; 2],
437) -> (Vec<f64>, Vec<f64>) {
438    let tx: Vec<f64> = ox.iter().map(|x| x - sweep_start[0]).collect();
439    let ty: Vec<f64> = oy.iter().map(|y| y - sweep_start[1]).collect();
440    let theta = sweep_vec[1].atan2(sweep_vec[0]);
441    rotate_points(&tx, &ty, theta)
442}
443
444fn convert_global_coordinate(
445    px: &[f64],
446    py: &[f64],
447    sweep_vec: [f64; 2],
448    sweep_start: [f64; 2],
449) -> (Vec<f64>, Vec<f64>) {
450    let theta = sweep_vec[1].atan2(sweep_vec[0]);
451    let (rx, ry) = rotate_points(px, py, -theta);
452    let gx: Vec<f64> = rx.iter().map(|x| x + sweep_start[0]).collect();
453    let gy: Vec<f64> = ry.iter().map(|y| y + sweep_start[1]).collect();
454    (gx, gy)
455}
456
457// ---------------------------------------------------------------------------
458// Public API
459// ---------------------------------------------------------------------------
460
461/// Configuration for the grid-based sweep coverage path planner.
462#[derive(Debug, Clone)]
463pub struct GridBasedSweepConfig {
464    /// Grid resolution \[m\].
465    pub resolution: f64,
466    /// Horizontal moving direction at the start of the sweep.
467    pub moving_direction: MovingDirection,
468    /// Vertical sweep direction.
469    pub sweep_direction: SweepDirection,
470}
471
472impl Default for GridBasedSweepConfig {
473    fn default() -> Self {
474        Self {
475            resolution: 5.0,
476            moving_direction: MovingDirection::Right,
477            sweep_direction: SweepDirection::Up,
478        }
479    }
480}
481
482/// Result of the grid-based sweep coverage path planner.
483#[derive(Debug, Clone)]
484pub struct SweepCoveragePath {
485    /// X coordinates of the planned path in the global frame.
486    pub x: Vec<f64>,
487    /// Y coordinates of the planned path in the global frame.
488    pub y: Vec<f64>,
489}
490
491/// Plan a boustrophedon (lawn-mower) coverage path over the area enclosed by
492/// the given polygon.
493///
494/// The polygon is specified as a sequence of vertices (`ox`, `oy`) forming a
495/// closed ring (first vertex == last vertex).
496///
497/// # Arguments
498/// * `ox` - x-coordinates of the polygon boundary.
499/// * `oy` - y-coordinates of the polygon boundary.
500/// * `config` - planner configuration.
501///
502/// # Returns
503/// A [`SweepCoveragePath`] containing the planned waypoints in global
504/// coordinates.
505///
506/// # Example
507/// ```
508/// use rust_robotics_planning::grid_based_sweep_cpp::{
509///     plan_sweep_coverage, GridBasedSweepConfig,
510/// };
511///
512/// let ox = vec![0.0, 50.0, 50.0, 0.0, 0.0];
513/// let oy = vec![0.0, 0.0, 30.0, 30.0, 0.0];
514/// let config = GridBasedSweepConfig {
515///     resolution: 5.0,
516///     ..Default::default()
517/// };
518/// let path = plan_sweep_coverage(&ox, &oy, &config);
519/// assert!(!path.x.is_empty());
520/// ```
521pub fn plan_sweep_coverage(
522    ox: &[f64],
523    oy: &[f64],
524    config: &GridBasedSweepConfig,
525) -> SweepCoveragePath {
526    assert!(
527        ox.len() >= 4 && ox.len() == oy.len(),
528        "polygon must have at least 3 distinct vertices (4 points with closing vertex)"
529    );
530
531    let (sweep_vec, sweep_start) = find_sweep_direction_and_start(ox, oy);
532    let (rox, roy) = convert_grid_coordinate(ox, oy, sweep_vec, sweep_start);
533
534    let (mut grid, x_inds_goal_y, goal_y) =
535        setup_grid_map(&rox, &roy, config.resolution, config.sweep_direction);
536
537    let mut searcher = SweepSearcher::new(
538        config.moving_direction,
539        config.sweep_direction,
540        x_inds_goal_y,
541        goal_y,
542    );
543
544    let (px, py) = sweep_path_search(&mut searcher, &mut grid);
545    let (gx, gy) = convert_global_coordinate(&px, &py, sweep_vec, sweep_start);
546
547    SweepCoveragePath { x: gx, y: gy }
548}
549
550// ---------------------------------------------------------------------------
551// Tests
552// ---------------------------------------------------------------------------
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use std::f64::consts::PI;
558
559    /// Helper: Euclidean distance between two points.
560    fn dist(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
561        ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
562    }
563
564    // -- unit tests for internal helpers ------------------------------------
565
566    #[test]
567    fn test_point_in_polygon_square() {
568        // Square from (0,0) to (10,10), closed.
569        let px = vec![0.0, 10.0, 10.0, 0.0, 0.0];
570        let py = vec![0.0, 0.0, 10.0, 10.0, 0.0];
571        assert!(point_in_polygon(5.0, 5.0, &px, &py));
572        assert!(!point_in_polygon(15.0, 5.0, &px, &py));
573        assert!(!point_in_polygon(-1.0, 5.0, &px, &py));
574    }
575
576    #[test]
577    fn test_rotate_points_90_degrees() {
578        let x = vec![1.0, 0.0];
579        let y = vec![0.0, 1.0];
580        let (rx, ry) = rotate_points(&x, &y, PI / 2.0);
581        // After rotating by 90 degrees: (1,0) -> (0, -1), (0,1) -> (1, 0)
582        assert!((rx[0] - 0.0).abs() < 1e-10);
583        assert!((ry[0] - (-1.0)).abs() < 1e-10);
584        assert!((rx[1] - 1.0).abs() < 1e-10);
585        assert!((ry[1] - 0.0).abs() < 1e-10);
586    }
587
588    #[test]
589    fn test_find_sweep_direction_longest_edge() {
590        let ox = vec![0.0, 100.0, 100.0, 0.0, 0.0];
591        let oy = vec![0.0, 0.0, 10.0, 10.0, 0.0];
592        let (vec, start) = find_sweep_direction_and_start(&ox, &oy);
593        // Longest edge is the bottom: (0,0) -> (100,0)
594        assert!((vec[0] - 100.0).abs() < 1e-10);
595        assert!((vec[1] - 0.0).abs() < 1e-10);
596        assert!((start[0] - 0.0).abs() < 1e-10);
597        assert!((start[1] - 0.0).abs() < 1e-10);
598    }
599
600    #[test]
601    fn test_grid_map_set_get() {
602        let mut gm = FloatGridMap::new(10, 10, 1.0, 5.0, 5.0);
603        assert!(!gm.is_occupied(5, 5, 0.5));
604        gm.set_value(5, 5, 1.0);
605        assert!(gm.is_occupied(5, 5, 0.5));
606    }
607
608    #[test]
609    fn test_grid_map_out_of_bounds() {
610        let gm = FloatGridMap::new(5, 5, 1.0, 2.5, 2.5);
611        assert!(gm.is_occupied(-1, 0, 0.5));
612        assert!(gm.is_occupied(0, -1, 0.5));
613        assert!(gm.is_occupied(5, 0, 0.5));
614        assert!(gm.is_occupied(0, 5, 0.5));
615    }
616
617    // -- integration tests --------------------------------------------------
618
619    #[test]
620    fn test_rectangle_coverage() {
621        let ox = vec![0.0, 50.0, 50.0, 0.0, 0.0];
622        let oy = vec![0.0, 0.0, 30.0, 30.0, 0.0];
623        let config = GridBasedSweepConfig {
624            resolution: 5.0,
625            ..Default::default()
626        };
627        let path = plan_sweep_coverage(&ox, &oy, &config);
628
629        assert!(
630            !path.x.is_empty(),
631            "coverage path should not be empty for a rectangle"
632        );
633        // Path should have many waypoints.
634        assert!(
635            path.x.len() > 5,
636            "expected more than 5 waypoints, got {}",
637            path.x.len()
638        );
639    }
640
641    #[test]
642    fn test_polygon_coverage() {
643        let ox = vec![0.0, 20.0, 50.0, 100.0, 130.0, 40.0, 0.0];
644        let oy = vec![0.0, -20.0, 0.0, 30.0, 60.0, 80.0, 0.0];
645        let config = GridBasedSweepConfig {
646            resolution: 5.0,
647            ..Default::default()
648        };
649        let path = plan_sweep_coverage(&ox, &oy, &config);
650
651        assert!(!path.x.is_empty());
652        assert!(path.x.len() > 10);
653    }
654
655    #[test]
656    fn test_path_lengths_match() {
657        let ox = vec![0.0, 50.0, 50.0, 0.0, 0.0];
658        let oy = vec![0.0, 0.0, 30.0, 30.0, 0.0];
659        let config = GridBasedSweepConfig::default();
660        let path = plan_sweep_coverage(&ox, &oy, &config);
661
662        assert_eq!(
663            path.x.len(),
664            path.y.len(),
665            "x and y paths must have the same length"
666        );
667    }
668
669    #[test]
670    fn test_waypoints_inside_polygon_neighbourhood() {
671        // All waypoints should be reasonably close to the polygon interior.
672        let ox = vec![0.0, 50.0, 50.0, 0.0, 0.0];
673        let oy = vec![0.0, 0.0, 30.0, 30.0, 0.0];
674        let config = GridBasedSweepConfig {
675            resolution: 1.3,
676            ..Default::default()
677        };
678        let path = plan_sweep_coverage(&ox, &oy, &config);
679
680        let margin = config.resolution * 3.0;
681        for (&x, &y) in path.x.iter().zip(path.y.iter()) {
682            assert!(
683                x > -margin && x < 50.0 + margin && y > -margin && y < 30.0 + margin,
684                "waypoint ({}, {}) is too far from the polygon",
685                x,
686                y
687            );
688        }
689    }
690
691    #[test]
692    fn test_consecutive_waypoints_are_close() {
693        let ox = vec![0.0, 50.0, 50.0, 0.0, 0.0];
694        let oy = vec![0.0, 0.0, 30.0, 30.0, 0.0];
695        let config = GridBasedSweepConfig {
696            resolution: 2.0,
697            ..Default::default()
698        };
699        let path = plan_sweep_coverage(&ox, &oy, &config);
700
701        let max_step = config.resolution * 3.0; // allow some slack for turns
702        for i in 1..path.x.len() {
703            let d = dist(path.x[i - 1], path.y[i - 1], path.x[i], path.y[i]);
704            assert!(
705                d < max_step,
706                "step {} -> {} is too large: {:.2} (max {:.2})",
707                i - 1,
708                i,
709                d,
710                max_step,
711            );
712        }
713    }
714
715    #[test]
716    fn test_different_sweep_directions() {
717        let ox = vec![0.0, 50.0, 50.0, 0.0, 0.0];
718        let oy = vec![0.0, 0.0, 30.0, 30.0, 0.0];
719
720        let cfg_up_right = GridBasedSweepConfig {
721            resolution: 5.0,
722            moving_direction: MovingDirection::Right,
723            sweep_direction: SweepDirection::Up,
724        };
725        let cfg_down_left = GridBasedSweepConfig {
726            resolution: 5.0,
727            moving_direction: MovingDirection::Left,
728            sweep_direction: SweepDirection::Down,
729        };
730
731        let path1 = plan_sweep_coverage(&ox, &oy, &cfg_up_right);
732        let path2 = plan_sweep_coverage(&ox, &oy, &cfg_down_left);
733
734        assert!(!path1.x.is_empty());
735        assert!(!path2.x.is_empty());
736        // Paths should differ in at least the first waypoint.
737        let same_start =
738            (path1.x[0] - path2.x[0]).abs() < 1e-6 && (path1.y[0] - path2.y[0]).abs() < 1e-6;
739        assert!(
740            !same_start,
741            "different sweep configs should produce different starting points"
742        );
743    }
744
745    #[test]
746    fn test_fine_resolution_rectangle() {
747        let ox = vec![0.0, 50.0, 50.0, 0.0, 0.0];
748        let oy = vec![0.0, 0.0, 30.0, 30.0, 0.0];
749        let config = GridBasedSweepConfig {
750            resolution: 1.3,
751            ..Default::default()
752        };
753        let path = plan_sweep_coverage(&ox, &oy, &config);
754        // Finer resolution should produce more waypoints.
755        assert!(
756            path.x.len() > 50,
757            "fine resolution should yield many waypoints, got {}",
758            path.x.len()
759        );
760    }
761
762    #[test]
763    #[should_panic(expected = "polygon must have at least 3 distinct vertices")]
764    fn test_too_few_vertices_panics() {
765        let ox = vec![0.0, 1.0];
766        let oy = vec![0.0, 1.0];
767        plan_sweep_coverage(&ox, &oy, &GridBasedSweepConfig::default());
768    }
769
770    #[test]
771    fn test_large_irregular_polygon() {
772        let ox = vec![0.0, 20.0, 50.0, 200.0, 130.0, 40.0, 0.0];
773        let oy = vec![0.0, -80.0, 0.0, 30.0, 60.0, 80.0, 0.0];
774        let config = GridBasedSweepConfig {
775            resolution: 5.0,
776            ..Default::default()
777        };
778        let path = plan_sweep_coverage(&ox, &oy, &config);
779        assert!(!path.x.is_empty());
780        assert!(path.x.len() > 20);
781    }
782}