condor_geometry/continuous.rs
1//! Continuous polygonal path types and the [`PolygonPathfinder`] search trait.
2//!
3//! Online exact solvers in this owner crate (visibility graph, TFS) share one
4//! contract: free-space geometry in a [`PolygonScene`], Euclidean polyline
5//! cost on [`PolygonPath`], and [`SearchOutcome`] found/no-path with
6//! [`PolygonSearchStats`]. Invalid free-space endpoints are
7//! [`PolygonSearchError`] (`Err`); unreachable but valid endpoints are
8//! `Ok(NoPath)`. Scene geometry validation
9//! ([`PolygonValidationError`](crate::polygonal::PolygonValidationError)) is a
10//! separate static/prep surface—not returned from `search` directly.
11//!
12//! # Prepared alternative
13//!
14//! Source-rooted repeated queries use prepared maps in
15//! [`crate::shortest_path_map`], not this trait.
16
17use crate::polygonal::{Point2, PolygonScene, PolygonSearchRequest};
18use condor_core::{BudgetExhausted, SearchOutcome, SearchPathCost, SearchVisitStats};
19use std::{error::Error, fmt};
20
21/// Polygon path construction failed (empty polyline or invariant violation).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
23#[non_exhaustive]
24pub enum PolygonPathBuildError {
25 /// Caller supplied an empty vertex list; paths require at least one point.
26 #[error("polygon paths must contain at least one point")]
27 Empty,
28}
29
30/// Non-empty free-space polyline in scene coordinates with Euclidean cost.
31#[derive(Debug, Clone, PartialEq)]
32pub struct PolygonPath {
33 points: Vec<Point2>,
34 cost: f64,
35}
36
37impl PolygonPath {
38 /// Builds a path and sets cost to the sum of consecutive Euclidean segments.
39 ///
40 /// # Errors
41 ///
42 /// Returns [`PolygonPathBuildError::Empty`] when `points` is empty.
43 pub fn from_points(points: Vec<Point2>) -> Result<Self, PolygonPathBuildError> {
44 if points.is_empty() {
45 return Err(PolygonPathBuildError::Empty);
46 }
47 let cost = points
48 .windows(2)
49 .map(|pair| pair[0].distance_to(pair[1]))
50 .sum();
51 Ok(Self { points, cost })
52 }
53
54 /// Builds a path with an explicit cost (callers must ensure consistency with the polyline).
55 ///
56 /// # Errors
57 ///
58 /// Returns [`PolygonPathBuildError::Empty`] when `points` is empty.
59 pub fn from_points_with_cost(
60 points: Vec<Point2>,
61 cost: f64,
62 ) -> Result<Self, PolygonPathBuildError> {
63 if points.is_empty() {
64 return Err(PolygonPathBuildError::Empty);
65 }
66 Ok(Self { points, cost })
67 }
68
69 /// Ordered free-space vertices from start through goal (inclusive).
70 #[must_use]
71 pub fn points(&self) -> &[Point2] {
72 &self.points
73 }
74
75 /// Vertex count (always ≥ 1 for constructed paths).
76 #[must_use]
77 pub fn len(&self) -> usize {
78 self.points.len()
79 }
80
81 /// Always `false` for successfully constructed paths (`from_points*` reject empty).
82 #[must_use]
83 pub fn is_empty(&self) -> bool {
84 self.points.is_empty()
85 }
86
87 /// First vertex.
88 ///
89 /// # Panics
90 ///
91 /// Panics if the path is empty (cannot occur for values built via `from_points*`).
92 #[must_use]
93 pub fn start(&self) -> Point2 {
94 self.points
95 .first()
96 .copied()
97 .expect("polygon paths must contain at least one point")
98 }
99
100 /// Last vertex.
101 ///
102 /// # Panics
103 ///
104 /// Panics if the path is empty (cannot occur for values built via `from_points*`).
105 #[must_use]
106 pub fn goal(&self) -> Point2 {
107 self.points
108 .last()
109 .copied()
110 .expect("polygon paths must contain at least one point")
111 }
112
113 /// Euclidean polyline length, or the explicit value from `from_points_with_cost`.
114 #[must_use]
115 pub const fn cost(&self) -> f64 {
116 self.cost
117 }
118}
119
120/// Work counters for polygonal search (typically expanded graph vertices).
121///
122/// `visited_nodes` meaning is solver-specific (visibility nodes vs taut path
123/// keys); comparable within one algorithm, not across solvers.
124#[derive(Debug, Clone, Copy, Default, PartialEq)]
125pub struct PolygonSearchStats {
126 /// Algorithm-defined visit / expansion count for this search (lane-specific).
127 pub visited_nodes: usize,
128}
129
130/// Request rejected before search, or budget hard stop mid-search.
131///
132/// Pathfinder-level endpoint errors and [`Self::BudgetExhausted`]. Unreachable but
133/// walkable endpoints produce [`SearchOutcome::NoPath`], not these variants. Static
134/// scene defects use [`PolygonValidationError`](crate::polygonal::PolygonValidationError)
135/// on `validate*` / preprocess paths instead.
136#[non_exhaustive]
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub enum PolygonSearchError {
139 /// Start fails [`PolygonScene::is_walkable`].
140 InvalidStart {
141 /// Off-free-space start that was rejected.
142 point: Point2,
143 },
144 /// Goal fails [`PolygonScene::is_walkable`].
145 InvalidGoal {
146 /// Off-free-space goal that was rejected.
147 point: Point2,
148 },
149 /// Caller search budget was exhausted before found/no-path completed.
150 BudgetExhausted(BudgetExhausted),
151}
152
153impl fmt::Display for PolygonSearchError {
154 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 Self::InvalidStart { point } => write!(formatter, "invalid polygon start: {point:?}"),
157 Self::InvalidGoal { point } => write!(formatter, "invalid polygon goal: {point:?}"),
158 Self::BudgetExhausted(reason) => write!(formatter, "{reason}"),
159 }
160 }
161}
162
163impl Error for PolygonSearchError {}
164
165/// Polygonal search return type: validation / budget `Err`, or found/no-path with stats.
166///
167/// `Ok(Found)` and `Ok(NoPath)` are completed searches; `Err` is not a proof of
168/// unreachability (invalid endpoints or exhausted [`condor_core::SearchBudget`]).
169pub type PolygonSearchResult =
170 Result<SearchOutcome<PolygonPath, PolygonSearchStats>, PolygonSearchError>;
171
172/// Builds `Ok(Found)` with the given polyline and expansion stats.
173pub(crate) const fn found(path: PolygonPath, visited_nodes: usize) -> PolygonSearchResult {
174 Ok(SearchOutcome::found(
175 path,
176 PolygonSearchStats { visited_nodes },
177 ))
178}
179
180/// Builds `Ok(NoPath)` with expansion stats after exhaustive free-space search.
181pub(crate) const fn not_found(visited_nodes: usize) -> PolygonSearchResult {
182 Ok(SearchOutcome::no_path(PolygonSearchStats { visited_nodes }))
183}
184
185impl SearchPathCost for PolygonPath {
186 type Cost = f64;
187
188 fn path_cost(&self) -> Self::Cost {
189 self.cost()
190 }
191}
192
193impl SearchVisitStats for PolygonSearchStats {
194 fn visited_nodes(&self) -> usize {
195 self.visited_nodes
196 }
197}
198
199/// Online exact pathfinder over a static polygonal obstacle scene.
200///
201/// Pair-search surface for one start–goal query. Implementations reject
202/// endpoints that fail [`PolygonScene::is_walkable`] with [`PolygonSearchError`],
203/// honor optional [`condor_core::SearchBudget`] on the request, and otherwise return
204/// found/no-path with Euclidean polyline cost. Endpoints that pass the looser
205/// walkability check but fail full [`PolygonScene::validate`] (for example sealed
206/// boundary) typically yield `Ok(NoPath)` rather than `Err`—match the baseline
207/// solvers when implementing. For many goals from one fixed source, use
208/// [`crate::shortest_path_map`] instead of this online trait.
209pub trait PolygonPathfinder {
210 /// Stable algorithm identifier for benchmarks, logs, and solver portfolios.
211 fn name(&self) -> &'static str;
212
213 /// Runs one search on `scene` for `request`.
214 ///
215 /// Returns `Err` for non-walkable endpoints or exhausted budgets, `Ok(NoPath)`
216 /// when no free-space route exists (including some sealed-endpoint cases after
217 /// validation), and `Ok(Found)` with a non-empty polyline when a route is found.
218 ///
219 /// # Errors
220 ///
221 /// Returns [`PolygonSearchError`] when an endpoint is non-walkable or the caller
222 /// budget is exhausted before found/no-path completes.
223 fn search(&self, scene: &PolygonScene, request: PolygonSearchRequest) -> PolygonSearchResult;
224}
225
226/// Maps a shared budget failure into [`PolygonSearchError::BudgetExhausted`].
227#[doc(hidden)]
228pub const fn budget_error(reason: BudgetExhausted) -> PolygonSearchError {
229 PolygonSearchError::BudgetExhausted(reason)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::PolygonPath;
235 use crate::polygonal::Point2;
236
237 #[test]
238 fn polygon_path_computes_euclidean_cost_from_points() {
239 let path = PolygonPath::from_points(vec![
240 Point2::new(1.0, 1.0),
241 Point2::new(4.0, 5.0),
242 Point2::new(7.0, 5.0),
243 ])
244 .expect("polygon path contains at least one point");
245
246 assert_eq!(path.start(), Point2::new(1.0, 1.0));
247 assert_eq!(path.goal(), Point2::new(7.0, 5.0));
248 assert!((path.cost() - 8.0).abs() <= 1e-9);
249 assert_eq!(path.len(), 3);
250 assert!(!path.is_empty());
251 }
252}