Skip to main content

condor_grid/algorithms/
theta_star.rs

1//! Any-angle [`AnyAnglePathfinder`]: online Theta*.
2//!
3//! Each grid-aligned-vertex query is independent; no-corner-cut neighbor moves and
4//! parent shortcuts use [`has_line_of_sight`]. Euclidean
5//! segment length is the path cost, with the usual invalid/found/no-path any-angle
6//! outcome. Prefer [`crate::PreparedAnyAngleGrid`] for repeated exact static queries,
7//! or [`super::anya::Anya`] for the curated any-angle entrypoint.
8
9use std::cmp::Ordering;
10use std::collections::BinaryHeap;
11
12use crate::{
13    Grid, Point,
14    any_angle::geometry::canonicalize_grid_vertex,
15    any_angle::{
16        AnyAnglePath, AnyAnglePathfinder, AnyAngleSearchRequest, AnyAngleSearchResult,
17        has_line_of_sight,
18    },
19};
20use condor_core::Point2;
21
22/// Online [`AnyAnglePathfinder`] using Theta* vertex expansion.
23///
24/// Parent shortcuts require line-of-sight; cost is Euclidean segment length. Prefer
25/// when any-angle polylines are needed without preprocess; prefer prepared any-angle
26/// for multi-query exact amortization, Lazy Theta* when deferred LOS is acceptable.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct ThetaStar;
29
30impl AnyAnglePathfinder for ThetaStar {
31    fn name(&self) -> &'static str {
32        "theta-star"
33    }
34
35    fn search(&self, grid: &Grid, request: AnyAngleSearchRequest) -> AnyAngleSearchResult {
36        let Some(start) = canonicalize_grid_vertex(request.start) else {
37            return Err(crate::AnyAngleSearchError::InvalidStart {
38                point: request.start,
39            });
40        };
41        let Some(goal) = canonicalize_grid_vertex(request.goal) else {
42            return Err(crate::AnyAngleSearchError::InvalidGoal {
43                point: request.goal,
44            });
45        };
46        let Some(start_v) = Vertex::from_point2(start) else {
47            return Err(crate::AnyAngleSearchError::InvalidStart { point: start });
48        };
49        let Some(goal_v) = Vertex::from_point2(goal) else {
50            return Err(crate::AnyAngleSearchError::InvalidGoal { point: goal });
51        };
52
53        if !is_vertex_valid(grid, start_v) {
54            return Err(crate::AnyAngleSearchError::InvalidStart { point: start });
55        }
56        if !is_vertex_valid(grid, goal_v) {
57            return Err(crate::AnyAngleSearchError::InvalidGoal { point: goal });
58        }
59
60        if start_v == goal_v {
61            return crate::any_angle::found(
62                AnyAnglePath::from_points(vec![start, goal])
63                    .expect("any-angle path contains at least one point"),
64                1,
65            );
66        }
67
68        let width = grid.width() + 1;
69        let height = grid.height() + 1;
70        let mut g_costs = vec![f64::INFINITY; width * height];
71        let mut parents = vec![None; width * height];
72        let mut visited_nodes = 0;
73        let watch = crate::search::BudgetWatch::start(request.budget);
74
75        let start_idx = vertex_index(start_v, width);
76        let goal_idx = vertex_index(goal_v, width);
77
78        g_costs[start_idx] = 0.0;
79        parents[start_idx] = Some(start_idx);
80
81        let mut frontier = BinaryHeap::new();
82        frontier.push(FrontierEntry {
83            vertex: start_v,
84            f_cost: start.distance_to(goal),
85        });
86
87        while let Some(current_entry) = frontier.pop() {
88            let current_v = current_entry.vertex;
89            let current_idx = vertex_index(current_v, width);
90
91            if current_entry.f_cost
92                > g_costs[current_idx] + current_v.to_point2().distance_to(goal) + 1e-9
93            {
94                continue;
95            }
96
97            visited_nodes += 1;
98
99            if current_v == goal_v {
100                break;
101            }
102
103            if let Err(reason) = watch.check(visited_nodes) {
104                return Err(crate::any_angle::budget_error(reason));
105            }
106
107            for neighbor_v in neighbors(grid, current_v) {
108                let neighbor_idx = vertex_index(neighbor_v, width);
109                let parent_idx = parents[current_idx].unwrap();
110                let parent_v = vertex_from_index(parent_idx, width);
111
112                if has_line_of_sight(grid, parent_v.to_point2(), neighbor_v.to_point2()) {
113                    let new_g = g_costs[parent_idx]
114                        + parent_v.to_point2().distance_to(neighbor_v.to_point2());
115                    if new_g < g_costs[neighbor_idx] {
116                        g_costs[neighbor_idx] = new_g;
117                        parents[neighbor_idx] = Some(parent_idx);
118                        frontier.push(FrontierEntry {
119                            vertex: neighbor_v,
120                            f_cost: new_g + neighbor_v.to_point2().distance_to(goal),
121                        });
122                    }
123                } else {
124                    let new_g = g_costs[current_idx]
125                        + current_v.to_point2().distance_to(neighbor_v.to_point2());
126                    if new_g < g_costs[neighbor_idx] {
127                        g_costs[neighbor_idx] = new_g;
128                        parents[neighbor_idx] = Some(current_idx);
129                        frontier.push(FrontierEntry {
130                            vertex: neighbor_v,
131                            f_cost: new_g + neighbor_v.to_point2().distance_to(goal),
132                        });
133                    }
134                }
135            }
136        }
137
138        if g_costs[goal_idx] == f64::INFINITY {
139            return crate::any_angle::not_found(visited_nodes);
140        }
141
142        let mut points = vec![goal];
143        let mut curr_idx = goal_idx;
144        while curr_idx != start_idx {
145            let next_idx = parents[curr_idx].unwrap();
146            if next_idx == curr_idx {
147                break;
148            }
149            let p = vertex_from_index(next_idx, width).to_point2();
150            if points
151                .last()
152                .is_some_and(|last| p.distance_to(*last) > 1e-9)
153            {
154                points.push(p);
155            }
156            curr_idx = next_idx;
157        }
158        if points
159            .last()
160            .is_some_and(|last| start.distance_to(*last) > 1e-9)
161        {
162            points.push(start);
163        }
164        points.reverse();
165
166        crate::any_angle::found(
167            AnyAnglePath::from_points(points).expect("any-angle path contains at least one point"),
168            visited_nodes,
169        )
170    }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174struct Vertex {
175    x: usize,
176    y: usize,
177}
178
179impl Vertex {
180    fn from_point2(p: Point2) -> Option<Self> {
181        if p.x < 0.0
182            || p.y < 0.0
183            || !is_grid_vertex_coordinate(p.x)
184            || !is_grid_vertex_coordinate(p.y)
185        {
186            return None;
187        }
188
189        Some(Self {
190            x: p.x.round() as usize,
191            y: p.y.round() as usize,
192        })
193    }
194
195    fn to_point2(self) -> Point2 {
196        Point2::new(self.x as f64, self.y as f64)
197    }
198}
199
200fn is_grid_vertex_coordinate(value: f64) -> bool {
201    (value - value.round()).abs() <= 1e-9
202}
203
204fn vertex_index(v: Vertex, width: usize) -> usize {
205    v.y * width + v.x
206}
207
208fn vertex_from_index(idx: usize, width: usize) -> Vertex {
209    Vertex {
210        x: idx % width,
211        y: idx / width,
212    }
213}
214
215fn is_vertex_valid(grid: &Grid, v: Vertex) -> bool {
216    v.x <= grid.width() && v.y <= grid.height()
217}
218
219fn neighbors(grid: &Grid, v: Vertex) -> Vec<Vertex> {
220    let mut neighbors = Vec::with_capacity(8);
221    let x = v.x as i64;
222    let y = v.y as i64;
223
224    for dx in -1..=1 {
225        for dy in -1..=1 {
226            if dx == 0 && dy == 0 {
227                continue;
228            }
229            let nx = x + dx;
230            let ny = y + dy;
231
232            if nx < 0 || nx > grid.width() as i64 || ny < 0 || ny > grid.height() as i64 {
233                continue;
234            }
235
236            let nv = Vertex {
237                x: nx as usize,
238                y: ny as usize,
239            };
240
241            if is_move_legal(grid, v, nv) {
242                neighbors.push(nv);
243            }
244        }
245    }
246    neighbors
247}
248
249/// No-corner-cut vertex adjacency on the dual of blocked cells.
250///
251/// Cardinal steps need either adjacent cell open along the shared edge.
252/// Diagonal steps require the single cell the diagonal crosses to be open
253/// (the cell whose lower-left corner is `min` of the two vertices in each axis).
254fn is_move_legal(grid: &Grid, v1: Vertex, v2: Vertex) -> bool {
255    let x_min = v1.x.min(v2.x);
256    let x_max = v1.x.max(v2.x);
257    let y_min = v1.y.min(v2.y);
258    let y_max = v1.y.max(v2.y);
259
260    if x_min == x_max {
261        let x = x_min;
262        let y = y_min;
263        let left_open = if x > 0 {
264            grid.is_walkable(Point::new(x - 1, y))
265        } else {
266            false
267        };
268        let right_open = if x < grid.width() {
269            grid.is_walkable(Point::new(x, y))
270        } else {
271            false
272        };
273        left_open || right_open
274    } else if y_min == y_max {
275        let x = x_min;
276        let y = y_min;
277        let above_open = if y > 0 {
278            grid.is_walkable(Point::new(x, y - 1))
279        } else {
280            false
281        };
282        let below_open = if y < grid.height() {
283            grid.is_walkable(Point::new(x, y))
284        } else {
285            false
286        };
287        above_open || below_open
288    } else {
289        let cx = if v2.x > v1.x { v1.x } else { v1.x - 1 };
290        let cy = if v2.y > v1.y { v1.y } else { v1.y - 1 };
291
292        grid.is_walkable(Point::new(cx, cy))
293    }
294}
295
296#[derive(Debug, PartialEq)]
297struct FrontierEntry {
298    vertex: Vertex,
299    f_cost: f64,
300}
301
302impl Eq for FrontierEntry {}
303
304impl Ord for FrontierEntry {
305    fn cmp(&self, other: &Self) -> Ordering {
306        other
307            .f_cost
308            .partial_cmp(&self.f_cost)
309            .unwrap_or(Ordering::Equal)
310    }
311}
312
313impl PartialOrd for FrontierEntry {
314    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
315        Some(self.cmp(other))
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::grid::Cell;
323
324    #[test]
325    fn theta_star_finds_direct_path_in_open_field() {
326        let grid = Grid::new(10, 10).expect("grid dimensions are valid");
327        let request = AnyAngleSearchRequest::new(Point2::new(0.0, 0.0), Point2::new(9.0, 9.0));
328        let result = ThetaStar.search(&grid, request);
329
330        assert!(result.as_ref().expect("valid search request").is_found());
331        let path = result
332            .as_ref()
333            .expect("valid search request")
334            .path()
335            .unwrap();
336        assert_eq!(path.points().len(), 2);
337        assert!((path.cost() - (9.0 * 2.0f64.sqrt())).abs() <= 1e-9);
338    }
339
340    #[test]
341    fn theta_star_detours_around_wall() {
342        let mut grid = Grid::new(10, 10).expect("grid dimensions are valid");
343        for x in 0..8 {
344            grid.set_cell(Point::new(x, 5), Cell::Blocked)
345                .expect("valid grid edit");
346        }
347
348        let request = AnyAngleSearchRequest::new(Point2::new(0.0, 0.0), Point2::new(0.0, 9.0));
349        let result = ThetaStar.search(&grid, request);
350
351        assert!(result.as_ref().expect("valid search request").is_found());
352        let path = result
353            .as_ref()
354            .expect("valid search request")
355            .path()
356            .unwrap();
357        assert!(path.points().len() >= 3);
358        assert!(path.cost() > 9.0);
359    }
360
361    #[test]
362    fn theta_star_rejects_non_grid_aligned_inputs() {
363        let grid = Grid::new(10, 10).expect("grid dimensions are valid");
364        let request = AnyAngleSearchRequest::new(Point2::new(0.25, 0.0), Point2::new(9.0, 9.0));
365        let result = ThetaStar.search(&grid, request);
366
367        assert_eq!(
368            result,
369            Err(crate::AnyAngleSearchError::InvalidStart {
370                point: Point2::new(0.25, 0.0),
371            })
372        );
373    }
374}