Skip to main content

condor_grid/grid/
reachability.rs

1//! Optional connected-component index for walkable cells on a [`Grid`].
2//!
3//! [`Grid::index_reachability`] builds it with the same 4-connected neighborhood as
4//! online search, letting solvers reject a valid but disconnected pair before their
5//! own work begins. It is an optimization only: rebuild it after a walkability edit,
6//! or omit it when queries are too sparse to justify preprocessing.
7
8use std::collections::VecDeque;
9
10use super::Grid;
11use crate::Point;
12
13/// Maps each walkable cell to a 4-connected component id for cheap reachability.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct GridReachabilityIndex {
16    width: usize,
17    height: usize,
18    components: Vec<Option<u32>>,
19    component_count: u32,
20}
21
22impl GridReachabilityIndex {
23    /// Flood-fills walkable cells into 4-connected components (same neighbor model as search).
24    ///
25    /// Snapshot of `grid` at call time; rebuild after walkability edits. Blocked cells
26    /// receive no component id.
27    #[must_use]
28    pub fn from_grid(grid: &Grid) -> Self {
29        let width = grid.width();
30        let height = grid.height();
31        let mut components = vec![None; width * height];
32        let mut component_count = 0;
33
34        for y in 0..height {
35            for x in 0..width {
36                let point = Point::new(x, y);
37                let index = grid.index_of(point).unwrap();
38
39                if grid.is_walkable(point) && components[index].is_none() {
40                    let component_id = component_count;
41                    component_count += 1;
42
43                    let mut queue = VecDeque::new();
44                    queue.push_back(point);
45                    components[index] = Some(component_id);
46
47                    while let Some(current) = queue.pop_front() {
48                        for neighbor in grid.neighbors4(current) {
49                            let neighbor_index = grid.index_of(neighbor).unwrap();
50                            if components[neighbor_index].is_none() {
51                                components[neighbor_index] = Some(component_id);
52                                queue.push_back(neighbor);
53                            }
54                        }
55                    }
56                }
57            }
58        }
59
60        Self {
61            width,
62            height,
63            components,
64            component_count,
65        }
66    }
67
68    /// Returns the component id for a walkable in-bounds cell; blocked and OOB cells return `None`.
69    #[must_use]
70    pub fn component_id(&self, point: Point) -> Option<u32> {
71        if point.x >= self.width || point.y >= self.height {
72            return None;
73        }
74        let index = (point.y * self.width) + point.x;
75        self.components[index]
76    }
77
78    /// Whether `start` and `goal` share a walkable 4-connected component.
79    ///
80    /// Returns `false` if either point is blocked, out of bounds, or the pair
81    /// lies in different components. A walkable cell is reachable from itself.
82    #[must_use]
83    pub fn is_reachable(&self, start: Point, goal: Point) -> bool {
84        let start_comp = self.component_id(start);
85        let goal_comp = self.component_id(goal);
86
87        match (start_comp, goal_comp) {
88            (Some(s), Some(g)) => s == g,
89            _ => false,
90        }
91    }
92
93    /// Number of distinct walkable components in the indexed grid.
94    #[must_use]
95    pub fn component_count(&self) -> u32 {
96        self.component_count
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::{
104        AStar, Bfs, BidirectionalBfs, Cell, Dijkstra, JumpPointSearch, Pathfinder,
105        RectangularSymmetryReduction, SearchRequest,
106    };
107
108    #[test]
109    fn open_field_has_one_component() {
110        let grid = Grid::new(10, 10).expect("grid dimensions are valid");
111        let index = GridReachabilityIndex::from_grid(&grid);
112        assert_eq!(index.component_count(), 1);
113        assert!(index.is_reachable(Point::new(0, 0), Point::new(9, 9)));
114    }
115
116    #[test]
117    fn blocked_wall_splits_components() {
118        let mut grid = Grid::new(10, 10).expect("grid dimensions are valid");
119        for y in 0..10 {
120            grid.set_cell(Point::new(5, y), Cell::Blocked)
121                .expect("valid grid edit");
122        }
123        let index = GridReachabilityIndex::from_grid(&grid);
124        assert_eq!(index.component_count(), 2);
125        assert!(!index.is_reachable(Point::new(0, 0), Point::new(9, 9)));
126        assert!(index.is_reachable(Point::new(0, 0), Point::new(4, 9)));
127        assert!(index.is_reachable(Point::new(6, 0), Point::new(9, 9)));
128    }
129
130    #[test]
131    fn blocked_cells_have_no_component() {
132        let mut grid = Grid::new(10, 10).expect("grid dimensions are valid");
133        grid.set_cell(Point::new(5, 5), Cell::Blocked)
134            .expect("valid grid edit");
135        let index = GridReachabilityIndex::from_grid(&grid);
136        assert_eq!(index.component_id(Point::new(5, 5)), None);
137    }
138
139    #[test]
140    fn out_of_bounds_points_have_no_component() {
141        let grid = Grid::new(4, 4).expect("grid dimensions are valid");
142        let index = GridReachabilityIndex::from_grid(&grid);
143        assert_eq!(index.component_id(Point::new(4, 0)), None);
144        assert_eq!(index.component_id(Point::new(0, 4)), None);
145    }
146
147    #[test]
148    fn pathfinders_short_circuit_on_disconnected_islands() {
149        let mut grid = Grid::new(10, 10).expect("grid dimensions are valid");
150        for y in 0..10 {
151            grid.set_cell(Point::new(5, y), Cell::Blocked)
152                .expect("wall cell should be valid");
153        }
154        grid.index_reachability();
155
156        let request = SearchRequest::new(Point::new(0, 0), Point::new(9, 9));
157        let algorithms: Vec<Box<dyn Pathfinder>> = vec![
158            Box::new(Bfs),
159            Box::new(AStar),
160            Box::new(BidirectionalBfs),
161            Box::new(Dijkstra),
162            Box::new(JumpPointSearch),
163            Box::new(RectangularSymmetryReduction),
164        ];
165
166        for algorithm in algorithms {
167            let result = algorithm
168                .search(&grid, request)
169                .expect("test request should be valid");
170            assert!(
171                !result.is_found(),
172                "Algorithm {} should not find a path",
173                algorithm.name()
174            );
175            assert_eq!(
176                result.stats().visited_nodes,
177                0,
178                "Algorithm {} should have 0 visited nodes due to short-circuit",
179                algorithm.name()
180            );
181        }
182    }
183
184    #[test]
185    fn pathfinders_work_normally_when_no_index_present() {
186        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
187        for x in 0..3 {
188            grid.set_cell(Point::new(x, 1), Cell::Blocked)
189                .expect("wall cell should be valid");
190        }
191
192        let request = SearchRequest::new(Point::new(0, 0), Point::new(2, 2));
193        let result = AStar
194            .search(&grid, request)
195            .expect("test request should be valid");
196
197        assert!(!result.is_found());
198        assert!(result.stats().visited_nodes > 0);
199    }
200}