condor_grid/any_angle.rs
1//! Any-angle grid search contracts for continuous endpoints on a blocked [`Grid`].
2//!
3//! [`AnyAnglePathfinder`] implementations run one Euclidean search between mutually
4//! visible grid vertices. Invalid endpoints return [`AnyAngleSearchError`]; valid
5//! but disconnected endpoints return [`SearchOutcome::NoPath`]. Use [`crate::ThetaStar`],
6//! [`crate::LazyThetaStar`], or the curated [`crate::Anya`] entrypoint for one query;
7//! use [`crate::PreparedAnyAngleGrid`] when many exact queries share one static map.
8//! Geometry helpers and exact-oracle internals stay private to this owner crate.
9
10/// Private continuous-on-grid geometry helpers (LOS, vertex snap, edge costs).
11pub(crate) mod geometry;
12
13use std::{error::Error, fmt};
14
15use condor_core::{BudgetExhausted, Point2, SearchBudget};
16
17use crate::{
18 grid::Grid,
19 search::{SearchOutcome, SearchPathCost, SearchVisitStats},
20};
21
22/// Start and goal endpoints for an any-angle grid search.
23///
24/// Validity (grid-aligned free vertices) is checked at search time, not construction.
25/// Optional [`SearchBudget`] caps expansions and/or wall-clock time; default is unlimited.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct AnyAngleSearchRequest {
28 /// Continuous-space start (canonicalized to a grid vertex at search time).
29 pub start: Point2,
30 /// Continuous-space goal (canonicalized to a grid vertex at search time).
31 pub goal: Point2,
32 /// Optional expansion / wall-clock caps for this query (default unlimited).
33 pub budget: SearchBudget,
34}
35
36impl AnyAngleSearchRequest {
37 /// Pairs continuous start and goal with an unlimited budget; validity is checked at search time.
38 #[must_use]
39 pub fn new(start: Point2, goal: Point2) -> Self {
40 Self {
41 start,
42 goal,
43 budget: SearchBudget::UNLIMITED,
44 }
45 }
46
47 /// Returns a copy of this request with the given budget.
48 #[must_use]
49 pub const fn with_budget(mut self, budget: SearchBudget) -> Self {
50 self.budget = budget;
51 self
52 }
53}
54
55/// Non-empty polyline in grid coordinates with Euclidean traversal cost.
56#[derive(Debug, Clone, PartialEq)]
57pub struct AnyAnglePath {
58 points: Vec<Point2>,
59 cost: f64,
60}
61
62/// Error returned when any-angle path construction violates invariants.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
64#[non_exhaustive]
65pub enum AnyAnglePathBuildError {
66 /// `from_points*` require at least one vertex.
67 #[error("any-angle paths must contain at least one point")]
68 Empty,
69}
70
71impl AnyAnglePath {
72 /// Builds a path and sets cost to the sum of consecutive Euclidean segments.
73 ///
74 /// # Errors
75 ///
76 /// Returns [`AnyAnglePathBuildError::Empty`] when `points` is empty.
77 pub fn from_points(points: Vec<Point2>) -> Result<Self, AnyAnglePathBuildError> {
78 if points.is_empty() {
79 return Err(AnyAnglePathBuildError::Empty);
80 }
81 let cost = points
82 .windows(2)
83 .map(|pair| pair[0].distance_to(pair[1]))
84 .sum();
85 Ok(Self { points, cost })
86 }
87
88 /// Builds a path with an explicit cost (callers must ensure consistency with geometry).
89 ///
90 /// # Errors
91 ///
92 /// Returns [`AnyAnglePathBuildError::Empty`] when `points` is empty.
93 pub fn from_points_with_cost(
94 points: Vec<Point2>,
95 cost: f64,
96 ) -> Result<Self, AnyAnglePathBuildError> {
97 if points.is_empty() {
98 return Err(AnyAnglePathBuildError::Empty);
99 }
100 Ok(Self { points, cost })
101 }
102
103 /// Ordered continuous vertices from start through goal (inclusive).
104 #[must_use]
105 pub fn points(&self) -> &[Point2] {
106 &self.points
107 }
108
109 /// Vertex count (always ≥ 1 for constructed paths).
110 #[must_use]
111 pub fn len(&self) -> usize {
112 self.points.len()
113 }
114
115 /// Always `false` for successfully constructed paths (`from_points*` reject empty).
116 #[must_use]
117 pub fn is_empty(&self) -> bool {
118 self.points.is_empty()
119 }
120
121 /// First vertex.
122 ///
123 /// # Panics
124 ///
125 /// Panics if the path is empty (cannot occur for values built via `from_points*`).
126 #[must_use]
127 pub fn start(&self) -> Point2 {
128 self.points[0]
129 }
130
131 /// Last vertex.
132 ///
133 /// # Panics
134 ///
135 /// Panics if the path is empty (cannot occur for values built via `from_points*`).
136 #[must_use]
137 pub fn goal(&self) -> Point2 {
138 self.points[self.points.len() - 1]
139 }
140
141 /// Euclidean polyline length, or the explicit value from `from_points_with_cost`.
142 #[must_use]
143 pub const fn cost(&self) -> f64 {
144 self.cost
145 }
146}
147
148/// Work counters from an any-angle search (algorithm-defined node visits).
149///
150/// Semantics of `visited_nodes` vary by solver (vertex pops vs interval states vs
151/// settled VG nodes) and must not be compared across algorithms without care.
152#[derive(Debug, Clone, Copy, Default, PartialEq)]
153pub struct AnyAngleSearchStats {
154 /// Nodes the solver counts as expanded or settled (see algorithm docs).
155 pub visited_nodes: usize,
156}
157
158/// Invalid any-angle search request or budget hard stop.
159///
160/// Unreachable but valid endpoints produce
161/// [`SearchOutcome::NoPath`], not these variants. Budget exhaustion does not
162/// prove unreachability.
163#[non_exhaustive]
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub enum AnyAngleSearchError {
166 /// Start is not a valid free-space position on the grid.
167 InvalidStart { point: Point2 },
168 /// Goal is not a valid free-space position on the grid.
169 InvalidGoal { point: Point2 },
170 /// Caller search budget was exhausted before found/no-path completed.
171 BudgetExhausted(BudgetExhausted),
172}
173
174impl fmt::Display for AnyAngleSearchError {
175 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176 match self {
177 Self::InvalidStart { point } => write!(formatter, "invalid any-angle start: {point:?}"),
178 Self::InvalidGoal { point } => write!(formatter, "invalid any-angle goal: {point:?}"),
179 Self::BudgetExhausted(reason) => write!(formatter, "{reason}"),
180 }
181 }
182}
183
184impl Error for AnyAngleSearchError {}
185
186/// Validated result of an any-angle search.
187pub type AnyAngleSearchResult =
188 Result<SearchOutcome<AnyAnglePath, AnyAngleSearchStats>, AnyAngleSearchError>;
189
190/// Builds a successful found outcome for owner-crate solvers.
191pub(crate) const fn found(path: AnyAnglePath, visited_nodes: usize) -> AnyAngleSearchResult {
192 Ok(SearchOutcome::found(
193 path,
194 AnyAngleSearchStats { visited_nodes },
195 ))
196}
197
198/// Builds a completed no-path outcome (valid endpoints, no route).
199pub(crate) const fn not_found(visited_nodes: usize) -> AnyAngleSearchResult {
200 Ok(SearchOutcome::no_path(AnyAngleSearchStats {
201 visited_nodes,
202 }))
203}
204
205impl SearchPathCost for AnyAnglePath {
206 type Cost = f64;
207
208 fn path_cost(&self) -> Self::Cost {
209 self.cost()
210 }
211}
212
213impl SearchVisitStats for AnyAngleSearchStats {
214 fn visited_nodes(&self) -> usize {
215 self.visited_nodes
216 }
217}
218
219/// Online any-angle algorithm entrypoint for static blocked grids.
220///
221/// Implementations search continuous coordinates with Euclidean edge costs between
222/// mutually visible points (v0 no-corner-cut LOS, e.g. [`has_line_of_sight`]).
223/// Invalid endpoints or exhausted [`SearchBudget`] return [`AnyAngleSearchError`];
224/// unreachable but valid endpoints return [`SearchOutcome::NoPath`]. Prefer prepared
225/// any-angle for repeated queries.
226pub trait AnyAnglePathfinder {
227 /// Stable algorithm identifier for benchmarks, logs, and solver portfolios.
228 fn name(&self) -> &'static str;
229
230 /// Runs one any-angle search on `grid` for `request`.
231 ///
232 /// Returns `Err` for invalid endpoints or exhausted budgets, `Ok(NoPath)` when
233 /// no route exists, and `Ok(Found)` with a non-empty polyline when a route is found.
234 fn search(&self, grid: &Grid, request: AnyAngleSearchRequest) -> AnyAngleSearchResult;
235}
236
237/// Maps a shared budget failure into [`AnyAngleSearchError::BudgetExhausted`].
238#[doc(hidden)]
239pub const fn budget_error(reason: BudgetExhausted) -> AnyAngleSearchError {
240 AnyAngleSearchError::BudgetExhausted(reason)
241}
242
243/// Returns whether `start` and `end` are mutually visible on `grid`.
244///
245/// Coordinates are canonicalized to grid vertices. The check uses the v0
246/// no-corner-cut geometry contract through an implementation independent from
247/// the oracle's clipping predicate.
248#[must_use]
249pub fn has_line_of_sight(grid: &Grid, start: Point2, end: Point2) -> bool {
250 geometry::sampling_segment_is_legal(grid, start, end)
251}
252
253/// Hidden exports for correctness tests and offline oracle capture.
254#[doc(hidden)]
255pub mod exact_oracle_v0 {
256 pub use crate::algorithms::any_angle_visibility_graph::{
257 AnyAngleOracleDiagnostics, AnyAngleSamplingReferenceOracle, AnyAngleVisibilityGraphOracle,
258 };
259 pub use crate::any_angle::geometry::{
260 approximately_equal, canonicalize_grid_vertex, extract_boundary_edges, is_endpoint_valid,
261 recompute_path_cost, retained_visibility_vertices, sampling_segment_is_legal,
262 segment_is_legal, validate_path, validate_sampling_path,
263 };
264}