Skip to main content

condor_geometry/
shortest_path_map.rs

1//! Source-rooted shortest-path maps for repeated polygonal goal queries.
2//!
3//! # Lifecycle
4//!
5//! This is the prepared surface for continuous free space with a **fixed
6//! source** (not the online pair-search [`crate::continuous::PolygonPathfinder`]
7//! trait):
8//!
9//! 1. [`PolygonShortestPathMapBuilder::preprocess`] validates the source via
10//!    [`PolygonScene::validate_source`](crate::polygonal::PolygonScene::validate_source)
11//!    and builds a prepared map (visibility-graph distances from that source).
12//! 2. [`PolygonShortestPathMap::query`] answers many goals without rebuilding
13//!    the source-rooted structure, stitching the best terminal vertex to the
14//!    goal with a final walkable segment.
15//!
16//! Cost and exactness match the online visibility-graph solver under the same
17//! free-space predicates. Arbitrary changing start–goal pairs still use online
18//! pathfinders; this API is many goals / one source only.
19
20use std::{cmp::Ordering, collections::BinaryHeap};
21
22use crate::continuous::{PolygonPath, PolygonSearchResult};
23use crate::polygonal::{Point2, PolygonScene, PolygonValidationError};
24
25const EPSILON: f64 = 1e-9;
26
27/// Prepared map that answers shortest-path queries from a fixed source.
28///
29/// Built once via [`PolygonShortestPathMapBuilder`]; subsequent `query` calls
30/// must use goals in the same scene geometry the map was prepared from.
31pub trait PolygonShortestPathMap {
32    /// Stable algorithm / builder identity for reports and portfolios.
33    fn name(&self) -> &'static str;
34
35    /// Scene-space source the map was rooted at during preprocess.
36    fn source(&self) -> Point2;
37
38    /// Shortest free-space path from [`Self::source`] to `goal`.
39    ///
40    /// Invalid goals (non-traversable free space, including sealed boundary)
41    /// return [`PolygonSearchError::InvalidGoal`](crate::continuous::PolygonSearchError::InvalidGoal).
42    /// Reachable goals return a Euclidean polyline; unreachable valid goals
43    /// return no-path (`NoPath` outcome), not a validation error.
44    fn query(&self, goal: Point2) -> PolygonSearchResult;
45}
46
47/// Preprocesses a polygon scene into a source-rooted shortest-path map.
48///
49/// Implementations own the expensive visibility / distance work so that
50/// [`PolygonShortestPathMap::query`] stays cheap relative to online pair search.
51pub trait PolygonShortestPathMapBuilder {
52    /// Prepared map type produced by this builder.
53    type Map: PolygonShortestPathMap;
54
55    /// Stable builder identity (usually matches the prepared map name).
56    fn name(&self) -> &'static str;
57
58    /// Build a map rooted at `source` for repeated goal queries on `scene`.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`PolygonShortestPathMapBuildError`] when static scene geometry
63    /// or the source endpoint fails validation (including sealed-boundary sources).
64    fn preprocess(
65        &self,
66        scene: &PolygonScene,
67        source: Point2,
68    ) -> Result<Self::Map, PolygonShortestPathMapBuildError>;
69}
70
71/// Preprocess failed before a prepared map could be produced.
72#[derive(Debug, Clone, PartialEq, thiserror::Error)]
73#[non_exhaustive]
74pub enum PolygonShortestPathMapBuildError {
75    /// Scene or source rejected by [`PolygonScene::validate_source`].
76    #[error("invalid source-rooted polygon scene: {source}")]
77    InvalidScene {
78        #[from]
79        source: PolygonValidationError,
80    },
81}
82
83/// Visibility-graph builder for source-rooted continuous shortest-path maps.
84///
85/// Preprocess: nodes = `{source}` ∪ obstacle vertices; undirected walkable
86/// edges with Euclidean cost; multi-target Dijkstra from the source.
87/// Query: minimize `dist[node] + dist(node, goal)` over nodes visible to the goal.
88#[derive(Debug, Clone, Copy, Default)]
89pub struct ContinuousShortestPathMap;
90
91/// Precomputed visibility-graph distances from one fixed source.
92///
93/// Owns a cloned [`PolygonScene`] snapshot plus node distances and predecessor
94/// chains. Thread-safe for concurrent `query` if shared behind a shared reference
95/// (no interior mutability).
96#[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}