1use crate::astar::{astar_grid2d_opts, GridAStarOpts};
36use crate::metrics::{CostMetric, DirectDistance};
37use thiserror::Error;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
41pub enum ContinuousAStarConfigError {
42 #[error("continuous-space extents must be positive")]
43 InvalidExtent,
44 #[error("grid dimensions must be positive")]
45 InvalidGridDimensions,
46 #[error("walkmap size mismatch: expected {expected}, got {actual}")]
47 WalkmapSizeMismatch { expected: usize, actual: usize },
48}
49
50#[derive(Debug)]
52pub struct ContinuousAStarOpts {
53 pub diagonal: bool,
55 pub periodic: bool,
57 pub admissibility: f64,
59}
60
61impl Default for ContinuousAStarOpts {
62 fn default() -> Self {
63 Self {
64 diagonal: true,
65 periodic: false,
66 admissibility: 0.0,
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct ContinuousPath {
74 pub waypoints: Vec<(f64, f64)>,
79 pub cost: f64,
81}
82
83pub struct ContinuousAStar<'a> {
89 extent_x: f64,
90 extent_y: f64,
91 walkmap: &'a [bool],
92 grid_w: usize,
93 grid_h: usize,
94 opts: ContinuousAStarOpts,
95 metric: Option<Box<dyn CostMetric>>,
96}
97
98impl<'a> ContinuousAStar<'a> {
99 pub fn new(
101 extent_x: f64,
102 extent_y: f64,
103 walkmap: &'a [bool],
104 grid_w: usize,
105 grid_h: usize,
106 opts: ContinuousAStarOpts,
107 ) -> Result<Self, ContinuousAStarConfigError> {
108 if extent_x <= 0.0 || extent_y <= 0.0 {
109 return Err(ContinuousAStarConfigError::InvalidExtent);
110 }
111 if grid_w == 0 || grid_h == 0 {
112 return Err(ContinuousAStarConfigError::InvalidGridDimensions);
113 }
114 let expected = grid_w * grid_h;
115 if walkmap.len() != expected {
116 return Err(ContinuousAStarConfigError::WalkmapSizeMismatch {
117 expected,
118 actual: walkmap.len(),
119 });
120 }
121 Ok(Self {
122 extent_x,
123 extent_y,
124 walkmap,
125 grid_w,
126 grid_h,
127 opts,
128 metric: None,
129 })
130 }
131
132 pub fn with_metric(mut self, metric: impl CostMetric + 'static) -> Self {
134 self.metric = Some(Box::new(metric));
135 self
136 }
137
138 fn to_discrete(&self, pos: (f64, f64)) -> (usize, usize) {
140 let gx = (pos.0 / self.extent_x * self.grid_w as f64)
141 .floor()
142 .max(0.0) as usize;
143 let gy = (pos.1 / self.extent_y * self.grid_h as f64)
144 .floor()
145 .max(0.0) as usize;
146 (gx.min(self.grid_w - 1), gy.min(self.grid_h - 1))
147 }
148
149 fn to_continuous(&self, cell: (usize, usize)) -> (f64, f64) {
151 let cell_w = self.extent_x / self.grid_w as f64;
152 let cell_h = self.extent_y / self.grid_h as f64;
153 (
154 cell.0 as f64 * cell_w + cell_w * 0.5,
155 cell.1 as f64 * cell_h + cell_h * 0.5,
156 )
157 }
158
159 fn sqr_distance(&self, a: (f64, f64), b: (f64, f64)) -> f64 {
161 let mut dx = (a.0 - b.0).abs();
162 let mut dy = (a.1 - b.1).abs();
163 if self.opts.periodic {
164 if dx > self.extent_x * 0.5 {
165 dx = self.extent_x - dx;
166 }
167 if dy > self.extent_y * 0.5 {
168 dy = self.extent_y - dy;
169 }
170 }
171 dx * dx + dy * dy
172 }
173
174 pub fn find_path(&self, from: (f64, f64), to: (f64, f64)) -> Option<ContinuousPath> {
182 let discrete_from = self.to_discrete(from);
183 let discrete_to = self.to_discrete(to);
184
185 let walkmap = self.walkmap;
186 let grid_w = self.grid_w;
187 let walkable_fn = move |x: usize, y: usize| -> bool { walkmap[y * grid_w + x] };
188
189 let default_metric = DirectDistance::new();
190 let metric_ref: &dyn CostMetric = match &self.metric {
191 Some(m) => m.as_ref(),
192 None => &default_metric,
193 };
194
195 let mut grid_opts = GridAStarOpts::new(self.grid_w, self.grid_h);
196 grid_opts.diagonal = self.opts.diagonal;
197 grid_opts.periodic = self.opts.periodic;
198 grid_opts.admissibility = self.opts.admissibility;
199 grid_opts.walkable = Some(&walkable_fn);
200 grid_opts.cost_metric = Some(metric_ref);
201
202 let grid_result = astar_grid2d_opts(discrete_from, discrete_to, &grid_opts)?;
203
204 if grid_result.path.len() <= 1 {
207 return Some(ContinuousPath {
208 waypoints: vec![to],
209 cost: grid_result.cost,
210 });
211 }
212
213 let mut waypoints: Vec<(f64, f64)> = grid_result.path[1..]
215 .iter()
216 .map(|&cell| self.to_continuous(cell))
217 .collect();
218
219 if waypoints.len() >= 2 {
224 let last = waypoints[waypoints.len() - 1];
225 let second_last = waypoints[waypoints.len() - 2];
226 let last_to_goal = self.sqr_distance(last, to);
227 let second_last_to_goal = self.sqr_distance(second_last, to);
228
229 if second_last_to_goal < last_to_goal {
230 waypoints.pop();
231 }
232 } else if waypoints.len() == 1 {
233 let last = waypoints[0];
236 let last_to_goal = self.sqr_distance(last, to);
237 if last_to_goal < 1e-12 {
238 waypoints.clear();
240 }
241 }
242
243 let goal_already_last = waypoints
245 .last()
246 .map(|&wp| self.sqr_distance(wp, to) < 1e-12)
247 .unwrap_or(false);
248 if !goal_already_last {
249 waypoints.push(to);
250 }
251
252 Some(ContinuousPath {
253 waypoints,
254 cost: grid_result.cost,
255 })
256 }
257}
258
259pub struct ContinuousAStar3D<'a> {
264 extent_x: f64,
265 extent_y: f64,
266 extent_z: f64,
267 walkmap: &'a [bool],
268 grid_w: usize,
269 grid_h: usize,
270 grid_d: usize,
271 periodic: bool,
272 diagonal: bool,
273 admissibility: f64,
274}
275
276#[derive(Debug, Clone)]
278pub struct ContinuousPath3D {
279 pub waypoints: Vec<(f64, f64, f64)>,
281 pub cost: f64,
283}
284
285impl<'a> ContinuousAStar3D<'a> {
286 pub fn new(
288 extent_x: f64,
289 extent_y: f64,
290 extent_z: f64,
291 walkmap: &'a [bool],
292 grid_w: usize,
293 grid_h: usize,
294 grid_d: usize,
295 ) -> Result<Self, ContinuousAStarConfigError> {
296 if extent_x <= 0.0 || extent_y <= 0.0 || extent_z <= 0.0 {
297 return Err(ContinuousAStarConfigError::InvalidExtent);
298 }
299 if grid_w == 0 || grid_h == 0 || grid_d == 0 {
300 return Err(ContinuousAStarConfigError::InvalidGridDimensions);
301 }
302 let expected = grid_w * grid_h * grid_d;
303 if walkmap.len() != expected {
304 return Err(ContinuousAStarConfigError::WalkmapSizeMismatch {
305 expected,
306 actual: walkmap.len(),
307 });
308 }
309 Ok(Self {
310 extent_x,
311 extent_y,
312 extent_z,
313 walkmap,
314 grid_w,
315 grid_h,
316 grid_d,
317 periodic: false,
318 diagonal: true,
319 admissibility: 0.0,
320 })
321 }
322
323 pub fn periodic(mut self, periodic: bool) -> Self {
325 self.periodic = periodic;
326 self
327 }
328
329 pub fn diagonal(mut self, diagonal: bool) -> Self {
331 self.diagonal = diagonal;
332 self
333 }
334
335 pub fn admissibility(mut self, admissibility: f64) -> Self {
337 self.admissibility = admissibility;
338 self
339 }
340
341 fn to_discrete(&self, pos: (f64, f64, f64)) -> (usize, usize, usize) {
342 let gx = (pos.0 / self.extent_x * self.grid_w as f64)
343 .floor()
344 .max(0.0) as usize;
345 let gy = (pos.1 / self.extent_y * self.grid_h as f64)
346 .floor()
347 .max(0.0) as usize;
348 let gz = (pos.2 / self.extent_z * self.grid_d as f64)
349 .floor()
350 .max(0.0) as usize;
351 (
352 gx.min(self.grid_w - 1),
353 gy.min(self.grid_h - 1),
354 gz.min(self.grid_d - 1),
355 )
356 }
357
358 fn to_continuous(&self, cell: (usize, usize, usize)) -> (f64, f64, f64) {
359 let cw = self.extent_x / self.grid_w as f64;
360 let ch = self.extent_y / self.grid_h as f64;
361 let cd = self.extent_z / self.grid_d as f64;
362 (
363 cell.0 as f64 * cw + cw * 0.5,
364 cell.1 as f64 * ch + ch * 0.5,
365 cell.2 as f64 * cd + cd * 0.5,
366 )
367 }
368
369 fn is_walkable(&self, x: usize, y: usize, z: usize) -> bool {
370 self.walkmap[z * self.grid_h * self.grid_w + y * self.grid_w + x]
371 }
372
373 fn sqr_distance_3d(&self, a: (f64, f64, f64), b: (f64, f64, f64)) -> f64 {
374 let mut dx = (a.0 - b.0).abs();
375 let mut dy = (a.1 - b.1).abs();
376 let mut dz = (a.2 - b.2).abs();
377 if self.periodic {
378 if dx > self.extent_x * 0.5 {
379 dx = self.extent_x - dx;
380 }
381 if dy > self.extent_y * 0.5 {
382 dy = self.extent_y - dy;
383 }
384 if dz > self.extent_z * 0.5 {
385 dz = self.extent_z - dz;
386 }
387 }
388 dx * dx + dy * dy + dz * dz
389 }
390
391 pub fn find_path(
393 &self,
394 from: (f64, f64, f64),
395 to: (f64, f64, f64),
396 ) -> Option<ContinuousPath3D> {
397 let d_from = self.to_discrete(from);
398 let d_to = self.to_discrete(to);
399
400 if !self.is_walkable(d_from.0, d_from.1, d_from.2)
401 || !self.is_walkable(d_to.0, d_to.1, d_to.2)
402 {
403 return None;
404 }
405
406 let admissibility = self.admissibility;
407 let periodic = self.periodic;
408 let gw = self.grid_w;
409 let gh = self.grid_h;
410 let gd = self.grid_d;
411
412 let heuristic = move |a: &(usize, usize, usize), b: &(usize, usize, usize)| -> f64 {
413 let mut dx = (a.0 as isize - b.0 as isize).unsigned_abs();
414 let mut dy = (a.1 as isize - b.1 as isize).unsigned_abs();
415 let mut dz = (a.2 as isize - b.2 as isize).unsigned_abs();
416 if periodic {
417 dx = dx.min(gw - dx);
418 dy = dy.min(gh - dy);
419 dz = dz.min(gd - dz);
420 }
421 let mut dims = [dx, dy, dz];
422 dims.sort_unstable();
423 let min_d = dims[0] as f64;
424 let mid_d = dims[1] as f64;
425 let max_d = dims[2] as f64;
426 let h = min_d * 1.7320508075688772 + (mid_d - min_d) * std::f64::consts::SQRT_2
429 + (max_d - mid_d) * 1.0;
430 (1.0 + admissibility) * h
431 };
432
433 let walkmap = self.walkmap;
434 let diagonal = self.diagonal;
435
436 let neighbors = move |node: &(usize, usize, usize)| -> Vec<((usize, usize, usize), f64)> {
437 let (x, y, z) = *node;
438 let mut result = Vec::new();
439
440 for &dz in &[-1i32, 0, 1] {
441 for &dy in &[-1i32, 0, 1] {
442 for &dx in &[-1i32, 0, 1] {
443 if dx == 0 && dy == 0 && dz == 0 {
444 continue;
445 }
446 if !diagonal {
448 let nonzero = (dx != 0) as u32 + (dy != 0) as u32 + (dz != 0) as u32;
449 if nonzero > 1 {
450 continue;
451 }
452 }
453
454 let nx = x as i32 + dx;
455 let ny = y as i32 + dy;
456 let nz = z as i32 + dz;
457
458 let neighbor = if periodic {
459 let px = ((nx % gw as i32) + gw as i32) % gw as i32;
460 let py = ((ny % gh as i32) + gh as i32) % gh as i32;
461 let pz = ((nz % gd as i32) + gd as i32) % gd as i32;
462 Some((px as usize, py as usize, pz as usize))
463 } else if nx >= 0
464 && ny >= 0
465 && nz >= 0
466 && (nx as usize) < gw
467 && (ny as usize) < gh
468 && (nz as usize) < gd
469 {
470 Some((nx as usize, ny as usize, nz as usize))
471 } else {
472 None
473 };
474
475 if let Some(n) = neighbor {
476 if walkmap[n.2 * gh * gw + n.1 * gw + n.0] {
477 let nonzero =
478 (dx != 0) as u32 + (dy != 0) as u32 + (dz != 0) as u32;
479 let cost = match nonzero {
480 1 => 1.0,
481 2 => std::f64::consts::SQRT_2,
482 _ => 1.7320508075688772, };
484 result.push((n, cost));
485 }
486 }
487 }
488 }
489 }
490
491 result
492 };
493
494 let grid_result = crate::astar::astar(d_from, d_to, heuristic, neighbors)?;
495
496 if grid_result.path.len() <= 1 {
497 return Some(ContinuousPath3D {
498 waypoints: vec![to],
499 cost: grid_result.cost,
500 });
501 }
502
503 let mut waypoints: Vec<(f64, f64, f64)> = grid_result.path[1..]
504 .iter()
505 .map(|&cell| self.to_continuous(cell))
506 .collect();
507
508 if waypoints.len() >= 2 {
510 let last = waypoints[waypoints.len() - 1];
511 let second_last = waypoints[waypoints.len() - 2];
512 if self.sqr_distance_3d(second_last, to) < self.sqr_distance_3d(last, to) {
513 waypoints.pop();
514 }
515 }
516
517 let goal_already_last = waypoints
518 .last()
519 .map(|&wp| self.sqr_distance_3d(wp, to) < 1e-12)
520 .unwrap_or(false);
521 if !goal_already_last {
522 waypoints.push(to);
523 }
524
525 Some(ContinuousPath3D {
526 waypoints,
527 cost: grid_result.cost,
528 })
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn continuous_2d_same_cell() {
538 let walkmap = vec![true; 100];
539 let pf = ContinuousAStar::new(10.0, 10.0, &walkmap, 10, 10, ContinuousAStarOpts::default())
540 .unwrap();
541 let result = pf.find_path((0.5, 0.5), (0.9, 0.9));
542 let result = result.expect("same-cell path should succeed");
543 assert_eq!(result.waypoints.last(), Some(&(0.9, 0.9)));
544 }
545
546 #[test]
547 fn continuous_2d_across_cells() {
548 let walkmap = vec![true; 100];
549 let pf = ContinuousAStar::new(10.0, 10.0, &walkmap, 10, 10, ContinuousAStarOpts::default())
550 .unwrap();
551 let result = pf.find_path((0.5, 0.5), (9.5, 9.5));
552 let result = result.expect("path should exist");
553 assert!(result.waypoints.len() >= 2);
554 assert_eq!(result.waypoints.last(), Some(&(9.5, 9.5)));
555 }
556
557 #[test]
558 fn continuous_2d_blocked() {
559 let mut walkmap = vec![true; 100];
560 for y in 0..10 {
561 walkmap[y * 10 + 5] = false;
562 }
563 let pf = ContinuousAStar::new(10.0, 10.0, &walkmap, 10, 10, ContinuousAStarOpts::default())
564 .unwrap();
565 let result = pf.find_path((0.5, 0.5), (9.5, 9.5));
566 assert!(result.is_none(), "wall should block path");
567 }
568
569 #[test]
570 fn continuous_2d_wall_with_gap() {
571 let mut walkmap = vec![true; 100];
572 for y in 0..10 {
573 if y != 5 {
574 walkmap[y * 10 + 5] = false;
575 }
576 }
577 let pf = ContinuousAStar::new(10.0, 10.0, &walkmap, 10, 10, ContinuousAStarOpts::default())
578 .unwrap();
579 let result = pf.find_path((0.5, 0.5), (9.5, 9.5));
580 assert!(result.is_some(), "should find path through gap");
581 }
582
583 #[test]
584 fn continuous_3d_basic() {
585 let walkmap = vec![true; 1000];
586 let pf = ContinuousAStar3D::new(10.0, 10.0, 10.0, &walkmap, 10, 10, 10).unwrap();
587 let result = pf.find_path((0.5, 0.5, 0.5), (9.5, 9.5, 9.5));
588 let result = result.expect("3D path should exist");
589 assert!(result.waypoints.len() >= 2);
590 assert_eq!(result.waypoints.last(), Some(&(9.5, 9.5, 9.5)));
591 }
592
593 #[test]
594 fn continuous_3d_blocked() {
595 let mut walkmap = vec![true; 1000];
596 for y in 0..10 {
597 for x in 0..10 {
598 walkmap[5 * 100 + y * 10 + x] = false;
599 }
600 }
601 let pf = ContinuousAStar3D::new(10.0, 10.0, 10.0, &walkmap, 10, 10, 10)
602 .unwrap()
603 .diagonal(false);
604 let result = pf.find_path((0.5, 0.5, 0.5), (9.5, 9.5, 9.5));
605 assert!(result.is_none(), "blocked z-plane should prevent path");
606 }
607}