Skip to main content

condor_grid/algorithms/
jps_plus.rs

1//! Prepared-grid [`PreparedGridSearch`]: cardinal JPS+.
2//!
3//! [`JpsPlusBuilder`] builds a durable per-direction jump table; [`PreparedJpsPlus`]
4//! answers repeated requests with the standard invalid/found/no-path outcome without
5//! rescanning rays. Jump-edge cost sums per-cell [`traversal_cost`](crate::Grid::traversal_cost)
6//! with a Manhattan heuristic. Prefer it for a static repeated-query map; use
7//! [`super::jump_point_search::JumpPointSearch`] for one-shot or changing maps.
8
9use std::cmp::Ordering;
10use std::collections::BinaryHeap;
11
12use crate::{
13    Grid, Path, Point,
14    algorithms::jps_cardinal::{
15        Direction, PointKind, classify_point_kind, manhattan_distance, reconstruct_jump_path, step,
16    },
17    preprocessed_grid::{
18        PreparedGridSearch, PreprocessedGridBuildError, PreprocessedGridBuilder,
19        PreprocessedGridMetadata, metadata_for_grid,
20    },
21    search::{SearchRequest, SearchResult},
22};
23
24/// [`PreprocessedGridBuilder`] for cardinal JPS+.
25///
26/// Builds a durable jump table so queries avoid rescanning rays. Weighted cost model
27/// matches online [`super::jump_point_search::JumpPointSearch`]. Prefer for multi-query
28/// static grids; online JPS for one-shot or frequently edited maps.
29#[derive(Debug, Clone, Copy, Default)]
30pub struct JpsPlusBuilder;
31
32impl JpsPlusBuilder {
33    /// Creates a JPS+ builder (no configuration knobs in v0).
34    #[must_use]
35    pub const fn new() -> Self {
36        Self
37    }
38}
39
40impl PreprocessedGridBuilder for JpsPlusBuilder {
41    type Map = PreparedJpsPlus;
42
43    fn name(&self) -> &'static str {
44        "jps-plus"
45    }
46
47    fn preprocess(&self, grid: &Grid) -> Result<Self::Map, PreprocessedGridBuildError> {
48        let cell_count = grid.cell_count();
49        let mut jumps = vec![None; cell_count * 4];
50
51        for index in 0..cell_count {
52            let point = grid.point_from_index(index);
53            if !grid.is_walkable(point) {
54                continue;
55            }
56            for (dir_i, direction) in Direction::ALL.iter().enumerate() {
57                jumps[index * 4 + dir_i] = precompute_jump(grid, point, index, *direction);
58            }
59        }
60
61        Ok(PreparedJpsPlus {
62            grid: grid.clone(),
63            jumps,
64            metadata: metadata_for_grid(grid, "jps-plus", "jps-plus"),
65        })
66    }
67}
68
69/// Prepared map implementing [`PreparedGridSearch`] for JPS+.
70///
71/// Immutable after preprocess. Path costs match online
72/// [`JumpPointSearch`](crate::JumpPointSearch) on 4-way weighted packs.
73#[derive(Debug, Clone)]
74pub struct PreparedJpsPlus {
75    grid: Grid,
76    /// `jumps[index * 4 + dir]` = jump stop from this cell in that direction.
77    jumps: Vec<Option<JumpEdge>>,
78    metadata: PreprocessedGridMetadata,
79}
80
81impl PreparedJpsPlus {
82    /// Returns a [`JpsPlusBuilder`] for preprocess entry.
83    #[must_use]
84    pub fn builder() -> JpsPlusBuilder {
85        JpsPlusBuilder::new()
86    }
87}
88
89impl PreparedGridSearch for PreparedJpsPlus {
90    fn name(&self) -> &'static str {
91        "jps-plus"
92    }
93
94    fn grid(&self) -> &Grid {
95        &self.grid
96    }
97
98    fn metadata(&self) -> &PreprocessedGridMetadata {
99        &self.metadata
100    }
101
102    fn search(&self, request: SearchRequest) -> SearchResult {
103        crate::search::validate_request(&self.grid, request)?;
104        search_with_tables(&self.grid, &self.jumps, request)
105    }
106}
107
108#[derive(Debug, Clone, Copy)]
109struct JumpEdge {
110    target_index: usize,
111    edge_cost: usize,
112    edge_weight: usize,
113}
114
115fn precompute_jump(
116    grid: &Grid,
117    start: Point,
118    start_index: usize,
119    direction: Direction,
120) -> Option<JumpEdge> {
121    let mut point = start;
122    let mut index = start_index;
123    let mut edge_cost = 0usize;
124    let mut edge_weight = 0usize;
125
126    loop {
127        let (next_point, next_index) = step(grid, point, index, direction)?;
128        point = next_point;
129        index = next_index;
130        edge_cost = edge_cost.checked_add(1)?;
131        edge_weight = edge_weight.checked_add(grid.traversal_cost(point).unwrap_or(1))?;
132
133        let point_kind = classify_point_kind(grid, point);
134        if point_kind != PointKind::StraightCorridor {
135            return Some(JumpEdge {
136                target_index: index,
137                edge_cost,
138                edge_weight,
139            });
140        }
141    }
142}
143
144fn search_with_tables(
145    grid: &Grid,
146    jumps: &[Option<JumpEdge>],
147    request: SearchRequest,
148) -> SearchResult {
149    let Some(start_index) = grid.index_of(request.start) else {
150        return crate::search::not_found(0);
151    };
152    let Some(goal_index) = grid.index_of(request.goal) else {
153        return crate::search::not_found(0);
154    };
155    if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
156        return crate::search::not_found(0);
157    }
158    if !grid.is_reachable(request.start, request.goal) {
159        return crate::search::not_found(0);
160    }
161    if request.start == request.goal {
162        return crate::search::found(
163            Path::from_steps(vec![request.start]).expect("path contains at least one point"),
164            1,
165        );
166    }
167
168    let initial_heuristic = manhattan_distance(request.start, request.goal);
169    let mut frontier = BinaryHeap::from([FrontierEntry {
170        estimated_total_cost: initial_heuristic,
171        heuristic_cost: initial_heuristic,
172        cost_so_far: 0,
173        index: start_index,
174    }]);
175    let mut best_costs = vec![None; grid.cell_count()];
176    let mut parents = vec![None; grid.cell_count()];
177    let mut visited_nodes = 0usize;
178    let watch = crate::search::BudgetWatch::start(request.budget);
179    best_costs[start_index] = Some(0);
180
181    while let Some(entry) = frontier.pop() {
182        if best_costs[entry.index] != Some(entry.cost_so_far) {
183            continue;
184        }
185        visited_nodes += 1;
186        if entry.index == goal_index {
187            break;
188        }
189
190        if let Err(reason) = watch.check(visited_nodes) {
191            return Err(crate::search::budget_error(reason));
192        }
193
194        let current = grid.point_from_index(entry.index);
195        for (dir_i, direction) in Direction::ALL.iter().enumerate() {
196            let Some(edge) = resolve_jump(
197                grid,
198                jumps,
199                current,
200                entry.index,
201                *direction,
202                dir_i,
203                goal_index,
204            ) else {
205                continue;
206            };
207            let Some(next_cost) = entry.cost_so_far.checked_add(edge.edge_weight) else {
208                continue;
209            };
210            if best_costs[edge.target_index].is_some_and(|best| next_cost >= best) {
211                continue;
212            }
213            best_costs[edge.target_index] = Some(next_cost);
214            parents[edge.target_index] = Some(entry.index);
215            let target = grid.point_from_index(edge.target_index);
216            let heuristic_cost = manhattan_distance(target, request.goal);
217            frontier.push(FrontierEntry {
218                estimated_total_cost: next_cost.saturating_add(heuristic_cost),
219                heuristic_cost,
220                cost_so_far: next_cost,
221                index: edge.target_index,
222            });
223        }
224    }
225
226    if let Some(goal_cost) = best_costs[goal_index] {
227        crate::search::found(
228            reconstruct_jump_path(grid, &parents, start_index, goal_index, goal_cost),
229            visited_nodes,
230        )
231    } else {
232        crate::search::not_found(visited_nodes)
233    }
234}
235
236/// Use precomputed jump, but stop at the goal if it lies on the ray first.
237fn resolve_jump(
238    grid: &Grid,
239    jumps: &[Option<JumpEdge>],
240    start: Point,
241    start_index: usize,
242    direction: Direction,
243    dir_i: usize,
244    goal_index: usize,
245) -> Option<JumpEdge> {
246    // Prefer a shorter on-ray goal jump over the precomputed primary stop.
247    if let Some(to_goal) = jump_toward_goal(grid, start, start_index, direction, goal_index) {
248        if let Some(pre) = jumps[start_index * 4 + dir_i] {
249            if to_goal.edge_cost <= pre.edge_cost {
250                return Some(to_goal);
251            }
252            return Some(pre);
253        }
254        return Some(to_goal);
255    }
256    jumps[start_index * 4 + dir_i]
257}
258
259fn jump_toward_goal(
260    grid: &Grid,
261    start: Point,
262    start_index: usize,
263    direction: Direction,
264    goal_index: usize,
265) -> Option<JumpEdge> {
266    let goal = grid.point_from_index(goal_index);
267    let aligned = match direction {
268        Direction::Left => start.y == goal.y && goal.x < start.x,
269        Direction::Right => start.y == goal.y && goal.x > start.x,
270        Direction::Up => start.x == goal.x && goal.y < start.y,
271        Direction::Down => start.x == goal.x && goal.y > start.y,
272    };
273    if !aligned {
274        return None;
275    }
276
277    let mut point = start;
278    let mut index = start_index;
279    let mut edge_cost = 0usize;
280    let mut edge_weight = 0usize;
281    loop {
282        let (next_point, next_index) = step(grid, point, index, direction)?;
283        point = next_point;
284        index = next_index;
285        edge_cost = edge_cost.checked_add(1)?;
286        edge_weight = edge_weight.checked_add(grid.traversal_cost(point).unwrap_or(1))?;
287        if index == goal_index {
288            return Some(JumpEdge {
289                target_index: index,
290                edge_cost,
291                edge_weight,
292            });
293        }
294    }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298struct FrontierEntry {
299    estimated_total_cost: usize,
300    heuristic_cost: usize,
301    cost_so_far: usize,
302    index: usize,
303}
304
305impl Ord for FrontierEntry {
306    fn cmp(&self, other: &Self) -> Ordering {
307        other
308            .estimated_total_cost
309            .cmp(&self.estimated_total_cost)
310            .then_with(|| other.heuristic_cost.cmp(&self.heuristic_cost))
311            .then_with(|| self.cost_so_far.cmp(&other.cost_so_far))
312            .then_with(|| other.index.cmp(&self.index))
313    }
314}
315
316impl PartialOrd for FrontierEntry {
317    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
318        Some(self.cmp(other))
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::{AStar, JumpPointSearch, Pathfinder, SearchRequest};
326
327    #[test]
328    fn jps_plus_matches_astar_and_online_jps_on_open_field() {
329        let grid = Grid::new(32, 32).unwrap();
330        let prepared = JpsPlusBuilder::new().preprocess(&grid).unwrap();
331        let request = SearchRequest::new(Point::new(0, 0), Point::new(31, 31));
332        let plus = prepared.search(request);
333        let jps = JumpPointSearch.search(&grid, request);
334        let astar = AStar.search(&grid, request);
335        assert!(
336            plus.as_ref().expect("valid search request").is_found()
337                && jps.as_ref().expect("valid search request").is_found()
338                && astar.as_ref().expect("valid search request").is_found()
339        );
340        assert_eq!(
341            plus.as_ref().expect("valid search request").cost(),
342            astar.as_ref().expect("valid search request").cost()
343        );
344        assert_eq!(
345            plus.as_ref().expect("valid search request").cost(),
346            jps.as_ref().expect("valid search request").cost()
347        );
348        assert_eq!(prepared.metadata().builder_name, "jps-plus");
349        assert_eq!(PreparedJpsPlus::builder().name(), "jps-plus");
350    }
351
352    #[test]
353    fn jps_plus_matches_astar_and_online_jps_on_weighted_corridor() {
354        use crate::Cell;
355
356        let mut grid = Grid::new(8, 3).expect("grid dimensions are valid");
357        for x in 0..8 {
358            grid.set_cell(Point::new(x, 0), Cell::Blocked)
359                .expect("top corridor wall should be valid");
360            grid.set_cell(Point::new(x, 2), Cell::Blocked)
361                .expect("bottom corridor wall should be valid");
362        }
363        grid.set_traversal_cost(Point::new(3, 1), 5)
364            .expect("weighted corridor cost should be valid");
365
366        let prepared = JpsPlusBuilder::new()
367            .preprocess(&grid)
368            .expect("weighted grid should preprocess");
369        let request = SearchRequest::new(Point::new(0, 1), Point::new(7, 1));
370        let plus = prepared.search(request).expect("valid search request");
371        let astar = AStar.search(&grid, request).expect("valid search request");
372        let jps = JumpPointSearch
373            .search(&grid, request)
374            .expect("valid search request");
375
376        assert!(plus.is_found());
377        assert_eq!(
378            plus.path().map(|path| path.cost()),
379            astar.path().map(|path| path.cost())
380        );
381        assert_eq!(
382            plus.path().map(|path| path.cost()),
383            jps.path().map(|path| path.cost())
384        );
385    }
386}