Skip to main content

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
277                    && tentative_g >= *g_values.get(&neighbor_state).expect("Operation failed")
278                {
279                    continue;
280                }
281
282                // Update the g-value
283                g_values.insert(neighbor_state.clone(), tentative_g);
284
285                // Create a new node for the neighbor
286                let h = self.weight * heuristic_fn(&neighbor_state, &goal);
287                let neighbor_node = Rc::new(Node::new(
288                    neighbor_state,
289                    Some(Rc::clone(&current)),
290                    tentative_g,
291                    h,
292                ));
293
294                // Add the neighbor to the open set
295                open_set.push(neighbor_node);
296            }
297        }
298
299        // If we've exhausted the open set without finding the goal, there's no path
300        Ok(None)
301    }
302
303    // Reconstruct the path from the goal node to the start node
304    fn reconstruct_path<N: Clone + Eq + Hash>(goal: N, node: Rc<Node<N>>) -> Path<N> {
305        let mut path = Vec::new();
306        // The path's total cost is the accumulated g-value at the goal node.
307        // Capture it up front: the loop below walks backwards from goal to
308        // start via `parent`, and the start node's own g-value is always
309        // 0.0, so overwriting `cost` on every iteration (as a previous
310        // version of this function did) always produced 0.0 instead of the
311        // real path cost.
312        let cost = node.g;
313        let mut current = Some(node);
314
315        while let Some(_node) = current {
316            path.push(_node.state.clone());
317            current = _node.parent.clone();
318        }
319
320        // Reverse the path so it goes from start to goal
321        path.reverse();
322
323        Path::new(path, cost)
324    }
325}
326
327// Useful heuristic functions
328
329/// Manhattan distance heuristic for 2D grid-based pathfinding
330#[allow(dead_code)]
331pub fn manhattan_distance(a: &[i32; 2], b: &[i32; 2]) -> f64 {
332    ((a[0] - b[0]).abs() + (a[1] - b[1]).abs()) as f64
333}
334
335/// Euclidean distance heuristic for continuous 2D space
336#[allow(dead_code)]
337pub fn euclidean_distance_2d(a: &[f64; 2], b: &[f64; 2]) -> f64 {
338    let dx = a[0] - b[0];
339    let dy = a[1] - b[1];
340    (dx * dx + dy * dy).sqrt()
341}
342
343/// Euclidean distance for n-dimensional points
344#[allow(dead_code)]
345pub fn euclidean_distance(a: &ArrayView1<f64>, b: &ArrayView1<f64>) -> SpatialResult<f64> {
346    if a.len() != b.len() {
347        return Err(SpatialError::DimensionError(format!(
348            "Mismatched dimensions: {} and {}",
349            a.len(),
350            b.len()
351        )));
352    }
353
354    let mut sum = 0.0;
355    for i in 0..a.len() {
356        let diff = a[i] - b[i];
357        sum += diff * diff;
358    }
359
360    Ok(sum.sqrt())
361}
362
363/// Grid-based A* planner for 2D grids with obstacles
364#[derive(Clone)]
365pub struct GridAStarPlanner {
366    pub grid: Vec<Vec<bool>>, // true if cell is an obstacle
367    pub diagonalsallowed: bool,
368}
369
370impl GridAStarPlanner {
371    /// Create a new grid-based A* planner
372    ///
373    /// # Arguments
374    ///
375    /// * `grid` - 2D grid where true represents an obstacle
376    /// * `diagonalsallowed` - Whether diagonal movements are allowed
377    pub fn new(grid: Vec<Vec<bool>>, diagonalsallowed: bool) -> Self {
378        GridAStarPlanner {
379            grid,
380            diagonalsallowed,
381        }
382    }
383
384    /// Get the height of the grid
385    pub fn height(&self) -> usize {
386        self.grid.len()
387    }
388
389    /// Get the width of the grid
390    pub fn width(&self) -> usize {
391        if self.grid.is_empty() {
392            0
393        } else {
394            self.grid[0].len()
395        }
396    }
397
398    /// Check if a position is valid and not an obstacle
399    pub fn is_valid(&self, pos: &[i32; 2]) -> bool {
400        let (rows, cols) = (self.height() as i32, self.width() as i32);
401
402        if pos[0] < 0 || pos[0] >= rows || pos[1] < 0 || pos[1] >= cols {
403            return false;
404        }
405
406        !self.grid[pos[0] as usize][pos[1] as usize]
407    }
408
409    /// Get valid neighbors for a given position
410    fn get_neighbors(&self, pos: &[i32; 2]) -> Vec<([i32; 2], f64)> {
411        let mut neighbors = Vec::new();
412        let directions = if self.diagonalsallowed {
413            // Include diagonal directions
414            vec![
415                [-1, 0],
416                [1, 0],
417                [0, -1],
418                [0, 1], // Cardinal directions
419                [-1, -1],
420                [-1, 1],
421                [1, -1],
422                [1, 1], // Diagonal directions
423            ]
424        } else {
425            // Only cardinal directions
426            vec![[-1, 0], [1, 0], [0, -1], [0, 1]]
427        };
428
429        for dir in directions {
430            let neighbor = [pos[0] + dir[0], pos[1] + dir[1]];
431            if self.is_valid(&neighbor) {
432                // Cost is 1.0 for cardinal moves, sqrt(2) for diagonal moves
433                let cost = if dir[0] != 0 && dir[1] != 0 {
434                    std::f64::consts::SQRT_2
435                } else {
436                    1.0
437                };
438                neighbors.push((neighbor, cost));
439            }
440        }
441
442        neighbors
443    }
444
445    /// Find a path from start to goal using A* search
446    pub fn find_path(
447        &self,
448        start: [i32; 2],
449        goal: [i32; 2],
450    ) -> SpatialResult<Option<Path<[i32; 2]>>> {
451        // Check that start and goal are valid positions
452        if !self.is_valid(&start) {
453            return Err(SpatialError::ValueError(
454                "Start position is invalid or an obstacle".to_string(),
455            ));
456        }
457        if !self.is_valid(&goal) {
458            return Err(SpatialError::ValueError(
459                "Goal position is invalid or an obstacle".to_string(),
460            ));
461        }
462
463        let planner = AStarPlanner::new();
464        let grid_clone = self.clone();
465        let neighbors_fn = move |pos: &[i32; 2]| grid_clone.get_neighbors(pos);
466        let heuristic_fn = |a: &[i32; 2], b: &[i32; 2]| manhattan_distance(a, b);
467
468        planner.search(start, goal, &neighbors_fn, &heuristic_fn)
469    }
470}
471
472/// 2D continuous space A* planner with polygon obstacles
473#[derive(Clone)]
474pub struct ContinuousAStarPlanner {
475    /// Obstacle polygons (each polygon is a vector of 2D points)
476    pub obstacles: Vec<Vec<[f64; 2]>>,
477    /// Step size for edge discretization
478    pub step_size: f64,
479    /// Collision distance threshold
480    pub collisionthreshold: f64,
481}
482
483impl ContinuousAStarPlanner {
484    /// Create a new continuous space A* planner
485    pub fn new(obstacles: Vec<Vec<[f64; 2]>>, step_size: f64, collisionthreshold: f64) -> Self {
486        ContinuousAStarPlanner {
487            obstacles,
488            step_size,
489            collisionthreshold,
490        }
491    }
492
493    /// Check if a point is in collision with any obstacle
494    pub fn is_in_collision(&self, point: &[f64; 2]) -> bool {
495        for obstacle in &self.obstacles {
496            if Self::point_in_polygon(point, obstacle) {
497                return true;
498            }
499        }
500        false
501    }
502
503    /// Check if a line segment intersects with any obstacle
504    pub fn line_in_collision(&self, start: &[f64; 2], end: &[f64; 2]) -> bool {
505        // Discretize the line and check each point
506        let dx = end[0] - start[0];
507        let dy = end[1] - start[1];
508        let distance = (dx * dx + dy * dy).sqrt();
509        let steps = (distance / self.step_size).ceil() as usize;
510
511        if steps == 0 {
512            return self.is_in_collision(start) || self.is_in_collision(end);
513        }
514
515        for i in 0..=steps {
516            let t = i as f64 / steps as f64;
517            let x = start[0] + dx * t;
518            let y = start[1] + dy * t;
519            if self.is_in_collision(&[x, y]) {
520                return true;
521            }
522        }
523
524        false
525    }
526
527    /// Point-in-polygon test using ray casting algorithm
528    fn point_in_polygon(point: &[f64; 2], polygon: &[[f64; 2]]) -> bool {
529        if polygon.len() < 3 {
530            return false;
531        }
532
533        let mut inside = false;
534        let mut j = polygon.len() - 1;
535
536        for i in 0..polygon.len() {
537            let xi = polygon[i][0];
538            let yi = polygon[i][1];
539            let xj = polygon[j][0];
540            let yj = polygon[j][1];
541
542            let intersect = ((yi > point[1]) != (yj > point[1]))
543                && (point[0] < (xj - xi) * (point[1] - yi) / (yj - yi) + xi);
544
545            if intersect {
546                inside = !inside;
547            }
548
549            j = i;
550        }
551
552        inside
553    }
554
555    /// Get valid neighbors for continuous space planning
556    fn get_neighbors(&self, pos: &[f64; 2], radius: f64) -> Vec<([f64; 2], f64)> {
557        let mut neighbors = Vec::new();
558
559        // Generate neighbors in a circle around the current position
560        let num_samples = 8; // Number of directions to sample
561
562        for i in 0..num_samples {
563            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (num_samples as f64);
564            let nx = pos[0] + radius * angle.cos();
565            let ny = pos[1] + radius * angle.sin();
566            let neighbor = [nx, ny];
567
568            // Check if the path to the neighbor is collision-free
569            if !self.line_in_collision(pos, &neighbor) {
570                let cost = radius; // Cost is the distance
571                neighbors.push((neighbor, cost));
572            }
573        }
574
575        neighbors
576    }
577
578    /// Find a path from start to goal in continuous space
579    pub fn find_path(
580        &self,
581        start: [f64; 2],
582        goal: [f64; 2],
583        neighbor_radius: f64,
584    ) -> SpatialResult<Option<Path<[f64; 2]>>> {
585        // Create equality function for f64 points
586        #[derive(Clone, Hash, PartialEq, Eq)]
587        struct Point2D {
588            x: i64,
589            y: i64,
590        }
591
592        // Convert to discrete points for Eq/Hash
593        let precision = 1000.0; // 3 decimal places
594        let to_point = |p: [f64; 2]| -> Point2D {
595            Point2D {
596                x: (p[0] * precision).round() as i64,
597                y: (p[1] * precision).round() as i64,
598            }
599        };
600
601        let start_point = to_point(start);
602        let goal_point = to_point(goal);
603
604        // Check that start and goal are not in collision
605        if self.is_in_collision(&start) {
606            return Err(SpatialError::ValueError(
607                "Start position is in collision with an obstacle".to_string(),
608            ));
609        }
610        if self.is_in_collision(&goal) {
611            return Err(SpatialError::ValueError(
612                "Goal position is in collision with an obstacle".to_string(),
613            ));
614        }
615
616        // If there's a direct path, return it immediately
617        if !self.line_in_collision(&start, &goal) {
618            let path = vec![start, goal];
619            let cost = euclidean_distance_2d(&start, &goal);
620            return Ok(Some(Path::new(path, cost)));
621        }
622
623        let planner = AStarPlanner::new();
624        let radius = neighbor_radius;
625        let planner_clone = self.clone();
626
627        // Create neighbor and heuristic functions that work with the Point2D type
628        let neighbors_fn = move |pos: &Point2D| {
629            let float_pos = [pos.x as f64 / precision, pos.y as f64 / precision];
630            planner_clone
631                .get_neighbors(&float_pos, radius)
632                .into_iter()
633                .map(|(neighbor, cost)| (to_point(neighbor), cost))
634                .collect()
635        };
636
637        let heuristic_fn = |a: &Point2D, b: &Point2D| {
638            let a_float = [a.x as f64 / precision, a.y as f64 / precision];
639            let b_float = [b.x as f64 / precision, b.y as f64 / precision];
640            euclidean_distance_2d(&a_float, &b_float)
641        };
642
643        // Run A* search with discrete points
644        let result = planner.search(start_point, goal_point, &neighbors_fn, &heuristic_fn)?;
645
646        // Convert result back to f64 points
647        if let Some(path) = result {
648            let float_path = path
649                .nodes
650                .into_iter()
651                .map(|p| [p.x as f64 / precision, p.y as f64 / precision])
652                .collect();
653            Ok(Some(Path::new(float_path, path.cost)))
654        } else {
655            Ok(None)
656        }
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn test_astar_grid_no_obstacles() {
666        // Create a 5x5 grid with no obstacles
667        let grid = vec![
668            vec![false, false, false, false, false],
669            vec![false, false, false, false, false],
670            vec![false, false, false, false, false],
671            vec![false, false, false, false, false],
672            vec![false, false, false, false, false],
673        ];
674
675        let planner = GridAStarPlanner::new(grid, false);
676        let start = [0, 0];
677        let goal = [4, 4];
678
679        let path = planner
680            .find_path(start, goal)
681            .expect("Operation failed")
682            .expect("Operation failed");
683
684        // The path should exist
685        assert!(!path.is_empty());
686
687        // The path should start at the start position and end at the goal
688        assert_eq!(path.nodes.first().expect("Operation failed"), &start);
689        assert_eq!(path.nodes.last().expect("Operation failed"), &goal);
690
691        // The path length should be 9 (start, 7 steps, goal) with only cardinal moves
692        assert_eq!(path.len(), 9);
693
694        assert_eq!(path.cost, 8.0);
695    }
696
697    #[test]
698    fn test_astar_grid_with_obstacles() {
699        // Create a 5x5 grid with obstacles forming a wall
700        let grid = vec![
701            vec![false, false, false, false, false],
702            vec![false, false, false, false, false],
703            vec![false, true, true, true, false],
704            vec![false, false, false, false, false],
705            vec![false, false, false, false, false],
706        ];
707
708        let planner = GridAStarPlanner::new(grid, false);
709        let start = [1, 1];
710        let goal = [4, 3];
711
712        let path = planner
713            .find_path(start, goal)
714            .expect("Operation failed")
715            .expect("Operation failed");
716
717        // The path should exist
718        assert!(!path.is_empty());
719
720        // The path should start at the start position and end at the goal
721        assert_eq!(path.nodes.first().expect("Operation failed"), &start);
722        assert_eq!(path.nodes.last().expect("Operation failed"), &goal);
723
724        // The path should go around the obstacles
725        // Check that none of the path nodes are obstacles
726        for node in &path.nodes {
727            assert!(!planner.grid[node[0] as usize][node[1] as usize]);
728        }
729    }
730
731    #[test]
732    fn test_astar_grid_no_path() {
733        // Create a 5x5 grid with obstacles forming a complete wall
734        let grid = vec![
735            vec![false, false, false, false, false],
736            vec![false, false, false, false, false],
737            vec![true, true, true, true, true],
738            vec![false, false, false, false, false],
739            vec![false, false, false, false, false],
740        ];
741
742        let planner = GridAStarPlanner::new(grid, false);
743        let start = [1, 1];
744        let goal = [4, 1];
745
746        let path = planner.find_path(start, goal).expect("Operation failed");
747
748        // There should be no path
749        assert!(path.is_none());
750    }
751
752    #[test]
753    fn test_astar_grid_with_diagonals() {
754        // Create a 5x5 grid with no obstacles
755        let grid = vec![
756            vec![false, false, false, false, false],
757            vec![false, false, false, false, false],
758            vec![false, false, false, false, false],
759            vec![false, false, false, false, false],
760            vec![false, false, false, false, false],
761        ];
762
763        let planner = GridAStarPlanner::new(grid, true);
764        let start = [0, 0];
765        let goal = [4, 4];
766
767        let path = planner
768            .find_path(start, goal)
769            .expect("Operation failed")
770            .expect("Operation failed");
771
772        // The path should exist
773        assert!(!path.is_empty());
774
775        // The path should start at the start position and end at the goal
776        assert_eq!(path.nodes.first().expect("Operation failed"), &start);
777        assert_eq!(path.nodes.last().expect("Operation failed"), &goal);
778
779        // With diagonals, the path should be shorter (5 nodes: start, 3 diagonal steps, goal)
780        assert_eq!(path.len(), 5);
781
782        assert!((path.cost - 4.0 * std::f64::consts::SQRT_2).abs() < 1e-6);
783    }
784}