1use std::{cmp::Ordering, collections::BinaryHeap};
21
22use crate::continuous::{PolygonPath, PolygonSearchResult};
23use crate::polygonal::{Point2, PolygonScene, PolygonValidationError};
24
25const EPSILON: f64 = 1e-9;
26
27pub trait PolygonShortestPathMap {
32 fn name(&self) -> &'static str;
34
35 fn source(&self) -> Point2;
37
38 fn query(&self, goal: Point2) -> PolygonSearchResult;
45}
46
47pub trait PolygonShortestPathMapBuilder {
52 type Map: PolygonShortestPathMap;
54
55 fn name(&self) -> &'static str;
57
58 fn preprocess(
65 &self,
66 scene: &PolygonScene,
67 source: Point2,
68 ) -> Result<Self::Map, PolygonShortestPathMapBuildError>;
69}
70
71#[derive(Debug, Clone, PartialEq, thiserror::Error)]
73#[non_exhaustive]
74pub enum PolygonShortestPathMapBuildError {
75 #[error("invalid source-rooted polygon scene: {source}")]
77 InvalidScene {
78 #[from]
79 source: PolygonValidationError,
80 },
81}
82
83#[derive(Debug, Clone, Copy, Default)]
89pub struct ContinuousShortestPathMap;
90
91#[derive(Debug, Clone)]
97pub struct PreparedContinuousShortestPathMap {
98 scene: PolygonScene,
99 source: Point2,
100 nodes: Vec<Point2>,
101 distances: Vec<f64>,
102 predecessors: Vec<Option<usize>>,
103}
104
105impl PolygonShortestPathMapBuilder for ContinuousShortestPathMap {
106 type Map = PreparedContinuousShortestPathMap;
107
108 fn name(&self) -> &'static str {
109 "continuous-shortest-path-map"
110 }
111
112 fn preprocess(
113 &self,
114 scene: &PolygonScene,
115 source: Point2,
116 ) -> Result<Self::Map, PolygonShortestPathMapBuildError> {
117 scene.validate_source(source)?;
118
119 let nodes = collect_nodes(scene, source);
120 let adjacency = build_visibility_edges(scene, &nodes);
121 let (distances, predecessors) = shortest_paths_from_source(&adjacency, 0);
122
123 Ok(PreparedContinuousShortestPathMap {
124 scene: scene.clone(),
125 source,
126 nodes,
127 distances,
128 predecessors,
129 })
130 }
131}
132
133impl PolygonShortestPathMap for PreparedContinuousShortestPathMap {
134 fn name(&self) -> &'static str {
135 "continuous-shortest-path-map"
136 }
137
138 fn source(&self) -> Point2 {
139 self.source
140 }
141
142 fn query(&self, goal: Point2) -> PolygonSearchResult {
143 if points_equal(self.source, goal) {
144 return crate::continuous::found(
145 PolygonPath::from_points(vec![self.source])
146 .expect("polygon path contains at least one point"),
147 1,
148 );
149 }
150
151 if self.scene.validate_goal(goal).is_err() {
152 return Err(crate::continuous::PolygonSearchError::InvalidGoal { point: goal });
153 }
154
155 let mut visited_nodes = 0usize;
156 let mut best_terminal = None;
157 let mut best_cost = f64::INFINITY;
158
159 for (node_index, &node) in self.nodes.iter().enumerate() {
160 visited_nodes += 1;
161
162 if !self.distances[node_index].is_finite()
163 || !self.scene.segment_is_walkable(node, goal)
164 {
165 continue;
166 }
167
168 let candidate_cost = self.distances[node_index] + node.distance_to(goal);
169 if candidate_cost + EPSILON < best_cost {
170 best_cost = candidate_cost;
171 best_terminal = Some(node_index);
172 }
173 }
174
175 match best_terminal {
176 Some(node_index) => {
177 let mut points = reconstruct_path(&self.nodes, &self.predecessors, node_index);
178 if !points_equal(
179 *points.last().expect("source-rooted path must be non-empty"),
180 goal,
181 ) {
182 points.push(goal);
183 }
184
185 crate::continuous::found(
186 PolygonPath::from_points_with_cost(points, best_cost)
187 .expect("polygon path contains at least one point"),
188 visited_nodes,
189 )
190 }
191 None => crate::continuous::not_found(visited_nodes),
192 }
193 }
194}
195
196fn collect_nodes(scene: &PolygonScene, source: Point2) -> Vec<Point2> {
197 let mut nodes = vec![source];
198 for obstacle in &scene.obstacles {
199 for &vertex in obstacle.vertices() {
200 if !nodes.iter().any(|point| points_equal(*point, vertex)) {
201 nodes.push(vertex);
202 }
203 }
204 }
205 nodes
206}
207
208fn build_visibility_edges(scene: &PolygonScene, nodes: &[Point2]) -> Vec<Vec<(usize, f64)>> {
209 let mut adjacency = vec![Vec::new(); nodes.len()];
210
211 for left_index in 0..nodes.len() {
212 for right_index in (left_index + 1)..nodes.len() {
213 let start = nodes[left_index];
214 let end = nodes[right_index];
215 if scene.segment_is_walkable(start, end) {
216 let cost = start.distance_to(end);
217 adjacency[left_index].push((right_index, cost));
218 adjacency[right_index].push((left_index, cost));
219 }
220 }
221 }
222
223 adjacency
224}
225
226fn shortest_paths_from_source(
227 adjacency: &[Vec<(usize, f64)>],
228 source_index: usize,
229) -> (Vec<f64>, Vec<Option<usize>>) {
230 let mut distances = vec![f64::INFINITY; adjacency.len()];
231 let mut predecessors = vec![None; adjacency.len()];
232 let mut closed = vec![false; adjacency.len()];
233 let mut frontier = BinaryHeap::new();
234
235 distances[source_index] = 0.0;
236 frontier.push(HeapEntry {
237 node_index: source_index,
238 cost: 0.0,
239 });
240
241 while let Some(entry) = frontier.pop() {
242 if closed[entry.node_index] {
243 continue;
244 }
245
246 closed[entry.node_index] = true;
247
248 for &(neighbor_index, edge_cost) in &adjacency[entry.node_index] {
249 if closed[neighbor_index] {
250 continue;
251 }
252
253 let next_cost = entry.cost + edge_cost;
254 if next_cost + EPSILON < distances[neighbor_index] {
255 distances[neighbor_index] = next_cost;
256 predecessors[neighbor_index] = Some(entry.node_index);
257 frontier.push(HeapEntry {
258 node_index: neighbor_index,
259 cost: next_cost,
260 });
261 }
262 }
263 }
264
265 (distances, predecessors)
266}
267
268fn reconstruct_path(
269 nodes: &[Point2],
270 predecessors: &[Option<usize>],
271 goal_index: usize,
272) -> Vec<Point2> {
273 let mut reversed = Vec::new();
274 let mut current = Some(goal_index);
275
276 while let Some(index) = current {
277 reversed.push(nodes[index]);
278 current = predecessors[index];
279 }
280
281 reversed.reverse();
282 reversed
283}
284
285fn points_equal(left: Point2, right: Point2) -> bool {
286 (left.x - right.x).abs() <= EPSILON && (left.y - right.y).abs() <= EPSILON
287}
288
289#[derive(Debug, Clone, Copy, PartialEq)]
290struct HeapEntry {
291 node_index: usize,
292 cost: f64,
293}
294
295impl Eq for HeapEntry {}
296
297impl Ord for HeapEntry {
298 fn cmp(&self, other: &Self) -> Ordering {
299 other
300 .cost
301 .total_cmp(&self.cost)
302 .then_with(|| other.node_index.cmp(&self.node_index))
303 }
304}
305
306impl PartialOrd for HeapEntry {
307 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
308 Some(self.cmp(other))
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::{ContinuousShortestPathMap, PolygonShortestPathMap, PolygonShortestPathMapBuilder};
315 use crate::{
316 continuous::PolygonPathfinder,
317 polygonal::{Point2, Polygon, PolygonScene, PolygonSearchRequest, WorldBounds},
318 visibility_graph::VisibilityGraph,
319 };
320
321 #[test]
322 fn build_error_wraps_scene_validation_failures() {
323 let scene = PolygonScene {
324 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
325 obstacles: vec![Polygon::new(vec![
326 Point2::new(4.0, 0.0),
327 Point2::new(6.0, 0.0),
328 Point2::new(6.0, 10.0),
329 Point2::new(4.0, 10.0),
330 ])],
331 };
332 let builder = ContinuousShortestPathMap;
333
334 let error = builder
335 .preprocess(&scene, Point2::new(5.0, 0.0))
336 .expect_err("sealed-boundary source should fail");
337
338 assert_eq!(
339 error,
340 crate::shortest_path_map::PolygonShortestPathMapBuildError::InvalidScene {
341 source: crate::polygonal::PolygonValidationError::EndpointNotTraversable {
342 endpoint: crate::polygonal::PolygonEndpoint::Source,
343 point: Point2::new(5.0, 0.0),
344 },
345 }
346 );
347 }
348
349 #[test]
350 fn source_rooted_map_surface_supports_repeated_goal_queries() {
351 let scene = PolygonScene {
352 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
353 obstacles: Vec::new(),
354 };
355 let builder = ContinuousShortestPathMap;
356 let map = builder
357 .preprocess(&scene, Point2::new(1.0, 1.0))
358 .expect("shortest-path map should preprocess");
359
360 assert_eq!(builder.name(), "continuous-shortest-path-map");
361 assert_eq!(map.name(), "continuous-shortest-path-map");
362 assert_eq!(map.source(), Point2::new(1.0, 1.0));
363
364 let first = map.query(Point2::new(5.0, 1.0));
365 let second = map.query(Point2::new(7.0, 4.0));
366
367 assert!(first.as_ref().expect("valid search request").is_found());
368 assert!(second.as_ref().expect("valid search request").is_found());
369 assert_eq!(
370 first
371 .as_ref()
372 .expect("valid search request")
373 .path()
374 .expect("path should be present")
375 .points(),
376 &[Point2::new(1.0, 1.0), Point2::new(5.0, 1.0)]
377 );
378 assert_eq!(
379 second
380 .as_ref()
381 .expect("valid search request")
382 .path()
383 .expect("path should be present")
384 .points(),
385 &[Point2::new(1.0, 1.0), Point2::new(7.0, 4.0)]
386 );
387 }
388
389 #[test]
390 fn continuous_shortest_path_map_reuses_preprocessed_source_for_multiple_goals() {
391 let scene = PolygonScene {
392 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(12.0, 12.0)),
393 obstacles: vec![Polygon::new(vec![
394 Point2::new(4.0, 4.0),
395 Point2::new(6.0, 4.0),
396 Point2::new(6.0, 8.0),
397 Point2::new(4.0, 8.0),
398 ])],
399 };
400 let source = Point2::new(1.0, 1.0);
401 let map = ContinuousShortestPathMap
402 .preprocess(&scene, source)
403 .expect("reusable shortest-path map should preprocess");
404 let baseline = VisibilityGraph;
405
406 for (goal, fixture_name) in [
407 (Point2::new(10.0, 5.0), "multi-goal-bottom-detour"),
408 (Point2::new(10.0, 10.0), "multi-goal-top-detour"),
409 ] {
410 let request = PolygonSearchRequest::new(source, goal);
411 let result = map.query(goal).expect("test request should be valid");
412 let baseline_result = baseline
413 .search(&scene, request)
414 .expect("test request should be valid");
415 let path = result.path().expect("prepared map path should be found");
416 let baseline_path = baseline_result
417 .path()
418 .expect("baseline path should be found");
419
420 assert_eq!(path.points().first(), Some(&source), "{fixture_name}");
421 assert_eq!(path.points().last(), Some(&goal), "{fixture_name}");
422 assert!(
423 path.points()
424 .windows(2)
425 .all(|pair| scene.segment_is_walkable(pair[0], pair[1])),
426 "prepared map path should remain walkable for {fixture_name}"
427 );
428 assert!(
429 (path.cost() - baseline_path.cost()).abs() <= 1e-9,
430 "{} should match {} cost for the {} fixture",
431 map.name(),
432 baseline.name(),
433 fixture_name
434 );
435 }
436 }
437
438 #[test]
439 fn source_rooted_map_reports_no_path_for_separator_goal() {
440 let scene = PolygonScene {
441 world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
442 obstacles: vec![Polygon::new(vec![
443 Point2::new(4.0, 0.0),
444 Point2::new(6.0, 0.0),
445 Point2::new(6.0, 10.0),
446 Point2::new(4.0, 10.0),
447 ])],
448 };
449 let map = ContinuousShortestPathMap
450 .preprocess(&scene, Point2::new(2.0, 5.0))
451 .expect("source should preprocess");
452
453 let result = map.query(Point2::new(8.0, 5.0));
454
455 assert!(!result.as_ref().expect("valid search request").is_found());
456 assert!(
457 result
458 .as_ref()
459 .expect("valid search request")
460 .path()
461 .is_none()
462 );
463 assert_eq!(result.as_ref().expect("valid search request").cost(), None);
464 }
465}