Skip to main content

condor_geometry/
visibility_graph.rs

1//! Exact polygonal pathfinder using a visibility graph over scene vertices.
2//!
3//! # Surface
4//!
5//! Online [`crate::continuous::PolygonPathfinder`] baseline for continuous free
6//! space: no prepared surface—the graph is rebuilt every query. Each search:
7//!
8//! 1. rejects non-walkable endpoints with typed errors;
9//! 2. builds nodes = `{start, goal}` ∪ obstacle vertices;
10//! 3. inserts undirected edges for every pair that passes
11//!    [`PolygonScene::segment_is_walkable`] with Euclidean edge cost;
12//! 4. runs Dijkstra from start to goal.
13//!
14//! # Cost and behavior
15//!
16//! Optimal under Condor's `f64` / epsilon walkability predicates (same cost
17//! contract as TFS). Prefer TFS on sparse pillar forests; denser vertex sets
18//! pay more here for all-pairs visibility tests. For many goals from one fixed
19//! source, use [`crate::shortest_path_map`] instead of re-running this online.
20//!
21//! # Examples
22//!
23//! ```
24//! use condor_geometry::{
25//!     continuous::PolygonPathfinder,
26//!     polygonal::{Point2, PolygonScene, PolygonSearchRequest, WorldBounds},
27//!     visibility_graph::VisibilityGraph,
28//! };
29//!
30//! let scene = PolygonScene {
31//!     world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(3.0, 3.0)),
32//!     obstacles: Vec::new(),
33//! };
34//! let request = PolygonSearchRequest::new(Point2::new(0.5, 0.5), Point2::new(2.5, 2.5));
35//! let result = VisibilityGraph.search(&scene, request).expect("request is valid");
36//! assert!(result.is_found());
37//! ```
38
39use std::cmp::Ordering;
40use std::collections::BinaryHeap;
41
42use crate::continuous::{PolygonPath, PolygonPathfinder, PolygonSearchResult};
43use crate::polygonal::{Point2, PolygonScene, PolygonSearchRequest};
44
45const EPSILON: f64 = 1e-9;
46
47/// Online exact continuous pathfinder: visibility graph + Dijkstra.
48///
49/// **Cost model**: Euclidean polyline length of free-space segments.
50/// **Exactness**: optimal under Condor's `f64` / epsilon walkability predicates
51/// (same contract as topological fracture search).
52///
53/// Invalid start/goal (non-walkable by [`PolygonScene::is_walkable`]) return
54/// [`PolygonSearchError`](crate::continuous::PolygonSearchError). `start == goal` yields a
55/// one-point zero-cost path. Static geometry failures or sealed endpoints that
56/// pass the looser walkability check still produce no-path rather than panic.
57#[derive(Debug, Clone, Copy, Default)]
58pub struct VisibilityGraph;
59
60impl PolygonPathfinder for VisibilityGraph {
61    fn name(&self) -> &'static str {
62        "visibility-graph"
63    }
64
65    /// Exact free-space path via per-query visibility graph + Dijkstra.
66    ///
67    /// See the type-level contract for cost, exactness, and endpoint errors.
68    fn search(&self, scene: &PolygonScene, request: PolygonSearchRequest) -> PolygonSearchResult {
69        if !scene.is_walkable(request.start) {
70            return Err(crate::continuous::PolygonSearchError::InvalidStart {
71                point: request.start,
72            });
73        }
74        if !scene.is_walkable(request.goal) {
75            return Err(crate::continuous::PolygonSearchError::InvalidGoal {
76                point: request.goal,
77            });
78        }
79        if scene.validate(request).is_err() {
80            return crate::continuous::not_found(0);
81        }
82
83        if points_equal(request.start, request.goal) {
84            return crate::continuous::found(
85                PolygonPath::from_points(vec![request.start])
86                    .expect("polygon path contains at least one point"),
87                1,
88            );
89        }
90
91        let nodes = collect_nodes(scene, request);
92        let adjacency = build_visibility_edges(scene, &nodes);
93        let (cost, predecessors, visited_nodes) =
94            match shortest_path(&adjacency, 0, 1, request.budget) {
95                Ok(outcome) => outcome,
96                Err(reason) => return Err(crate::continuous::budget_error(reason)),
97            };
98
99        match cost {
100            Some(goal_cost) => {
101                let points = reconstruct_path(&nodes, &predecessors, 1);
102                crate::continuous::found(
103                    PolygonPath::from_points_with_cost(points, goal_cost)
104                        .expect("polygon path contains at least one point"),
105                    visited_nodes,
106                )
107            }
108            None => crate::continuous::not_found(visited_nodes),
109        }
110    }
111}
112
113fn collect_nodes(scene: &PolygonScene, request: PolygonSearchRequest) -> Vec<Point2> {
114    let mut nodes = vec![request.start, request.goal];
115    for obstacle in &scene.obstacles {
116        for &vertex in obstacle.vertices() {
117            if !nodes.iter().any(|point| points_equal(*point, vertex)) {
118                nodes.push(vertex);
119            }
120        }
121    }
122    nodes
123}
124
125fn build_visibility_edges(scene: &PolygonScene, nodes: &[Point2]) -> Vec<Vec<(usize, f64)>> {
126    let mut adjacency = vec![Vec::new(); nodes.len()];
127
128    for left_index in 0..nodes.len() {
129        for right_index in (left_index + 1)..nodes.len() {
130            let start = nodes[left_index];
131            let end = nodes[right_index];
132            if scene.segment_is_walkable(start, end) {
133                let cost = start.distance_to(end);
134                adjacency[left_index].push((right_index, cost));
135                adjacency[right_index].push((left_index, cost));
136            }
137        }
138    }
139
140    adjacency
141}
142
143type ShortestPathOutcome = (Option<f64>, Vec<Option<usize>>, usize);
144
145fn shortest_path(
146    adjacency: &[Vec<(usize, f64)>],
147    start_index: usize,
148    goal_index: usize,
149    budget: condor_core::SearchBudget,
150) -> Result<ShortestPathOutcome, condor_core::BudgetExhausted> {
151    let mut distances = vec![f64::INFINITY; adjacency.len()];
152    let mut predecessors = vec![None; adjacency.len()];
153    let mut closed = vec![false; adjacency.len()];
154    let mut frontier = BinaryHeap::new();
155    let mut visited_nodes = 0usize;
156    let watch = condor_core::BudgetWatch::start(budget);
157
158    distances[start_index] = 0.0;
159    frontier.push(HeapEntry {
160        node_index: start_index,
161        cost: 0.0,
162    });
163
164    while let Some(entry) = frontier.pop() {
165        if closed[entry.node_index] {
166            continue;
167        }
168
169        closed[entry.node_index] = true;
170        visited_nodes += 1;
171
172        if entry.node_index == goal_index {
173            return Ok((Some(entry.cost), predecessors, visited_nodes));
174        }
175
176        watch.check(visited_nodes)?;
177
178        for &(neighbor_index, edge_cost) in &adjacency[entry.node_index] {
179            if closed[neighbor_index] {
180                continue;
181            }
182
183            let next_cost = entry.cost + edge_cost;
184            if next_cost + EPSILON < distances[neighbor_index] {
185                distances[neighbor_index] = next_cost;
186                predecessors[neighbor_index] = Some(entry.node_index);
187                frontier.push(HeapEntry {
188                    node_index: neighbor_index,
189                    cost: next_cost,
190                });
191            }
192        }
193    }
194
195    Ok((None, predecessors, visited_nodes))
196}
197
198fn reconstruct_path(
199    nodes: &[Point2],
200    predecessors: &[Option<usize>],
201    goal_index: usize,
202) -> Vec<Point2> {
203    let mut reversed = Vec::new();
204    let mut current = Some(goal_index);
205
206    while let Some(index) = current {
207        reversed.push(nodes[index]);
208        current = predecessors[index];
209    }
210
211    reversed.reverse();
212    reversed
213}
214
215fn points_equal(left: Point2, right: Point2) -> bool {
216    (left.x - right.x).abs() <= EPSILON && (left.y - right.y).abs() <= EPSILON
217}
218
219#[derive(Debug, Clone, Copy, PartialEq)]
220struct HeapEntry {
221    node_index: usize,
222    cost: f64,
223}
224
225impl Eq for HeapEntry {}
226
227impl Ord for HeapEntry {
228    fn cmp(&self, other: &Self) -> Ordering {
229        other
230            .cost
231            .total_cmp(&self.cost)
232            .then_with(|| other.node_index.cmp(&self.node_index))
233    }
234}
235
236impl PartialOrd for HeapEntry {
237    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
238        Some(self.cmp(other))
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::VisibilityGraph;
245    use crate::continuous::PolygonPathfinder;
246    use crate::polygonal::{Point2, Polygon, PolygonScene, PolygonSearchRequest, WorldBounds};
247
248    #[test]
249    fn visibility_graph_finds_direct_path_in_open_space() {
250        let scene = PolygonScene {
251            world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
252            obstacles: Vec::new(),
253        };
254        let request = PolygonSearchRequest::new(Point2::new(1.0, 1.0), Point2::new(9.0, 1.0));
255
256        let result = VisibilityGraph.search(&scene, request);
257
258        assert!(result.as_ref().expect("valid search request").is_found());
259        let path = result
260            .as_ref()
261            .expect("valid search request")
262            .path()
263            .expect("path should be present");
264        assert_eq!(path.points(), &[request.start, request.goal]);
265        assert!((path.cost() - 8.0).abs() <= 1e-9);
266    }
267
268    #[test]
269    fn visibility_graph_reports_no_path_for_separator() {
270        let scene = PolygonScene {
271            world_bounds: WorldBounds::new(Point2::new(0.0, 0.0), Point2::new(10.0, 10.0)),
272            obstacles: vec![Polygon::new(vec![
273                Point2::new(4.0, 0.0),
274                Point2::new(6.0, 0.0),
275                Point2::new(6.0, 10.0),
276                Point2::new(4.0, 10.0),
277            ])],
278        };
279        let request = PolygonSearchRequest::new(Point2::new(2.0, 5.0), Point2::new(8.0, 5.0));
280
281        let result = VisibilityGraph.search(&scene, request);
282
283        assert!(!result.as_ref().expect("valid search request").is_found());
284        assert!(
285            result
286                .as_ref()
287                .expect("valid search request")
288                .path()
289                .is_none()
290        );
291        assert_eq!(result.as_ref().expect("valid search request").cost(), None);
292    }
293}