Skip to main content

condor_grid/algorithms/
lazy_theta_star.rs

1//! Any-angle [`AnyAnglePathfinder`]: online Lazy Theta*.
2//!
3//! Each grid-aligned-vertex query is independent. It shares Theta*'s no-corner-cut
4//! vertex model but defers line-of-sight repair until expansion. Path cost is Euclidean
5//! segment length and outcomes follow the any-angle invalid/found/no-path contract.
6//! Prefer [`super::theta_star::ThetaStar`] for immediate LOS checks, or
7//! [`crate::PreparedAnyAngleGrid`] for repeated exact static queries.
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
22const EPSILON: f64 = 1e-9;
23
24/// Online [`AnyAnglePathfinder`] with deferred LOS (Lazy Theta*).
25///
26/// Same vertex grid and Euclidean cost model as [`super::theta_star::ThetaStar`]; parent
27/// chains are repaired at expansion when a popped vertex lacks LOS to its parent.
28/// Prefer when LOS is expensive relative to expansions; not a multi-query prepared map.
29#[derive(Debug, Clone, Copy, Default)]
30pub struct LazyThetaStar;
31
32impl AnyAnglePathfinder for LazyThetaStar {
33    fn name(&self) -> &'static str {
34        "lazy-theta-star"
35    }
36
37    fn search(&self, grid: &Grid, request: AnyAngleSearchRequest) -> AnyAngleSearchResult {
38        let Some(start) = canonicalize_grid_vertex(request.start) else {
39            return Err(crate::AnyAngleSearchError::InvalidStart {
40                point: request.start,
41            });
42        };
43        let Some(goal) = canonicalize_grid_vertex(request.goal) else {
44            return Err(crate::AnyAngleSearchError::InvalidGoal {
45                point: request.goal,
46            });
47        };
48        let Some(start_v) = Vertex::from_point2(start) else {
49            return Err(crate::AnyAngleSearchError::InvalidStart { point: start });
50        };
51        let Some(goal_v) = Vertex::from_point2(goal) else {
52            return Err(crate::AnyAngleSearchError::InvalidGoal { point: goal });
53        };
54
55        if !is_vertex_valid(grid, start_v) {
56            return Err(crate::AnyAngleSearchError::InvalidStart { point: start });
57        }
58        if !is_vertex_valid(grid, goal_v) {
59            return Err(crate::AnyAngleSearchError::InvalidGoal { point: goal });
60        }
61
62        if start_v == goal_v {
63            return crate::any_angle::found(
64                AnyAnglePath::from_points(vec![start, goal])
65                    .expect("any-angle path contains at least one point"),
66                1,
67            );
68        }
69
70        let width = grid.width() + 1;
71        let height = grid.height() + 1;
72        let mut g_costs = vec![f64::INFINITY; width * height];
73        let mut parents = vec![None; width * height];
74        let mut closed = vec![false; width * height];
75        let mut visited_nodes = 0;
76        let watch = crate::search::BudgetWatch::start(request.budget);
77
78        let start_idx = vertex_index(start_v, width);
79        let goal_idx = vertex_index(goal_v, width);
80
81        g_costs[start_idx] = 0.0;
82        parents[start_idx] = Some(start_idx);
83
84        let mut frontier = BinaryHeap::new();
85        frontier.push(FrontierEntry {
86            vertex: start_v,
87            f_cost: start.distance_to(goal),
88        });
89
90        while let Some(current_entry) = frontier.pop() {
91            let current_v = current_entry.vertex;
92            let current_idx = vertex_index(current_v, width);
93
94            if closed[current_idx] {
95                continue;
96            }
97
98            if current_entry.f_cost
99                > g_costs[current_idx] + current_v.to_point2().distance_to(goal) + EPSILON
100            {
101                continue;
102            }
103
104            set_vertex(
105                grid,
106                current_v,
107                current_idx,
108                width,
109                &mut g_costs,
110                &mut parents,
111                &closed,
112            );
113            if g_costs[current_idx].is_infinite() {
114                continue;
115            }
116
117            visited_nodes += 1;
118
119            if current_v == goal_v {
120                break;
121            }
122
123            if let Err(reason) = watch.check(visited_nodes) {
124                return Err(crate::any_angle::budget_error(reason));
125            }
126
127            closed[current_idx] = true;
128
129            for neighbor_v in neighbors(grid, current_v) {
130                let neighbor_idx = vertex_index(neighbor_v, width);
131                if closed[neighbor_idx] {
132                    continue;
133                }
134
135                let current_parent_idx = parents[current_idx].unwrap_or(current_idx);
136                let current_parent_v = vertex_from_index(current_parent_idx, width);
137
138                let shortcut_g = g_costs[current_parent_idx]
139                    + current_parent_v
140                        .to_point2()
141                        .distance_to(neighbor_v.to_point2());
142                let edge_g = g_costs[current_idx]
143                    + current_v.to_point2().distance_to(neighbor_v.to_point2());
144
145                let (candidate_g, candidate_parent_idx) = if shortcut_g < edge_g {
146                    (shortcut_g, current_parent_idx)
147                } else {
148                    (edge_g, current_idx)
149                };
150
151                if candidate_g + EPSILON < g_costs[neighbor_idx] {
152                    g_costs[neighbor_idx] = candidate_g;
153                    parents[neighbor_idx] = Some(candidate_parent_idx);
154                    frontier.push(FrontierEntry {
155                        vertex: neighbor_v,
156                        f_cost: candidate_g + neighbor_v.to_point2().distance_to(goal),
157                    });
158                }
159            }
160        }
161
162        if g_costs[goal_idx] == f64::INFINITY {
163            return crate::any_angle::not_found(visited_nodes);
164        }
165
166        let mut points = vec![goal];
167        let mut current_idx = goal_idx;
168        while current_idx != start_idx {
169            let next_idx = parents[current_idx].unwrap();
170            if next_idx == current_idx {
171                break;
172            }
173            let point = vertex_from_index(next_idx, width).to_point2();
174            if points
175                .last()
176                .is_some_and(|last| point.distance_to(*last) > EPSILON)
177            {
178                points.push(point);
179            }
180            current_idx = next_idx;
181        }
182        if points
183            .last()
184            .is_some_and(|last| start.distance_to(*last) > EPSILON)
185        {
186            points.push(start);
187        }
188        points.reverse();
189
190        crate::any_angle::found(
191            AnyAnglePath::from_points(points).expect("any-angle path contains at least one point"),
192            visited_nodes,
193        )
194    }
195}
196
197fn set_vertex(
198    grid: &Grid,
199    current_v: Vertex,
200    current_idx: usize,
201    width: usize,
202    g_costs: &mut [f64],
203    parents: &mut [Option<usize>],
204    closed: &[bool],
205) {
206    let Some(parent_idx) = parents[current_idx] else {
207        return;
208    };
209    if parent_idx == current_idx {
210        return;
211    }
212
213    let parent_v = vertex_from_index(parent_idx, width);
214    if has_line_of_sight(grid, parent_v.to_point2(), current_v.to_point2()) {
215        return;
216    }
217
218    let mut best_parent = None;
219    let mut best_g = f64::INFINITY;
220    for neighbor_v in neighbors(grid, current_v) {
221        let neighbor_idx = vertex_index(neighbor_v, width);
222        if !closed[neighbor_idx] {
223            continue;
224        }
225
226        let candidate_g =
227            g_costs[neighbor_idx] + neighbor_v.to_point2().distance_to(current_v.to_point2());
228        if candidate_g < best_g {
229            best_g = candidate_g;
230            best_parent = Some(neighbor_idx);
231        }
232    }
233
234    if let Some(best_parent_idx) = best_parent {
235        g_costs[current_idx] = best_g;
236        parents[current_idx] = Some(best_parent_idx);
237    } else {
238        g_costs[current_idx] = f64::INFINITY;
239        parents[current_idx] = None;
240    }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244struct Vertex {
245    x: usize,
246    y: usize,
247}
248
249impl Vertex {
250    fn from_point2(point: Point2) -> Option<Self> {
251        if point.x < 0.0
252            || point.y < 0.0
253            || !is_grid_vertex_coordinate(point.x)
254            || !is_grid_vertex_coordinate(point.y)
255        {
256            return None;
257        }
258
259        Some(Self {
260            x: point.x.round() as usize,
261            y: point.y.round() as usize,
262        })
263    }
264
265    fn to_point2(self) -> Point2 {
266        Point2::new(self.x as f64, self.y as f64)
267    }
268}
269
270fn is_grid_vertex_coordinate(value: f64) -> bool {
271    (value - value.round()).abs() <= EPSILON
272}
273
274fn vertex_index(vertex: Vertex, width: usize) -> usize {
275    vertex.y * width + vertex.x
276}
277
278fn vertex_from_index(index: usize, width: usize) -> Vertex {
279    Vertex {
280        x: index % width,
281        y: index / width,
282    }
283}
284
285fn is_vertex_valid(grid: &Grid, vertex: Vertex) -> bool {
286    vertex.x <= grid.width() && vertex.y <= grid.height()
287}
288
289fn neighbors(grid: &Grid, vertex: Vertex) -> Vec<Vertex> {
290    let mut neighbors = Vec::with_capacity(8);
291    let x = vertex.x as i64;
292    let y = vertex.y as i64;
293
294    for dx in -1..=1 {
295        for dy in -1..=1 {
296            if dx == 0 && dy == 0 {
297                continue;
298            }
299
300            let nx = x + dx;
301            let ny = y + dy;
302
303            if nx < 0 || nx > grid.width() as i64 || ny < 0 || ny > grid.height() as i64 {
304                continue;
305            }
306
307            let neighbor = Vertex {
308                x: nx as usize,
309                y: ny as usize,
310            };
311
312            if is_move_legal(grid, vertex, neighbor) {
313                neighbors.push(neighbor);
314            }
315        }
316    }
317
318    neighbors
319}
320
321fn is_move_legal(grid: &Grid, from: Vertex, to: Vertex) -> bool {
322    let x_min = from.x.min(to.x);
323    let x_max = from.x.max(to.x);
324    let y_min = from.y.min(to.y);
325    let y_max = from.y.max(to.y);
326
327    if x_min == x_max {
328        let x = x_min;
329        let y = y_min;
330        let left_open = if x > 0 {
331            grid.is_walkable(Point::new(x - 1, y))
332        } else {
333            false
334        };
335        let right_open = if x < grid.width() {
336            grid.is_walkable(Point::new(x, y))
337        } else {
338            false
339        };
340        left_open || right_open
341    } else if y_min == y_max {
342        let x = x_min;
343        let y = y_min;
344        let above_open = if y > 0 {
345            grid.is_walkable(Point::new(x, y - 1))
346        } else {
347            false
348        };
349        let below_open = if y < grid.height() {
350            grid.is_walkable(Point::new(x, y))
351        } else {
352            false
353        };
354        above_open || below_open
355    } else {
356        let cell_x = if to.x > from.x { from.x } else { from.x - 1 };
357        let cell_y = if to.y > from.y { from.y } else { from.y - 1 };
358        grid.is_walkable(Point::new(cell_x, cell_y))
359    }
360}
361
362#[derive(Debug, PartialEq)]
363struct FrontierEntry {
364    vertex: Vertex,
365    f_cost: f64,
366}
367
368impl Eq for FrontierEntry {}
369
370impl Ord for FrontierEntry {
371    fn cmp(&self, other: &Self) -> Ordering {
372        other
373            .f_cost
374            .partial_cmp(&self.f_cost)
375            .unwrap_or(Ordering::Equal)
376    }
377}
378
379impl PartialOrd for FrontierEntry {
380    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
381        Some(self.cmp(other))
382    }
383}