Skip to main content

rustsim_pathfinding/
continuous_astar.rs

1//! Continuous-space A* pathfinding.
2//!
3//! Discretizes a continuous 2D or 3D space into a grid, runs A* on the grid,
4//! then maps the resulting path back to continuous coordinates. This mirrors
5//! Julia Agents.jl `astar_continuous.jl`.
6//!
7//! # How it works
8//!
9//! 1. The continuous space `[0, extent_x) x [0, extent_y)` is overlaid with
10//!    a walkability grid of `grid_w x grid_h` cells.
11//! 2. Continuous positions are converted to discrete grid cells.
12//! 3. A* runs on the grid using the configured cost metric.
13//! 4. The discrete path is converted back to continuous waypoints
14//!    (cell centers), with anti-backtracking optimization on the
15//!    last waypoint.
16//!
17//! # Example
18//!
19//! ```
20//! use rustsim_pathfinding::continuous_astar::{ContinuousAStar, ContinuousAStarOpts};
21//!
22//! // 100x100 continuous space, discretized to a 50x50 walkability grid
23//! let walkmap = vec![true; 50 * 50]; // all walkable
24//! let pathfinder = ContinuousAStar::new(
25//!     100.0, 100.0,
26//!     &walkmap, 50, 50,
27//!     ContinuousAStarOpts::default(),
28//! )
29//! .unwrap();
30//!
31//! let path = pathfinder.find_path((10.0, 10.0), (90.0, 90.0));
32//! assert!(path.is_some());
33//! ```
34
35use crate::astar::{astar_grid2d_opts, GridAStarOpts};
36use crate::metrics::{CostMetric, DirectDistance};
37use thiserror::Error;
38
39/// Errors returned by continuous A* configuration validation.
40#[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/// Options for continuous-space A* pathfinding.
51#[derive(Debug)]
52pub struct ContinuousAStarOpts {
53    /// Allow diagonal movement on the underlying grid.
54    pub diagonal: bool,
55    /// Periodic (toroidal) boundary wrapping.
56    pub periodic: bool,
57    /// Admissibility factor (0.0 = optimal, higher = faster but suboptimal).
58    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/// Result of a continuous-space A* search.
72#[derive(Debug, Clone)]
73pub struct ContinuousPath {
74    /// Waypoints in continuous coordinates, from (near) start to goal.
75    /// The first waypoint is NOT the start position -- it is the first
76    /// cell center the agent should walk toward. The last waypoint is
77    /// the exact goal position.
78    pub waypoints: Vec<(f64, f64)>,
79    /// Approximate total cost (from the underlying grid A*).
80    pub cost: f64,
81}
82
83/// 2D continuous-space A* pathfinder.
84///
85/// Wraps a walkability grid and continuous-space extent. Use
86/// [`find_path`](Self::find_path) to compute paths between continuous
87/// positions.
88pub 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    /// Create a new continuous-space pathfinder.
100    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    /// Set a custom cost metric for the underlying grid A*.
133    pub fn with_metric(mut self, metric: impl CostMetric + 'static) -> Self {
134        self.metric = Some(Box::new(metric));
135        self
136    }
137
138    /// Convert a continuous position to a discrete grid cell.
139    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    /// Convert a discrete grid cell to the continuous position at its center.
150    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    /// Squared distance between two continuous positions, respecting periodicity.
160    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    /// Find a path from `from` to `to` in continuous coordinates.
175    ///
176    /// Returns `None` if no path exists (start or goal is unwalkable,
177    /// or no connected path through walkable cells).
178    ///
179    /// The returned [`ContinuousPath`] contains waypoints in continuous
180    /// coordinates. The last waypoint is the exact `to` position.
181    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 the discrete path is just the start cell (from and to are in the
205        // same cell), the only waypoint is the exact goal.
206        if grid_result.path.len() <= 1 {
207            return Some(ContinuousPath {
208                waypoints: vec![to],
209                cost: grid_result.cost,
210            });
211        }
212
213        // Convert discrete waypoints to continuous (skip the start cell).
214        let mut waypoints: Vec<(f64, f64)> = grid_result.path[1..]
215            .iter()
216            .map(|&cell| self.to_continuous(cell))
217            .collect();
218
219        // Anti-backtracking optimization on the last waypoint.
220        // If the second-to-last waypoint is closer to the goal than the last
221        // waypoint, remove the last waypoint to prevent overshooting.
222        // Mirrors Agents.jl's anti-backtracking logic.
223        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            // Only one waypoint (the last cell center); check if it's
234            // farther from goal than the start
235            let last = waypoints[0];
236            let last_to_goal = self.sqr_distance(last, to);
237            if last_to_goal < 1e-12 {
238                // Already at goal cell center
239                waypoints.clear();
240            }
241        }
242
243        // Append exact goal position (unless it's already the last waypoint)
244        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
259/// 3D continuous-space A* pathfinder.
260///
261/// Discretizes a 3D continuous space into a 3D voxel grid and runs A* on it.
262/// The 3D A* uses 26-connected (diagonal) or 6-connected (cardinal) neighbors.
263pub 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/// Result of a 3D continuous-space A* search.
277#[derive(Debug, Clone)]
278pub struct ContinuousPath3D {
279    /// Waypoints in continuous 3D coordinates.
280    pub waypoints: Vec<(f64, f64, f64)>,
281    /// Approximate total cost.
282    pub cost: f64,
283}
284
285impl<'a> ContinuousAStar3D<'a> {
286    /// Create a 3D continuous-space pathfinder.
287    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    /// Enable periodic boundaries.
324    pub fn periodic(mut self, periodic: bool) -> Self {
325        self.periodic = periodic;
326        self
327    }
328
329    /// Enable/disable diagonal movement (26- vs 6-connected).
330    pub fn diagonal(mut self, diagonal: bool) -> Self {
331        self.diagonal = diagonal;
332        self
333    }
334
335    /// Set admissibility factor.
336    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    /// Find a path between two 3D continuous positions.
392    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            // 3D octile distance: diagonal steps cost sqrt(3), face-diagonal sqrt(2), cardinal 1
427            let h = min_d * 1.7320508075688772  // sqrt(3)
428                + (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 not diagonal, only allow cardinal moves
447                        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, // ?3
483                                };
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        // Anti-backtracking
509        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}