Skip to main content

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}