use std::{cmp::Ordering, collections::BinaryHeap};
use crate::{
grid::Grid,
path::Path,
point::Point,
preprocessed_grid::{PreprocessedGridBuildError, PreprocessedGridMetadata, metadata_for_grid},
search::{SearchRequest, SearchResult},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedGoalReverseDistanceFieldBuilder {
goal: Point,
}
impl FixedGoalReverseDistanceFieldBuilder {
pub const CANDIDATE_ID: &str = "static-weighted-grid/fixed-goal-reverse-distance-field";
#[must_use]
pub const fn new(goal: Point) -> Self {
Self { goal }
}
#[must_use]
pub const fn name(&self) -> &'static str {
"fixed-goal-reverse-distance-field"
}
pub fn preprocess(
&self,
grid: &Grid,
) -> Result<PreparedFixedGoalReverseDistanceField, PreprocessedGridBuildError> {
let snapshot = grid.clone();
let (reverse_dist, parents) = build_reverse_field(&snapshot, self.goal);
Ok(PreparedFixedGoalReverseDistanceField {
grid: snapshot,
goal: self.goal,
reverse_dist,
parents,
metadata: metadata_for_grid(
grid,
"fixed-goal-reverse-distance-field",
"fixed-goal-reverse-query",
),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedFixedGoalReverseDistanceField {
grid: Grid,
goal: Point,
reverse_dist: Vec<Option<usize>>,
parents: Vec<Option<usize>>,
metadata: PreprocessedGridMetadata,
}
impl PreparedFixedGoalReverseDistanceField {
#[must_use]
pub const fn goal(&self) -> Point {
self.goal
}
#[must_use]
pub fn name(&self) -> &'static str {
self.metadata.builder_name
}
#[must_use]
pub fn grid(&self) -> &Grid {
&self.grid
}
#[must_use]
pub fn metadata(&self) -> &PreprocessedGridMetadata {
&self.metadata
}
pub fn search(&self, request: SearchRequest) -> SearchResult {
crate::search::validate_request(&self.grid, request)?;
if request.goal != self.goal {
return crate::search::not_found(0);
}
let Some(start_index) = self.grid.index_of(request.start) else {
return crate::search::not_found(0);
};
let Some(goal_index) = self.grid.index_of(self.goal) else {
return crate::search::not_found(0);
};
if request.start == self.goal {
return crate::search::found(
Path::from_steps(vec![request.start]).expect("path contains at least one point"),
1,
);
}
let Some(total_cost) = self.reverse_dist[start_index] else {
return crate::search::not_found(0);
};
crate::search::found(
reconstruct_toward_goal(
&self.grid,
&self.parents,
start_index,
goal_index,
total_cost,
),
1,
)
}
}
fn build_reverse_field(grid: &Grid, goal: Point) -> (Vec<Option<usize>>, Vec<Option<usize>>) {
let cell_count = grid.cell_count();
let mut reverse_dist = vec![None; cell_count];
let mut parents = vec![None; cell_count];
let Some(goal_index) = grid.index_of(goal) else {
return (reverse_dist, parents);
};
if !grid.is_walkable(goal) {
return (reverse_dist, parents);
}
let mut frontier = BinaryHeap::from([FrontierEntry {
cost_so_far: 0,
index: goal_index,
}]);
reverse_dist[goal_index] = Some(0);
while let Some(entry) = frontier.pop() {
if reverse_dist[entry.index] != Some(entry.cost_so_far) {
continue;
}
let current = grid.point_from_index(entry.index);
let leave_cost = grid
.traversal_cost(current)
.expect("walkable reverse source must have a traversal cost");
for neighbor in grid.neighbors4(current) {
let neighbor_index = grid
.index_of(neighbor)
.expect("walkable neighbors must exist inside the grid");
let Some(next_cost) = entry.cost_so_far.checked_add(leave_cost) else {
continue;
};
if reverse_dist[neighbor_index].is_some_and(|best| next_cost >= best) {
continue;
}
reverse_dist[neighbor_index] = Some(next_cost);
parents[neighbor_index] = Some(entry.index);
frontier.push(FrontierEntry {
cost_so_far: next_cost,
index: neighbor_index,
});
}
}
(reverse_dist, parents)
}
fn reconstruct_toward_goal(
grid: &Grid,
parents: &[Option<usize>],
start_index: usize,
goal_index: usize,
total_cost: usize,
) -> Path {
let mut current_index = start_index;
let mut steps = vec![grid.point_from_index(start_index)];
while current_index != goal_index {
current_index = parents[current_index]
.expect("a labeled start must have a complete reverse parent chain to the goal");
steps.push(grid.point_from_index(current_index));
}
Path::from_steps_with_cost(steps, total_cost).expect("path contains at least one point")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FrontierEntry {
cost_so_far: usize,
index: usize,
}
impl Ord for FrontierEntry {
fn cmp(&self, other: &Self) -> Ordering {
other
.cost_so_far
.cmp(&self.cost_so_far)
.then_with(|| other.index.cmp(&self.index))
}
}
impl PartialOrd for FrontierEntry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[cfg(test)]
mod tests {
use crate::{
algorithms::{
dijkstra::Dijkstra,
fixed_goal_reverse_distance_field::FixedGoalReverseDistanceFieldBuilder,
},
grid::{Cell, Grid},
point::Point,
search::{Pathfinder, SearchRequest},
};
#[test]
fn query_cost_matches_dijkstra_from_multiple_starts() {
let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
for y in 0..5 {
if y != 2 {
grid.set_cell(Point::new(2, y), Cell::Blocked)
.expect("valid grid edit");
}
}
let goal = Point::new(4, 4);
let prepared = FixedGoalReverseDistanceFieldBuilder::new(goal)
.preprocess(&grid)
.expect("preprocess succeeds");
for start in [Point::new(0, 0), Point::new(0, 4), Point::new(4, 0)] {
let request = SearchRequest::new(start, goal);
let candidate = prepared.search(request).expect("endpoints walkable");
let baseline = Dijkstra.search(&grid, request).expect("endpoints walkable");
assert!(candidate.is_found(), "start {start:?}");
assert_eq!(candidate.cost(), baseline.cost(), "start {start:?}");
}
}
#[test]
fn query_cost_matches_dijkstra_on_weighted_detour() {
let mut grid = Grid::new(4, 3).expect("grid dimensions are valid");
grid.set_traversal_cost(Point::new(1, 1), 10)
.expect("valid cost edit");
grid.set_traversal_cost(Point::new(2, 1), 10)
.expect("valid cost edit");
let goal = Point::new(3, 1);
let prepared = FixedGoalReverseDistanceFieldBuilder::new(goal)
.preprocess(&grid)
.expect("preprocess succeeds");
for start in [Point::new(0, 1), Point::new(0, 0), Point::new(0, 2)] {
let request = SearchRequest::new(start, goal);
let candidate = prepared.search(request).expect("endpoints walkable");
let baseline = Dijkstra.search(&grid, request).expect("endpoints walkable");
assert!(candidate.is_found(), "start {start:?}");
assert_eq!(
candidate.cost(),
baseline.cost(),
"weighted reverse field must match Dijkstra for start {start:?}"
);
}
}
#[test]
fn reports_when_no_path_exists() {
let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
for x in 0..3 {
grid.set_cell(Point::new(x, 1), Cell::Blocked)
.expect("valid grid edit");
}
let goal = Point::new(2, 2);
let prepared = FixedGoalReverseDistanceFieldBuilder::new(goal)
.preprocess(&grid)
.expect("preprocess succeeds");
let result = prepared
.search(SearchRequest::new(Point::new(0, 0), goal))
.expect("endpoints walkable");
assert!(!result.is_found());
}
#[test]
fn stale_geometry_or_costs_require_rebuild() {
let mut grid = Grid::new(4, 1).expect("grid dimensions are valid");
let goal = Point::new(3, 0);
let prepared = FixedGoalReverseDistanceFieldBuilder::new(goal)
.preprocess(&grid)
.expect("preprocess succeeds");
grid.set_cell(Point::new(1, 0), Cell::Blocked)
.expect("valid grid edit");
let snapshot_answer = prepared
.search(SearchRequest::new(Point::new(0, 0), goal))
.expect("endpoints walkable on snapshot");
assert!(
snapshot_answer.is_found(),
"immutable snapshot still has the old open corridor"
);
assert_eq!(snapshot_answer.cost(), Some(3));
let rebuilt = FixedGoalReverseDistanceFieldBuilder::new(goal)
.preprocess(&grid)
.expect("rebuild succeeds");
let rebuilt_answer = rebuilt
.search(SearchRequest::new(Point::new(0, 0), goal))
.expect("endpoints walkable");
assert!(
!rebuilt_answer.is_found(),
"rebuild observes the new blocked cell"
);
let mismatch = prepared
.search(SearchRequest::new(Point::new(0, 0), Point::new(2, 0)))
.expect("validation uses snapshot endpoints");
assert!(!mismatch.is_found());
}
#[test]
fn retains_candidate_id() {
assert_eq!(
FixedGoalReverseDistanceFieldBuilder::CANDIDATE_ID,
"static-weighted-grid/fixed-goal-reverse-distance-field"
);
}
}