1use 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#[derive(Debug, Clone, Copy, Default)]
30pub struct JpsPlusBuilder;
31
32impl JpsPlusBuilder {
33 #[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#[derive(Debug, Clone)]
74pub struct PreparedJpsPlus {
75 grid: Grid,
76 jumps: Vec<Option<JumpEdge>>,
78 metadata: PreprocessedGridMetadata,
79}
80
81impl PreparedJpsPlus {
82 #[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
236fn 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 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}