1use std::cmp::Ordering;
11use std::collections::{BTreeMap, BinaryHeap, VecDeque};
12
13use crate::{
14 Grid, Path, Point,
15 hierarchical::{HierarchicalGridBuildError, HierarchicalGridBuilder, PreparedHierarchicalGrid},
16 preprocessed_grid::{
17 PreparedGridSearch, PreprocessedGridBuildError, PreprocessedGridBuilder,
18 PreprocessedGridMetadata, metadata_for_grid,
19 },
20 search::{SearchRequest, SearchResult},
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct HPAStarBuilder {
31 cluster_size: usize,
32}
33
34impl HPAStarBuilder {
35 pub fn new(cluster_size: usize) -> Result<Self, HierarchicalGridBuildError> {
39 if cluster_size == 0 {
40 return Err(HierarchicalGridBuildError::InvalidClusterSize);
41 }
42 Ok(Self { cluster_size })
43 }
44}
45
46impl Default for HPAStarBuilder {
47 fn default() -> Self {
48 Self { cluster_size: 10 }
49 }
50}
51
52impl HierarchicalGridBuilder for HPAStarBuilder {
53 type Map = PreparedHPAStar;
54
55 fn name(&self) -> &'static str {
56 "hpa-star"
57 }
58
59 fn preprocess(&self, grid: &Grid) -> Result<Self::Map, HierarchicalGridBuildError> {
60 ensure_uniform_traversal_costs(grid)?;
61
62 let mut abstract_graph = AbstractGraph::new();
63 let width = grid.width();
64 let height = grid.height();
65
66 for y in (0..height).step_by(self.cluster_size) {
67 for x in (0..width).step_by(self.cluster_size) {
68 if x + self.cluster_size < width {
69 let x_left = x + self.cluster_size - 1;
70 let x_right = x + self.cluster_size;
71 let y_max = (y + self.cluster_size).min(height);
72
73 let mut current_run = Vec::new();
74 for yi in y..y_max {
75 let p_left = Point::new(x_left, yi);
76 let p_right = Point::new(x_right, yi);
77 if grid.is_walkable(p_left) && grid.is_walkable(p_right) {
78 current_run.push(yi);
79 } else if !current_run.is_empty() {
80 add_entrances(&mut abstract_graph, x_left, x_right, ¤t_run, true);
81 current_run.clear();
82 }
83 }
84 if !current_run.is_empty() {
85 add_entrances(&mut abstract_graph, x_left, x_right, ¤t_run, true);
86 }
87 }
88
89 if y + self.cluster_size < height {
90 let y_top = y + self.cluster_size - 1;
91 let y_bottom = y + self.cluster_size;
92 let x_max = (x + self.cluster_size).min(width);
93
94 let mut current_run = Vec::new();
95 for xi in x..x_max {
96 let p_top = Point::new(xi, y_top);
97 let p_bottom = Point::new(xi, y_bottom);
98 if grid.is_walkable(p_top) && grid.is_walkable(p_bottom) {
99 current_run.push(xi);
100 } else if !current_run.is_empty() {
101 add_entrances(
102 &mut abstract_graph,
103 y_top,
104 y_bottom,
105 ¤t_run,
106 false,
107 );
108 current_run.clear();
109 }
110 }
111 if !current_run.is_empty() {
112 add_entrances(&mut abstract_graph, y_top, y_bottom, ¤t_run, false);
113 }
114 }
115 }
116 }
117
118 for cy in 0..=((height - 1) / self.cluster_size) {
119 for cx in 0..=((width - 1) / self.cluster_size) {
120 let cluster_entrances: Vec<Point> = abstract_graph
121 .nodes
122 .keys()
123 .filter(|p| p.x / self.cluster_size == cx && p.y / self.cluster_size == cy)
124 .copied()
125 .collect();
126
127 for i in 0..cluster_entrances.len() {
128 for j in (i + 1)..cluster_entrances.len() {
129 let start = cluster_entrances[i];
130 let end = cluster_entrances[j];
131 if let Some(path) =
132 intra_cluster_search(grid, start, end, self.cluster_size)
133 {
134 abstract_graph.add_edge(start, end, path.cost(), path.steps().to_vec());
135 }
136 }
137 }
138 }
139 }
140
141 let entrances_by_cluster = index_entrances_by_cluster(&abstract_graph, self.cluster_size);
142
143 Ok(PreparedHPAStar {
144 grid: grid.clone(),
145 cluster_size: self.cluster_size,
146 abstract_graph,
147 entrances_by_cluster,
148 metadata: metadata_for_grid(
149 grid,
150 <Self as HierarchicalGridBuilder>::name(self),
151 <Self as HierarchicalGridBuilder>::name(self),
152 ),
153 })
154 }
155}
156
157impl PreprocessedGridBuilder for HPAStarBuilder {
158 type Map = PreparedHPAStar;
159
160 fn name(&self) -> &'static str {
161 <Self as HierarchicalGridBuilder>::name(self)
162 }
163
164 fn preprocess(&self, grid: &Grid) -> Result<Self::Map, PreprocessedGridBuildError> {
165 <Self as HierarchicalGridBuilder>::preprocess(self, grid)
166 .map_err(PreprocessedGridBuildError::from)
167 }
168}
169
170fn ensure_uniform_traversal_costs(grid: &Grid) -> Result<(), HierarchicalGridBuildError> {
171 for y in 0..grid.height() {
172 for x in 0..grid.width() {
173 let point = Point::new(x, y);
174 if let Some(cost) = grid.traversal_cost(point)
175 && cost != 1
176 {
177 return Err(HierarchicalGridBuildError::NonUniformCost { point, cost });
178 }
179 }
180 }
181
182 Ok(())
183}
184
185fn add_entrances(
186 graph: &mut AbstractGraph,
187 coord1: usize,
188 coord2: usize,
189 run: &[usize],
190 vertical: bool,
191) {
192 if run.is_empty() {
193 return;
194 }
195
196 let points = entrance_sample_indices(run);
197
198 for r in points {
199 let (p1, p2) = if vertical {
200 (Point::new(coord1, r), Point::new(coord2, r))
201 } else {
202 (Point::new(r, coord1), Point::new(r, coord2))
203 };
204 graph.add_edge(p1, p2, 1, vec![p1, p2]);
205 }
206}
207
208fn entrance_sample_indices(run: &[usize]) -> Vec<usize> {
214 run.to_vec()
215}
216
217pub struct PreparedHPAStar {
223 grid: Grid,
224 cluster_size: usize,
225 abstract_graph: AbstractGraph,
226 entrances_by_cluster: BTreeMap<(usize, usize), Vec<Point>>,
228 metadata: PreprocessedGridMetadata,
229}
230
231impl PreparedHierarchicalGrid for PreparedHPAStar {
232 fn name(&self) -> &'static str {
233 "hpa-star"
234 }
235
236 fn search(&self, request: SearchRequest) -> SearchResult {
237 crate::search::validate_request(&self.grid, request)?;
238 if !self.grid.is_walkable(request.start) || !self.grid.is_walkable(request.goal) {
239 return crate::search::not_found(0);
240 }
241
242 if request.start == request.goal {
243 return crate::search::found(
244 Path::from_steps(vec![request.start]).expect("path contains at least one point"),
245 1,
246 );
247 }
248
249 let mut temp_edges: BTreeMap<Point, Vec<AbstractEdge>> = BTreeMap::new();
251
252 for &p in &[request.start, request.goal] {
253 let cx = p.x / self.cluster_size;
254 let cy = p.y / self.cluster_size;
255 let cluster_entrances = self
256 .entrances_by_cluster
257 .get(&(cx, cy))
258 .map(Vec::as_slice)
259 .unwrap_or(&[]);
260
261 for &entrance in cluster_entrances {
262 if let Some(path) = intra_cluster_search(&self.grid, p, entrance, self.cluster_size)
263 {
264 add_temp_edge(
265 &mut temp_edges,
266 p,
267 entrance,
268 path.cost(),
269 path.steps().to_vec(),
270 );
271 }
272 }
273 }
274
275 if request.start.x / self.cluster_size == request.goal.x / self.cluster_size
276 && request.start.y / self.cluster_size == request.goal.y / self.cluster_size
277 && let Some(path) =
278 intra_cluster_search(&self.grid, request.start, request.goal, self.cluster_size)
279 {
280 add_temp_edge(
281 &mut temp_edges,
282 request.start,
283 request.goal,
284 path.cost(),
285 path.steps().to_vec(),
286 );
287 }
288
289 let watch = crate::search::BudgetWatch::start(request.budget);
290 let (dist, parent_map) = match abstract_search(
291 &self.abstract_graph,
292 &temp_edges,
293 request.start,
294 request.goal,
295 &watch,
296 ) {
297 Ok(outcome) => outcome,
298 Err(reason) => return Err(crate::search::budget_error(reason)),
299 };
300
301 if dist == f64::INFINITY {
302 return crate::search::not_found(parent_map.len());
303 }
304
305 let visited_nodes = parent_map.len();
306
307 let mut abstract_path = Vec::new();
308 let mut curr = request.goal;
309 while curr != request.start {
310 let prev = parent_map[&curr];
311 abstract_path.push((prev, curr));
312 curr = prev;
313 }
314 abstract_path.reverse();
315
316 let mut concrete_steps = Vec::new();
317 concrete_steps.push(request.start);
318 for (u, v) in abstract_path {
319 let edge_path = temp_edge_path(&temp_edges, u, v)
320 .or_else(|| self.abstract_graph.get_edge_path(u, v))
321 .expect("abstract parent edge must exist in base or temp edges");
322 let skip = if concrete_steps.last() == Some(&edge_path[0]) {
324 1
325 } else {
326 0
327 };
328 concrete_steps.extend(edge_path.iter().skip(skip));
329 }
330
331 crate::search::found(
332 Path::from_steps(concrete_steps).expect("path contains at least one point"),
333 visited_nodes,
334 )
335 }
336}
337
338impl PreparedGridSearch for PreparedHPAStar {
339 fn name(&self) -> &'static str {
340 <Self as PreparedHierarchicalGrid>::name(self)
341 }
342
343 fn grid(&self) -> &Grid {
344 &self.grid
345 }
346
347 fn metadata(&self) -> &PreprocessedGridMetadata {
348 &self.metadata
349 }
350
351 fn search(&self, request: SearchRequest) -> SearchResult {
352 <Self as PreparedHierarchicalGrid>::search(self, request)
353 }
354}
355
356#[derive(Clone)]
357struct AbstractGraph {
358 nodes: BTreeMap<Point, Vec<AbstractEdge>>,
359}
360
361#[derive(Clone)]
362struct AbstractEdge {
363 to: Point,
364 cost: usize,
365 path: Vec<Point>,
366}
367
368impl AbstractGraph {
369 fn new() -> Self {
370 Self {
371 nodes: BTreeMap::new(),
372 }
373 }
374
375 fn add_edge(&mut self, from: Point, to: Point, cost: usize, path: Vec<Point>) {
376 self.nodes.entry(from).or_default().push(AbstractEdge {
377 to,
378 cost,
379 path: path.clone(),
380 });
381 let mut rev_path = path;
382 rev_path.reverse();
383 self.nodes.entry(to).or_default().push(AbstractEdge {
384 to: from,
385 cost,
386 path: rev_path,
387 });
388 }
389
390 fn get_edge_path(&self, from: Point, to: Point) -> Option<&[Point]> {
391 self.nodes
392 .get(&from)?
393 .iter()
394 .find(|e| e.to == to)
395 .map(|e| e.path.as_slice())
396 }
397}
398
399fn intra_cluster_search(
400 grid: &Grid,
401 start: Point,
402 goal: Point,
403 cluster_size: usize,
404) -> Option<Path> {
405 let cx = start.x / cluster_size;
406 let cy = start.y / cluster_size;
407
408 let mut frontier = VecDeque::from([start]);
409 let mut parents = BTreeMap::from([(start, None)]);
410
411 while let Some(curr) = frontier.pop_front() {
412 if curr == goal {
413 let mut steps = Vec::new();
414 let mut c = Some(curr);
415 while let Some(p) = c {
416 steps.push(p);
417 c = parents[&p];
418 }
419 steps.reverse();
420 return Some(Path::from_steps(steps).expect("path contains at least one point"));
421 }
422
423 for next in grid.neighbors4(curr) {
424 if next.x / cluster_size == cx
425 && next.y / cluster_size == cy
426 && let std::collections::btree_map::Entry::Vacant(e) = parents.entry(next)
427 {
428 e.insert(Some(curr));
429 frontier.push_back(next);
430 }
431 }
432 }
433 None
434}
435
436fn index_entrances_by_cluster(
437 graph: &AbstractGraph,
438 cluster_size: usize,
439) -> BTreeMap<(usize, usize), Vec<Point>> {
440 let mut by_cluster: BTreeMap<(usize, usize), Vec<Point>> = BTreeMap::new();
441 for &p in graph.nodes.keys() {
442 by_cluster
443 .entry((p.x / cluster_size, p.y / cluster_size))
444 .or_default()
445 .push(p);
446 }
447 by_cluster
448}
449
450fn add_temp_edge(
451 temp_edges: &mut BTreeMap<Point, Vec<AbstractEdge>>,
452 from: Point,
453 to: Point,
454 cost: usize,
455 path: Vec<Point>,
456) {
457 temp_edges.entry(from).or_default().push(AbstractEdge {
458 to,
459 cost,
460 path: path.clone(),
461 });
462 let mut rev_path = path;
463 rev_path.reverse();
464 temp_edges.entry(to).or_default().push(AbstractEdge {
465 to: from,
466 cost,
467 path: rev_path,
468 });
469}
470
471fn temp_edge_path(
472 temp_edges: &BTreeMap<Point, Vec<AbstractEdge>>,
473 from: Point,
474 to: Point,
475) -> Option<&[Point]> {
476 temp_edges
477 .get(&from)?
478 .iter()
479 .find(|e| e.to == to)
480 .map(|e| e.path.as_slice())
481}
482
483fn abstract_search(
484 graph: &AbstractGraph,
485 temp_edges: &BTreeMap<Point, Vec<AbstractEdge>>,
486 start: Point,
487 goal: Point,
488 watch: &crate::search::BudgetWatch,
489) -> Result<(f64, BTreeMap<Point, Point>), crate::search::BudgetExhausted> {
490 let mut distances = BTreeMap::new();
491 let mut parents = BTreeMap::new();
492 let mut frontier = BinaryHeap::new();
493 let mut expansions = 0usize;
494
495 distances.insert(start, 0.0);
496 frontier.push(AbstractHeapEntry {
497 point: start,
498 cost: 0.0,
499 });
500
501 while let Some(entry) = frontier.pop() {
502 if entry.point == goal {
503 return Ok((entry.cost, parents));
504 }
505
506 if entry.cost > *distances.get(&entry.point).unwrap_or(&f64::INFINITY) {
507 continue;
508 }
509
510 expansions += 1;
511 watch.check(expansions)?;
512
513 let base = graph
514 .nodes
515 .get(&entry.point)
516 .map(Vec::as_slice)
517 .unwrap_or(&[]);
518 let temp = temp_edges
519 .get(&entry.point)
520 .map(Vec::as_slice)
521 .unwrap_or(&[]);
522 for edge in base.iter().chain(temp.iter()) {
523 let next_dist = entry.cost + edge.cost as f64;
524 if next_dist < *distances.get(&edge.to).unwrap_or(&f64::INFINITY) {
525 distances.insert(edge.to, next_dist);
526 parents.insert(edge.to, entry.point);
527 frontier.push(AbstractHeapEntry {
528 point: edge.to,
529 cost: next_dist,
530 });
531 }
532 }
533 }
534
535 Ok((f64::INFINITY, parents))
536}
537
538#[derive(PartialEq)]
539struct AbstractHeapEntry {
540 point: Point,
541 cost: f64,
542}
543
544impl Eq for AbstractHeapEntry {}
545
546impl Ord for AbstractHeapEntry {
547 fn cmp(&self, other: &Self) -> Ordering {
548 other
549 .cost
550 .partial_cmp(&self.cost)
551 .unwrap_or(Ordering::Equal)
552 }
553}
554
555impl PartialOrd for AbstractHeapEntry {
556 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
557 Some(self.cmp(other))
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::{AStar, Cell, Pathfinder, PreparedGridSearch, PreprocessedGridBuilder};
565
566 #[test]
567 fn neutral_adapter_preprocesses_through_the_preprocessed_contract() {
568 let mut grid = Grid::new(6, 4).expect("grid dimensions are valid");
569 grid.set_cell(Point::new(1, 1), Cell::Blocked)
570 .expect("point is in bounds");
571 grid.set_cell(Point::new(4, 2), Cell::Blocked)
572 .expect("point is in bounds");
573
574 let builder = HPAStarBuilder::new(3).expect("cluster size is positive");
575 let prepared = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid)
576 .expect("HPA* should preprocess through neutral contract");
577
578 assert_eq!(
579 <HPAStarBuilder as PreprocessedGridBuilder>::name(&builder),
580 "hpa-star"
581 );
582 assert_eq!(PreparedGridSearch::name(&prepared), "hpa-star");
583 let metadata = *prepared.metadata();
584 assert_eq!(metadata.builder_name, "hpa-star");
585 assert_eq!(metadata.query_algorithm, "hpa-star");
586 assert_eq!(metadata.width, 6);
587 assert_eq!(metadata.height, 4);
588 assert_eq!(metadata.cell_count, 24);
589 assert_eq!(metadata.walkable_cell_count, 22);
590 assert_eq!(metadata.blocked_cell_count, 2);
591 assert_eq!(metadata.movement_model, "4-way");
592 assert_eq!(metadata.cost_model, "uniform");
593
594 let request = SearchRequest::new(Point::new(0, 0), Point::new(5, 3));
595 let result = PreparedGridSearch::search(&prepared, request).expect("valid request");
596 assert!(result.is_found());
597 assert_eq!(*prepared.metadata(), metadata);
598 assert!(result.stats().visited_nodes > 0);
599 let path = result.path().expect("path should exist");
600 assert_eq!(path.start(), request.start);
601 assert_eq!(path.goal(), request.goal);
602 assert!(prepared.grid().path_is_walkable(path.steps()));
603 }
604
605 #[test]
606 fn neutral_adapter_matches_the_hierarchical_entrypoint() {
607 let grid = Grid::new(7, 3).expect("grid dimensions are valid");
608 let request = SearchRequest::new(Point::new(0, 1), Point::new(6, 1));
609 let builder = HPAStarBuilder::new(2).expect("cluster size is positive");
610
611 let neutral = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid)
612 .expect("HPA* should preprocess through neutral contract");
613 let hierarchical = <HPAStarBuilder as HierarchicalGridBuilder>::preprocess(&builder, &grid)
614 .expect("HPA* should preprocess through hierarchical contract");
615
616 assert_eq!(
617 PreparedGridSearch::search(&neutral, request),
618 PreparedHierarchicalGrid::search(&hierarchical, request)
619 );
620 }
621
622 #[test]
623 fn neutral_adapter_rejects_weighted_grids_before_reporting_unit_cost_paths() {
624 let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
625 grid.set_traversal_cost(Point::new(1, 0), 5)
626 .expect("walkable weighted cell should accept positive cost");
627 let builder = HPAStarBuilder::new(1).expect("cluster size is positive");
628
629 let neutral_error =
630 match <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid) {
631 Ok(_) => panic!("neutral HPA* adapter should reject weighted grids"),
632 Err(error) => error,
633 };
634 assert_eq!(
635 neutral_error,
636 PreprocessedGridBuildError::Hierarchical(HierarchicalGridBuildError::NonUniformCost {
637 point: Point::new(1, 0),
638 cost: 5,
639 })
640 );
641
642 let hierarchical_error =
643 match <HPAStarBuilder as HierarchicalGridBuilder>::preprocess(&builder, &grid) {
644 Ok(_) => panic!("hierarchical HPA* builder should reject weighted grids"),
645 Err(error) => error,
646 };
647 assert_eq!(
648 hierarchical_error,
649 HierarchicalGridBuildError::NonUniformCost {
650 point: Point::new(1, 0),
651 cost: 5,
652 }
653 );
654 }
655
656 #[test]
657 fn neutral_adapter_returns_normal_not_found_result() {
658 let mut grid = Grid::new(5, 1).expect("grid dimensions are valid");
659 grid.set_cell(Point::new(2, 0), Cell::Blocked)
660 .expect("point is in bounds");
661
662 let builder = HPAStarBuilder::new(2).expect("cluster size is positive");
663 let prepared = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid)
664 .expect("HPA* should preprocess through neutral contract");
665 let result = PreparedGridSearch::search(
666 &prepared,
667 SearchRequest::new(Point::new(0, 0), Point::new(4, 0)),
668 )
669 .expect("valid request");
670
671 assert!(!result.is_found());
672 assert!(result.path().is_none());
673 assert_eq!(result.path().map(|path| path.cost()), None);
674 }
675
676 #[test]
677 fn neutral_adapter_preserves_the_online_pathfinder_surface() {
678 let grid = Grid::new(3, 1).expect("grid dimensions are valid");
679 let request = SearchRequest::new(Point::new(0, 0), Point::new(2, 0));
680 let online = AStar.search(&grid, request).expect("valid request");
681 let prepared = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(
682 &HPAStarBuilder::new(1).expect("cluster size is positive"),
683 &grid,
684 )
685 .expect("HPA* should preprocess through neutral contract");
686 let prepared_result =
687 PreparedGridSearch::search(&prepared, request).expect("valid request");
688
689 assert!(online.is_found());
690 assert!(prepared_result.is_found());
691 assert_eq!(
692 online.path().map(|path| path.cost()),
693 prepared_result.path().map(|path| path.cost())
694 );
695 }
696}