1use std::{
10 cmp::Ordering,
11 collections::{BinaryHeap, HashMap, VecDeque},
12};
13
14use crate::{
15 AStar, Grid, Path, Pathfinder, Point,
16 preprocessed_grid::{
17 PreparedGridSearch, PreprocessedGridBuildError, PreprocessedGridBuilder,
18 PreprocessedGridMetadata, metadata_for_grid,
19 },
20 search::{SearchRequest, SearchResult},
21};
22
23#[derive(Debug, Clone, Copy, Default)]
29pub struct SubgoalGraphBuilder;
30
31impl SubgoalGraphBuilder {
32 #[must_use]
34 pub const fn new() -> Self {
35 Self
36 }
37}
38
39impl PreprocessedGridBuilder for SubgoalGraphBuilder {
40 type Map = PreparedSubgoalGraph;
41
42 fn name(&self) -> &'static str {
43 "subgoal-graph"
44 }
45
46 fn preprocess(&self, grid: &Grid) -> Result<Self::Map, PreprocessedGridBuildError> {
47 ensure_uniform_traversal_costs(grid)?;
48 let subgoals = select_corner_subgoals(grid);
49 Ok(PreparedSubgoalGraph {
50 grid: grid.clone(),
51 metadata: metadata_for_grid(grid, self.name(), "subgoal-graph-query"),
52 subgoals,
53 })
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct PreparedSubgoalGraph {
63 grid: Grid,
64 metadata: PreprocessedGridMetadata,
65 subgoals: Vec<Point>,
66}
67
68impl PreparedSubgoalGraph {
69 #[must_use]
71 pub fn builder() -> SubgoalGraphBuilder {
72 SubgoalGraphBuilder
73 }
74
75 #[must_use]
77 pub fn subgoal_count(&self) -> usize {
78 self.subgoals.len()
79 }
80}
81
82impl PreparedGridSearch for PreparedSubgoalGraph {
83 fn name(&self) -> &'static str {
84 self.metadata.builder_name
85 }
86
87 fn grid(&self) -> &Grid {
88 &self.grid
89 }
90
91 fn metadata(&self) -> &PreprocessedGridMetadata {
92 &self.metadata
93 }
94
95 fn search(&self, request: SearchRequest) -> SearchResult {
96 crate::search::validate_request(&self.grid, request)?;
97 if request.start == request.goal || self.subgoals.is_empty() || self.subgoals.len() > 64 {
99 return AStar.search(&self.grid, request);
100 }
101
102 abstract_search(self, request).unwrap_or_else(|| AStar.search(&self.grid, request))
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107enum AbstractNode {
108 Start,
109 Goal,
110 Subgoal(usize),
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114struct HeapEntry {
115 cost: usize,
116 node: AbstractNode,
117}
118
119impl Ord for HeapEntry {
120 fn cmp(&self, other: &Self) -> Ordering {
121 other
122 .cost
123 .cmp(&self.cost)
124 .then_with(|| node_rank(self.node).cmp(&node_rank(other.node)))
125 }
126}
127
128impl PartialOrd for HeapEntry {
129 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
130 Some(self.cmp(other))
131 }
132}
133
134fn node_rank(node: AbstractNode) -> u8 {
135 match node {
136 AbstractNode::Start => 0,
137 AbstractNode::Goal => 1,
138 AbstractNode::Subgoal(_) => 2,
139 }
140}
141
142fn abstract_search(map: &PreparedSubgoalGraph, request: SearchRequest) -> Option<SearchResult> {
143 let mut distances_from: HashMap<Point, HashMap<Point, usize>> = HashMap::new();
144 let mut best = HashMap::new();
145 let mut parent: HashMap<AbstractNode, AbstractNode> = HashMap::new();
146 let mut heap = BinaryHeap::new();
147
148 best.insert(AbstractNode::Start, 0usize);
149 heap.push(HeapEntry {
150 cost: 0,
151 node: AbstractNode::Start,
152 });
153
154 let mut visited_nodes = 0usize;
155 let watch = crate::search::BudgetWatch::start(request.budget);
156
157 while let Some(HeapEntry { cost, node }) = heap.pop() {
158 if best.get(&node).is_some_and(|&known| cost > known) {
159 continue;
160 }
161 visited_nodes += 1;
162
163 if matches!(node, AbstractNode::Goal) {
164 let points = reconstruct_path(map, request, &parent)?;
165 let path = Path::from_steps(points).ok()?;
166 return Some(crate::search::found(path, visited_nodes));
167 }
168
169 if let Err(reason) = watch.check(visited_nodes) {
170 return Some(Err(crate::search::budget_error(reason)));
171 }
172
173 for (next, step_cost) in outgoing_edges(map, node, request, &mut distances_from) {
174 let next_cost = cost.saturating_add(step_cost);
175 if best.get(&next).is_some_and(|&known| next_cost >= known) {
176 continue;
177 }
178 best.insert(next, next_cost);
179 parent.insert(next, node);
180 heap.push(HeapEntry {
181 cost: next_cost,
182 node: next,
183 });
184 }
185 }
186
187 None
188}
189
190fn outgoing_edges(
191 map: &PreparedSubgoalGraph,
192 node: AbstractNode,
193 request: SearchRequest,
194 distances_from: &mut HashMap<Point, HashMap<Point, usize>>,
195) -> Vec<(AbstractNode, usize)> {
196 let from = match node_point(map, node, request) {
197 Some(point) => point,
198 None => return Vec::new(),
199 };
200
201 let distances = distances_from
202 .entry(from)
203 .or_insert_with(|| bfs_distances(&map.grid, from));
204
205 let mut targets: Vec<(AbstractNode, Point)> = Vec::new();
206 match node {
207 AbstractNode::Start => {
208 for (index, &subgoal) in map.subgoals.iter().enumerate() {
209 targets.push((AbstractNode::Subgoal(index), subgoal));
210 }
211 targets.push((AbstractNode::Goal, request.goal));
212 }
213 AbstractNode::Subgoal(index) => {
214 for (j, &subgoal) in map.subgoals.iter().enumerate() {
215 if j != index {
216 targets.push((AbstractNode::Subgoal(j), subgoal));
217 }
218 }
219 targets.push((AbstractNode::Goal, request.goal));
220 }
221 AbstractNode::Goal => return Vec::new(),
222 }
223
224 let mut out = Vec::new();
225 for (next, to) in targets {
226 if let Some(cost) = distances.get(&to).copied() {
227 out.push((next, cost));
228 }
229 }
230 out
231}
232
233fn reconstruct_path(
234 map: &PreparedSubgoalGraph,
235 request: SearchRequest,
236 parent: &HashMap<AbstractNode, AbstractNode>,
237) -> Option<Vec<Point>> {
238 let mut chain = vec![AbstractNode::Goal];
239 let mut current = AbstractNode::Goal;
240 while !matches!(current, AbstractNode::Start) {
241 current = *parent.get(¤t)?;
242 chain.push(current);
243 }
244 chain.reverse();
245
246 let mut points = Vec::new();
247 for window in chain.windows(2) {
248 let from = node_point(map, window[0], request)?;
249 let to = node_point(map, window[1], request)?;
250 let segment = bfs_path(&map.grid, from, to)?;
251 if points.is_empty() {
252 points = segment;
253 } else {
254 points.extend(segment.into_iter().skip(1));
255 }
256 }
257 Some(points)
258}
259
260fn node_point(
261 map: &PreparedSubgoalGraph,
262 node: AbstractNode,
263 request: SearchRequest,
264) -> Option<Point> {
265 match node {
266 AbstractNode::Start => Some(request.start),
267 AbstractNode::Goal => Some(request.goal),
268 AbstractNode::Subgoal(index) => map.subgoals.get(index).copied(),
269 }
270}
271
272fn select_corner_subgoals(grid: &Grid) -> Vec<Point> {
274 let mut subgoals = Vec::new();
275 let width = grid.width();
276 let height = grid.height();
277
278 for y in 0..height {
279 for x in 0..width {
280 let point = Point::new(x, y);
281 if grid.is_walkable(point) && is_free_space_corner(grid, point) {
282 subgoals.push(point);
283 }
284 }
285 }
286
287 subgoals
288}
289
290fn is_free_space_corner(grid: &Grid, point: Point) -> bool {
291 let dirs = [
294 (1isize, 0isize),
295 (1, 1),
296 (0, 1),
297 (-1, 1),
298 (-1, 0),
299 (-1, -1),
300 (0, -1),
301 (1, -1),
302 ];
303
304 for i in (0..8).step_by(2) {
305 let (c1x, c1y) = dirs[i];
306 let (dx, dy) = dirs[(i + 1) % 8];
307 let (c2x, c2y) = dirs[(i + 2) % 8];
308 let cardinal_a = offset_point(point, c1x, c1y);
309 let diagonal = offset_point(point, dx, dy);
310 let cardinal_b = offset_point(point, c2x, c2y);
311
312 if is_free(grid, cardinal_a)
313 && is_free(grid, cardinal_b)
314 && is_blocked_or_oob(grid, diagonal)
315 {
316 return true;
317 }
318 }
319
320 false
321}
322
323fn offset_point(point: Point, dx: isize, dy: isize) -> Option<Point> {
324 let x = point.x as isize + dx;
325 let y = point.y as isize + dy;
326 if x < 0 || y < 0 {
327 return None;
328 }
329 Some(Point::new(x as usize, y as usize))
330}
331
332fn is_free(grid: &Grid, point: Option<Point>) -> bool {
333 point.is_some_and(|p| grid.index_of(p).is_some() && grid.is_walkable(p))
334}
335
336fn is_blocked_or_oob(grid: &Grid, point: Option<Point>) -> bool {
337 match point {
338 None => true,
339 Some(p) => grid.index_of(p).is_none() || !grid.is_walkable(p),
340 }
341}
342
343fn ensure_uniform_traversal_costs(grid: &Grid) -> Result<(), PreprocessedGridBuildError> {
344 for y in 0..grid.height() {
345 for x in 0..grid.width() {
346 let point = Point::new(x, y);
347 if let Some(cost) = grid.traversal_cost(point)
348 && cost != 1
349 {
350 return Err(PreprocessedGridBuildError::NonUniformCost {
351 algorithm: "subgoal-graph",
352 point,
353 cost,
354 });
355 }
356 }
357 }
358 Ok(())
359}
360
361fn bfs_distances(grid: &Grid, start: Point) -> HashMap<Point, usize> {
362 let mut distances = HashMap::new();
363 if !grid.is_walkable(start) {
364 return distances;
365 }
366 let mut queue = VecDeque::from([start]);
367 distances.insert(start, 0usize);
368
369 while let Some(current) = queue.pop_front() {
370 let current_cost = distances[¤t];
371 for neighbor in four_neighbors(current) {
372 if grid.index_of(neighbor).is_none() || !grid.is_walkable(neighbor) {
373 continue;
374 }
375 if distances.contains_key(&neighbor) {
376 continue;
377 }
378 distances.insert(neighbor, current_cost + 1);
379 queue.push_back(neighbor);
380 }
381 }
382
383 distances
384}
385
386fn bfs_path(grid: &Grid, start: Point, goal: Point) -> Option<Vec<Point>> {
387 if start == goal {
388 return Some(vec![start]);
389 }
390 if !grid.is_walkable(start) || !grid.is_walkable(goal) {
391 return None;
392 }
393
394 let mut parent: HashMap<Point, Point> = HashMap::new();
395 let mut queue = VecDeque::from([start]);
396 parent.insert(start, start);
397
398 while let Some(current) = queue.pop_front() {
399 if current == goal {
400 break;
401 }
402 for neighbor in four_neighbors(current) {
403 if grid.index_of(neighbor).is_none() || !grid.is_walkable(neighbor) {
404 continue;
405 }
406 if parent.contains_key(&neighbor) {
407 continue;
408 }
409 parent.insert(neighbor, current);
410 queue.push_back(neighbor);
411 }
412 }
413
414 if !parent.contains_key(&goal) {
415 return None;
416 }
417
418 let mut path = vec![goal];
419 let mut cursor = goal;
420 while cursor != start {
421 cursor = parent[&cursor];
422 path.push(cursor);
423 }
424 path.reverse();
425 Some(path)
426}
427
428fn four_neighbors(point: Point) -> [Point; 4] {
429 [
430 Point::new(point.x.wrapping_sub(1), point.y),
431 Point::new(point.x + 1, point.y),
432 Point::new(point.x, point.y.wrapping_sub(1)),
433 Point::new(point.x, point.y + 1),
434 ]
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::{Cell, SearchRequest};
441
442 #[test]
443 fn subgoal_graph_matches_astar_on_simple_gap() {
444 let mut grid = Grid::new(7, 5).expect("grid");
445 for y in 0..5 {
446 if y != 2 {
447 grid.set_cell(Point::new(3, y), Cell::Blocked)
448 .expect("valid grid edit");
449 }
450 }
451
452 let prepared = SubgoalGraphBuilder
453 .preprocess(&grid)
454 .expect("preprocess should succeed");
455 let request = SearchRequest::new(Point::new(0, 2), Point::new(6, 2));
456 let subgoal = prepared.search(request);
457 let exact = AStar.search(&grid, request);
458
459 assert!(subgoal.as_ref().expect("valid search request").is_found());
460 assert_eq!(
461 subgoal.as_ref().expect("valid search request").cost(),
462 exact.as_ref().expect("valid search request").cost()
463 );
464 }
465
466 #[test]
467 fn subgoal_graph_rejects_weighted_grids() {
468 let mut grid = Grid::new(4, 3).expect("grid dimensions are valid");
469 grid.set_traversal_cost(Point::new(1, 1), 3)
470 .expect("cost should be valid");
471
472 let error = SubgoalGraphBuilder
473 .preprocess(&grid)
474 .expect_err("weighted grid should fail");
475 assert!(error.to_string().contains("uniform"));
476 }
477
478 #[test]
479 fn subgoal_graph_queries_an_immutable_snapshot() {
480 let mut grid = Grid::new(5, 1).expect("grid dimensions are valid");
481 let request = SearchRequest::new(Point::new(0, 0), Point::new(4, 0));
482 let prepared = SubgoalGraphBuilder
483 .preprocess(&grid)
484 .expect("preprocess should succeed");
485 grid.set_cell(Point::new(2, 0), Cell::Blocked)
486 .expect("cell should be in bounds");
487
488 let prepared_result = prepared.search(request).expect("valid search request");
489 let changed = AStar.search(&grid, request).expect("valid search request");
490
491 assert!(prepared_result.is_found());
492 assert_eq!(prepared_result.path().map(|path| path.cost()), Some(4));
493 assert!(!changed.is_found());
494 }
495
496 #[test]
497 fn subgoal_graph_matches_astar_on_a_uniform_obstacle_course() {
498 let mut grid = Grid::new(7, 5).expect("grid dimensions are valid");
499 for y in 0..5 {
500 if y != 2 {
501 grid.set_cell(Point::new(3, y), Cell::Blocked)
502 .expect("fixture point is in bounds");
503 }
504 }
505 let request = SearchRequest::new(Point::new(0, 2), Point::new(6, 2));
506
507 let prepared = SubgoalGraphBuilder
508 .preprocess(&grid)
509 .expect("subgoal preprocessing succeeds");
510 let prepared_result = prepared.search(request).expect("request is valid");
511 let exact = AStar.search(&grid, request).expect("request is valid");
512
513 assert_eq!(prepared_result.cost(), exact.cost());
514 assert_eq!(prepared_result.is_found(), exact.is_found());
515 assert!(
516 prepared_result
517 .path()
518 .is_some_and(|path| prepared.grid().path_is_walkable(path.steps()))
519 );
520 }
521}