1use 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#[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
249fn 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}