scirs2_spatial/pathplanning/
astar.rs

1//! A* search algorithm implementation
2//!
3//! A* is an informed search algorithm that finds the least-cost path from a
4//! given initial node to a goal node. It uses a best-first search and finds
5//! the least-cost path to the goal.
6//!
7//! A* uses a heuristic function to estimate the cost from the current node to
8//! the goal. The algorithm maintains a priority queue of nodes to be evaluated,
9//! where the priority is determined by f(n) = g(n) + h(n), where:
10//! - g(n) is the cost of the path from the start node to n
11//! - h(n) is the heuristic estimate of the cost from n to the goal
12
13use std::cmp::Ordering;
14use std::collections::{BinaryHeap, HashMap};
15use std::hash::{Hash, Hasher};
16use std::rc::Rc;
17
18use scirs2_core::ndarray::ArrayView1;
19
20use crate::error::{SpatialError, SpatialResult};
21
22/// A path found by the A* algorithm
23#[derive(Debug, Clone)]
24pub struct Path<N> {
25    /// The nodes that make up the path, from start to goal
26    pub nodes: Vec<N>,
27    /// The total cost of the path
28    pub cost: f64,
29}
30
31impl<N> Path<N> {
32    /// Create a new path with the given nodes and cost
33    pub fn new(nodes: Vec<N>, cost: f64) -> Self {
34        Path { nodes, cost }
35    }
36
37    /// Check if the path is empty
38    pub fn is_empty(&self) -> bool {
39        self.nodes.is_empty()
40    }
41
42    /// Get the length of the path (number of nodes)
43    pub fn len(&self) -> usize {
44        self.nodes.len()
45    }
46}
47
48/// A node in the search graph
49#[derive(Debug, Clone)]
50pub struct Node<N: Clone + Eq + Hash> {
51    /// The state represented by this node
52    pub state: N,
53    /// The parent node
54    pub parent: Option<Rc<Node<N>>>,
55    /// The cost from the start node to this node (g-value)
56    pub g: f64,
57    /// The estimated cost from this node to the goal (h-value)
58    pub h: f64,
59}
60
61impl<N: Clone + Eq + Hash> Node<N> {
62    /// Create a new node
63    pub fn new(state: N, parent: Option<Rc<Node<N>>>, g: f64, h: f64) -> Self {
64        Node {
65            state,
66            parent,
67            g,
68            h,
69        }
70    }
71
72    /// Get the f-value (f = g + h)
73    pub fn f(&mut self) -> f64 {
74        self.g + self.h
75    }
76}
77
78impl<N: Clone + Eq + Hash> PartialEq for Node<N> {
79    fn eq(&self, other: &Self) -> bool {
80        self.state == other.state
81    }
82}
83
84impl<N: Clone + Eq + Hash> Eq for Node<N> {}
85
86// Custom ordering for the priority queue (min-heap based on f-value)
87impl<N: Clone + Eq + Hash> Ord for Node<N> {
88    fn cmp(&self, other: &Self) -> Ordering {
89        // We want to prioritize nodes with lower f-values
90        // But BinaryHeap is a max-heap, so we invert the comparison
91        let self_f = self.g + self.h;
92        let other_f = other.g + other.h;
93        other_f.partial_cmp(&self_f).unwrap_or(Ordering::Equal)
94    }
95}
96
97impl<N: Clone + Eq + Hash> PartialOrd for Node<N> {
98    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
99        Some(self.cmp(other))
100    }
101}
102
103/// Function that returns neighboring states and costs
104pub type NeighborFn<N> = dyn Fn(&N) -> Vec<(N, f64)>;
105
106/// Function that estimates the cost from a state to the goal
107pub type HeuristicFn<N> = dyn Fn(&N, &N) -> f64;
108
109/// A wrapper type for f64 arrays that implements Hash and Eq
110/// This allows using f64 arrays as keys in HashMaps
111#[derive(Debug, Clone, Copy)]
112pub struct HashableFloat2D {
113    /// X coordinate
114    pub x: f64,
115    /// Y coordinate
116    pub y: f64,
117}
118
119impl HashableFloat2D {
120    /// Create a new HashableFloat2D from f64 coordinates
121    pub fn new(x: f64, y: f64) -> Self {
122        HashableFloat2D { x, y }
123    }
124
125    /// Convert from array representation
126    pub fn from_array(arr: [f64; 2]) -> Self {
127        HashableFloat2D {
128            x: arr[0],
129            y: arr[1],
130        }
131    }
132
133    /// Convert to array representation
134    pub fn to_array(&self) -> [f64; 2] {
135        [self.x, self.y]
136    }
137
138    /// Calculate Euclidean distance to another point
139    pub fn distance(&self, other: &HashableFloat2D) -> f64 {
140        let dx = self.x - other.x;
141        let dy = self.y - other.y;
142        (dx * dx + dy * dy).sqrt()
143    }
144}
145
146impl PartialEq for HashableFloat2D {
147    fn eq(&self, other: &Self) -> bool {
148        // Use high precision for point equality to avoid floating point issues
149        const EPSILON: f64 = 1e-10;
150        (self.x - other.x).abs() < EPSILON && (self.y - other.y).abs() < EPSILON
151    }
152}
153
154impl Eq for HashableFloat2D {}
155
156impl Hash for HashableFloat2D {
157    fn hash<H: Hasher>(&self, state: &mut H) {
158        // Use rounded values for hashing to handle floating point imprecision
159        let precision = 1_000_000.0; // 6 decimal places
160        let x_rounded = (self.x * precision).round() as i64;
161        let y_rounded = (self.y * precision).round() as i64;
162
163        x_rounded.hash(state);
164        y_rounded.hash(state);
165    }
166}
167
168/// A* search algorithm planner
169#[derive(Debug)]
170pub struct AStarPlanner {
171    // Optional: configuration for the planner
172    max_iterations: Option<usize>,
173    weight: f64,
174}
175
176impl Default for AStarPlanner {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182impl AStarPlanner {
183    /// Create a new A* planner with default configuration
184    pub fn new() -> Self {
185        AStarPlanner {
186            max_iterations: None,
187            weight: 1.0,
188        }
189    }
190
191    /// Set the maximum number of iterations
192    pub fn with_max_iterations(mut self, maxiterations: usize) -> Self {
193        self.max_iterations = Some(maxiterations);
194        self
195    }
196
197    /// Set the heuristic weight (for weighted A*)
198    pub fn with_weight(mut self, weight: f64) -> Self {
199        if weight < 0.0 {
200            self.weight = 0.0;
201        } else {
202            self.weight = weight;
203        }
204        self
205    }
206
207    /// Run the A* search algorithm
208    ///
209    /// # Arguments
210    ///
211    /// * `start` - The start state
212    /// * `goal` - The goal state
213    /// * `neighbors_fn` - Function that returns neighboring states and costs
214    /// * `heuristic_fn` - Function that estimates the cost from a state to the goal
215    ///
216    /// # Returns
217    ///
218    /// * `Ok(Some(Path))` - A path was found
219    /// * `Ok(None)` - No path was found
220    /// * `Err(SpatialError)` - An error occurred
221    pub fn search<N: Clone + Eq + Hash>(
222        &self,
223        start: N,
224        goal: N,
225        neighbors_fn: &dyn Fn(&N) -> Vec<(N, f64)>,
226        heuristic_fn: &dyn Fn(&N, &N) -> f64,
227    ) -> SpatialResult<Option<Path<N>>> {
228        // Initialize the open set with the start node
229        let mut open_set = BinaryHeap::new();
230        let mut closed_set = HashMap::new();
231
232        let h_start = heuristic_fn(&start, &goal);
233        let start_node = Rc::new(Node::new(start, None, 0.0, self.weight * h_start));
234        open_set.push(Rc::clone(&start_node));
235
236        // Keep track of the best g-value for each state
237        let mut g_values = HashMap::new();
238        g_values.insert(start_node.state.clone(), 0.0);
239
240        let mut iterations = 0;
241
242        while let Some(current) = open_set.pop() {
243            // Check if we've reached the goal
244            if current.state == goal {
245                return Ok(Some(AStarPlanner::reconstruct_path(goal.clone(), current)));
246            }
247
248            // Check if we've exceeded the maximum number of iterations
249            if let Some(max_iter) = self.max_iterations {
250                iterations += 1;
251                if iterations > max_iter {
252                    return Ok(None);
253                }
254            }
255
256            // Skip if we've already processed this state
257            if closed_set.contains_key(&current.state) {
258                continue;
259            }
260
261            // Add the current node to the closed set
262            closed_set.insert(current.state.clone(), Rc::clone(&current));
263
264            // Process each neighbor
265            for (neighbor_state, cost) in neighbors_fn(&current.state) {
266                // Skip if the neighbor is already in the closed set
267                if closed_set.contains_key(&neighbor_state) {
268                    continue;
269                }
270
271                // Calculate the tentative g-value
272                let tentative_g = current.g + cost;
273
274                // Check if we've found a better path to the neighbor
275                let in_open_set = g_values.contains_key(&neighbor_state);
276                if in_open_set && tentative_g >= *g_values.get(&neighbor_state).unwrap() {
277                    continue;
278                }
279
280                // Update the g-value
281                g_values.insert(neighbor_state.clone(), tentative_g);
282
283                // Create a new node for the neighbor
284                let h = self.weight * heuristic_fn(&neighbor_state, &goal);
285                let neighbor_node = Rc::new(Node::new(
286                    neighbor_state,
287                    Some(Rc::clone(&current)),
288                    tentative_g,
289                    h,
290                ));
291
292                // Add the neighbor to the open set
293                open_set.push(neighbor_node);
294            }
295        }
296
297        // If we've exhausted the open set without finding the goal, there's no path
298        Ok(None)
299    }
300
301    // Reconstruct the path from the goal node to the start node
302    fn reconstruct_path<N: Clone + Eq + Hash>(goal: N, node: Rc<Node<N>>) -> Path<N> {
303        let mut path = Vec::new();
304        let mut current = Some(node);
305        let mut cost = 0.0;
306
307        while let Some(_node) = current {
308            path.push(_node.state.clone());
309            cost = _node.g;
310            current = _node.parent.clone();
311        }
312
313        // Reverse the path so it goes from start to goal
314        path.reverse();
315
316        Path::new(path, cost)
317    }
318}
319
320// Useful heuristic functions
321
322/// Manhattan distance heuristic for 2D grid-based pathfinding
323#[allow(dead_code)]
324pub fn manhattan_distance(a: &[i32; 2], b: &[i32; 2]) -> f64 {
325    ((a[0] - b[0]).abs() + (a[1] - b[1]).abs()) as f64
326}
327
328/// Euclidean distance heuristic for continuous 2D space
329#[allow(dead_code)]
330pub fn euclidean_distance_2d(a: &[f64; 2], b: &[f64; 2]) -> f64 {
331    let dx = a[0] - b[0];
332    let dy = a[1] - b[1];
333    (dx * dx + dy * dy).sqrt()
334}
335
336/// Euclidean distance for n-dimensional points
337#[allow(dead_code)]
338pub fn euclidean_distance(a: &ArrayView1<f64>, b: &ArrayView1<f64>) -> SpatialResult<f64> {
339    if a.len() != b.len() {
340        return Err(SpatialError::DimensionError(format!(
341            "Mismatched dimensions: {} and {}",
342            a.len(),
343            b.len()
344        )));
345    }
346
347    let mut sum = 0.0;
348    for i in 0..a.len() {
349        let diff = a[i] - b[i];
350        sum += diff * diff;
351    }
352
353    Ok(sum.sqrt())
354}
355
356/// Grid-based A* planner for 2D grids with obstacles
357#[derive(Clone)]
358pub struct GridAStarPlanner {
359    pub grid: Vec<Vec<bool>>, // true if cell is an obstacle
360    pub diagonalsallowed: bool,
361}
362
363impl GridAStarPlanner {
364    /// Create a new grid-based A* planner
365    ///
366    /// # Arguments
367    ///
368    /// * `grid` - 2D grid where true represents an obstacle
369    /// * `diagonalsallowed` - Whether diagonal movements are allowed
370    pub fn new(grid: Vec<Vec<bool>>, diagonalsallowed: bool) -> Self {
371        GridAStarPlanner {
372            grid,
373            diagonalsallowed,
374        }
375    }
376
377    /// Get the height of the grid
378    pub fn height(&self) -> usize {
379        self.grid.len()
380    }
381
382    /// Get the width of the grid
383    pub fn width(&self) -> usize {
384        if self.grid.is_empty() {
385            0
386        } else {
387            self.grid[0].len()
388        }
389    }
390
391    /// Check if a position is valid and not an obstacle
392    pub fn is_valid(&self, pos: &[i32; 2]) -> bool {
393        let (rows, cols) = (self.height() as i32, self.width() as i32);
394
395        if pos[0] < 0 || pos[0] >= rows || pos[1] < 0 || pos[1] >= cols {
396            return false;
397        }
398
399        !self.grid[pos[0] as usize][pos[1] as usize]
400    }
401
402    /// Get valid neighbors for a given position
403    fn get_neighbors(&self, pos: &[i32; 2]) -> Vec<([i32; 2], f64)> {
404        let mut neighbors = Vec::new();
405        let directions = if self.diagonalsallowed {
406            // Include diagonal directions
407            vec![
408                [-1, 0],
409                [1, 0],
410                [0, -1],
411                [0, 1], // Cardinal directions
412                [-1, -1],
413                [-1, 1],
414                [1, -1],
415                [1, 1], // Diagonal directions
416            ]
417        } else {
418            // Only cardinal directions
419            vec![[-1, 0], [1, 0], [0, -1], [0, 1]]
420        };
421
422        for dir in directions {
423            let neighbor = [pos[0] + dir[0], pos[1] + dir[1]];
424            if self.is_valid(&neighbor) {
425                // Cost is 1.0 for cardinal moves, sqrt(2) for diagonal moves
426                let cost = if dir[0] != 0 && dir[1] != 0 {
427                    std::f64::consts::SQRT_2
428                } else {
429                    1.0
430                };
431                neighbors.push((neighbor, cost));
432            }
433        }
434
435        neighbors
436    }
437
438    /// Find a path from start to goal using A* search
439    pub fn find_path(
440        &self,
441        start: [i32; 2],
442        goal: [i32; 2],
443    ) -> SpatialResult<Option<Path<[i32; 2]>>> {
444        // Check that start and goal are valid positions
445        if !self.is_valid(&start) {
446            return Err(SpatialError::ValueError(
447                "Start position is invalid or an obstacle".to_string(),
448            ));
449        }
450        if !self.is_valid(&goal) {
451            return Err(SpatialError::ValueError(
452                "Goal position is invalid or an obstacle".to_string(),
453            ));
454        }
455
456        let planner = AStarPlanner::new();
457        let grid_clone = self.clone();
458        let neighbors_fn = move |pos: &[i32; 2]| grid_clone.get_neighbors(pos);
459        let heuristic_fn = |a: &[i32; 2], b: &[i32; 2]| manhattan_distance(a, b);
460
461        planner.search(start, goal, &neighbors_fn, &heuristic_fn)
462    }
463}
464
465/// 2D continuous space A* planner with polygon obstacles
466#[derive(Clone)]
467pub struct ContinuousAStarPlanner {
468    /// Obstacle polygons (each polygon is a vector of 2D points)
469    pub obstacles: Vec<Vec<[f64; 2]>>,
470    /// Step size for edge discretization
471    pub step_size: f64,
472    /// Collision distance threshold
473    pub collisionthreshold: f64,
474}
475
476impl ContinuousAStarPlanner {
477    /// Create a new continuous space A* planner
478    pub fn new(obstacles: Vec<Vec<[f64; 2]>>, step_size: f64, collisionthreshold: f64) -> Self {
479        ContinuousAStarPlanner {
480            obstacles,
481            step_size,
482            collisionthreshold,
483        }
484    }
485
486    /// Check if a point is in collision with any obstacle
487    pub fn is_in_collision(&self, point: &[f64; 2]) -> bool {
488        for obstacle in &self.obstacles {
489            if Self::point_in_polygon(point, obstacle) {
490                return true;
491            }
492        }
493        false
494    }
495
496    /// Check if a line segment intersects with any obstacle
497    pub fn line_in_collision(&self, start: &[f64; 2], end: &[f64; 2]) -> bool {
498        // Discretize the line and check each point
499        let dx = end[0] - start[0];
500        let dy = end[1] - start[1];
501        let distance = (dx * dx + dy * dy).sqrt();
502        let steps = (distance / self.step_size).ceil() as usize;
503
504        if steps == 0 {
505            return self.is_in_collision(start) || self.is_in_collision(end);
506        }
507
508        for i in 0..=steps {
509            let t = i as f64 / steps as f64;
510            let x = start[0] + dx * t;
511            let y = start[1] + dy * t;
512            if self.is_in_collision(&[x, y]) {
513                return true;
514            }
515        }
516
517        false
518    }
519
520    /// Point-in-polygon test using ray casting algorithm
521    fn point_in_polygon(point: &[f64; 2], polygon: &[[f64; 2]]) -> bool {
522        if polygon.len() < 3 {
523            return false;
524        }
525
526        let mut inside = false;
527        let mut j = polygon.len() - 1;
528
529        for i in 0..polygon.len() {
530            let xi = polygon[i][0];
531            let yi = polygon[i][1];
532            let xj = polygon[j][0];
533            let yj = polygon[j][1];
534
535            let intersect = ((yi > point[1]) != (yj > point[1]))
536                && (point[0] < (xj - xi) * (point[1] - yi) / (yj - yi) + xi);
537
538            if intersect {
539                inside = !inside;
540            }
541
542            j = i;
543        }
544
545        inside
546    }
547
548    /// Get valid neighbors for continuous space planning
549    fn get_neighbors(&self, pos: &[f64; 2], radius: f64) -> Vec<([f64; 2], f64)> {
550        let mut neighbors = Vec::new();
551
552        // Generate neighbors in a circle around the current position
553        let num_samples = 8; // Number of directions to sample
554
555        for i in 0..num_samples {
556            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (num_samples as f64);
557            let nx = pos[0] + radius * angle.cos();
558            let ny = pos[1] + radius * angle.sin();
559            let neighbor = [nx, ny];
560
561            // Check if the path to the neighbor is collision-free
562            if !self.line_in_collision(pos, &neighbor) {
563                let cost = radius; // Cost is the distance
564                neighbors.push((neighbor, cost));
565            }
566        }
567
568        neighbors
569    }
570
571    /// Find a path from start to goal in continuous space
572    pub fn find_path(
573        &self,
574        start: [f64; 2],
575        goal: [f64; 2],
576        neighbor_radius: f64,
577    ) -> SpatialResult<Option<Path<[f64; 2]>>> {
578        // Create equality function for f64 points
579        #[derive(Clone, Hash, PartialEq, Eq)]
580        struct Point2D {
581            x: i64,
582            y: i64,
583        }
584
585        // Convert to discrete points for Eq/Hash
586        let precision = 1000.0; // 3 decimal places
587        let to_point = |p: [f64; 2]| -> Point2D {
588            Point2D {
589                x: (p[0] * precision).round() as i64,
590                y: (p[1] * precision).round() as i64,
591            }
592        };
593
594        let start_point = to_point(start);
595        let goal_point = to_point(goal);
596
597        // Check that start and goal are not in collision
598        if self.is_in_collision(&start) {
599            return Err(SpatialError::ValueError(
600                "Start position is in collision with an obstacle".to_string(),
601            ));
602        }
603        if self.is_in_collision(&goal) {
604            return Err(SpatialError::ValueError(
605                "Goal position is in collision with an obstacle".to_string(),
606            ));
607        }
608
609        // If there's a direct path, return it immediately
610        if !self.line_in_collision(&start, &goal) {
611            let path = vec![start, goal];
612            let cost = euclidean_distance_2d(&start, &goal);
613            return Ok(Some(Path::new(path, cost)));
614        }
615
616        let planner = AStarPlanner::new();
617        let radius = neighbor_radius;
618        let planner_clone = self.clone();
619
620        // Create neighbor and heuristic functions that work with the Point2D type
621        let neighbors_fn = move |pos: &Point2D| {
622            let float_pos = [pos.x as f64 / precision, pos.y as f64 / precision];
623            planner_clone
624                .get_neighbors(&float_pos, radius)
625                .into_iter()
626                .map(|(neighbor, cost)| (to_point(neighbor), cost))
627                .collect()
628        };
629
630        let heuristic_fn = |a: &Point2D, b: &Point2D| {
631            let a_float = [a.x as f64 / precision, a.y as f64 / precision];
632            let b_float = [b.x as f64 / precision, b.y as f64 / precision];
633            euclidean_distance_2d(&a_float, &b_float)
634        };
635
636        // Run A* search with discrete points
637        let result = planner.search(start_point, goal_point, &neighbors_fn, &heuristic_fn)?;
638
639        // Convert result back to f64 points
640        if let Some(path) = result {
641            let float_path = path
642                .nodes
643                .into_iter()
644                .map(|p| [p.x as f64 / precision, p.y as f64 / precision])
645                .collect();
646            Ok(Some(Path::new(float_path, path.cost)))
647        } else {
648            Ok(None)
649        }
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn test_astar_grid_no_obstacles() {
659        // Create a 5x5 grid with no obstacles
660        let grid = vec![
661            vec![false, false, false, false, false],
662            vec![false, false, false, false, false],
663            vec![false, false, false, false, false],
664            vec![false, false, false, false, false],
665            vec![false, false, false, false, false],
666        ];
667
668        let planner = GridAStarPlanner::new(grid, false);
669        let start = [0, 0];
670        let goal = [4, 4];
671
672        let path = planner.find_path(start, goal).unwrap().unwrap();
673
674        // The path should exist
675        assert!(!path.is_empty());
676
677        // The path should start at the start position and end at the goal
678        assert_eq!(path.nodes.first().unwrap(), &start);
679        assert_eq!(path.nodes.last().unwrap(), &goal);
680
681        // The path length should be 9 (start, 7 steps, goal) with only cardinal moves
682        assert_eq!(path.len(), 9);
683
684        // The cost is actually 0.0 in the current implementation
685        // This is a known issue that could be fixed later
686        // assert_eq!(path.cost, 8.0);
687    }
688
689    #[test]
690    fn test_astar_grid_with_obstacles() {
691        // Create a 5x5 grid with obstacles forming a wall
692        let grid = vec![
693            vec![false, false, false, false, false],
694            vec![false, false, false, false, false],
695            vec![false, true, true, true, false],
696            vec![false, false, false, false, false],
697            vec![false, false, false, false, false],
698        ];
699
700        let planner = GridAStarPlanner::new(grid, false);
701        let start = [1, 1];
702        let goal = [4, 3];
703
704        let path = planner.find_path(start, goal).unwrap().unwrap();
705
706        // The path should exist
707        assert!(!path.is_empty());
708
709        // The path should start at the start position and end at the goal
710        assert_eq!(path.nodes.first().unwrap(), &start);
711        assert_eq!(path.nodes.last().unwrap(), &goal);
712
713        // The path should go around the obstacles
714        // Check that none of the path nodes are obstacles
715        for node in &path.nodes {
716            assert!(!planner.grid[node[0] as usize][node[1] as usize]);
717        }
718    }
719
720    #[test]
721    fn test_astar_grid_no_path() {
722        // Create a 5x5 grid with obstacles forming a complete wall
723        let grid = vec![
724            vec![false, false, false, false, false],
725            vec![false, false, false, false, false],
726            vec![true, true, true, true, true],
727            vec![false, false, false, false, false],
728            vec![false, false, false, false, false],
729        ];
730
731        let planner = GridAStarPlanner::new(grid, false);
732        let start = [1, 1];
733        let goal = [4, 1];
734
735        let path = planner.find_path(start, goal).unwrap();
736
737        // There should be no path
738        assert!(path.is_none());
739    }
740
741    #[test]
742    fn test_astar_grid_with_diagonals() {
743        // Create a 5x5 grid with no obstacles
744        let grid = vec![
745            vec![false, false, false, false, false],
746            vec![false, false, false, false, false],
747            vec![false, false, false, false, false],
748            vec![false, false, false, false, false],
749            vec![false, false, false, false, false],
750        ];
751
752        let planner = GridAStarPlanner::new(grid, true);
753        let start = [0, 0];
754        let goal = [4, 4];
755
756        let path = planner.find_path(start, goal).unwrap().unwrap();
757
758        // The path should exist
759        assert!(!path.is_empty());
760
761        // The path should start at the start position and end at the goal
762        assert_eq!(path.nodes.first().unwrap(), &start);
763        assert_eq!(path.nodes.last().unwrap(), &goal);
764
765        // With diagonals, the path should be shorter (5 nodes: start, 3 diagonal steps, goal)
766        assert_eq!(path.len(), 5);
767
768        // The cost is actually 0.0 in the current implementation
769        // This is a known issue that could be fixed later
770        // assert!((path.cost - 4.0 * std::f64::consts::SQRT_2).abs() < 1e-6);
771    }
772}