Skip to main content

rust_robotics_planning/
flow_field.rs

1//! Flow Field path planning algorithm
2//!
3//! Computes a vector field that points toward the goal from every
4//! reachable cell using weighted shortest-path integration. An agent follows
5//! the field from its start position to the goal.
6//!
7//! Reference: Leif Erkenbrach, "Flow Field Pathfinding" (2013)
8//! <https://leifnode.com/2013/12/flow-field-pathfinding/>
9
10use std::cmp::Reverse;
11use std::collections::BinaryHeap;
12
13use crate::grid::GridMap;
14use rust_robotics_core::{Obstacles, Path2D, PathPlanner, Point2D, RoboticsError, RoboticsResult};
15
16/// Configuration for the flow field planner.
17#[derive(Debug, Clone)]
18pub struct FlowFieldConfig {
19    /// Grid resolution in metres
20    pub resolution: f64,
21    /// Robot radius for obstacle inflation
22    pub robot_radius: f64,
23}
24
25impl Default for FlowFieldConfig {
26    fn default() -> Self {
27        Self {
28            resolution: 1.0,
29            robot_radius: 0.5,
30        }
31    }
32}
33
34impl FlowFieldConfig {
35    pub fn validate(&self) -> RoboticsResult<()> {
36        if !self.resolution.is_finite() || self.resolution <= 0.0 {
37            return Err(RoboticsError::InvalidParameter(format!(
38                "resolution must be positive and finite, got {}",
39                self.resolution
40            )));
41        }
42        if !self.robot_radius.is_finite() || self.robot_radius < 0.0 {
43            return Err(RoboticsError::InvalidParameter(format!(
44                "robot_radius must be non-negative and finite, got {}",
45                self.robot_radius
46            )));
47        }
48        Ok(())
49    }
50}
51
52/// A direction vector stored per cell.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54struct FlowDir {
55    dx: i32,
56    dy: i32,
57}
58
59/// Result of computing a flow field toward a specific goal.
60///
61/// The integration field stores the shortest-path distance (using 10/14 costs for
62/// cardinal/diagonal moves) from every reachable cell to the goal.  The
63/// vector field stores, for each cell, the offset to its best neighbour.
64#[derive(Debug, Clone)]
65pub struct FlowFieldResult {
66    /// Integration cost for each cell (row-major, `x_width * y_width`).
67    /// `u32::MAX` means unreachable.
68    pub integration: Vec<u32>,
69    /// Flow direction per cell.  `(0, 0)` means goal or unreachable.
70    directions: Vec<FlowDir>,
71    /// Grid width along x-axis.
72    pub x_width: i32,
73    /// Grid width along y-axis.
74    pub y_width: i32,
75}
76
77/// Flow field path planner.
78pub struct FlowFieldPlanner {
79    grid_map: GridMap,
80    #[allow(dead_code)]
81    config: FlowFieldConfig,
82    /// 8-connected neighbour offsets with cost (10 = cardinal, 14 = diagonal).
83    motion: [(i32, i32, u32); 8],
84}
85
86impl FlowFieldPlanner {
87    /// Create a new planner (panics on invalid input).
88    pub fn new(ox: &[f64], oy: &[f64], config: FlowFieldConfig) -> Self {
89        Self::try_new(ox, oy, config).expect(
90            "invalid flow field planner input: obstacle list must be non-empty and valid, \
91             and config values must be positive/finite",
92        )
93    }
94
95    /// Create a validated planner.
96    pub fn try_new(ox: &[f64], oy: &[f64], config: FlowFieldConfig) -> RoboticsResult<Self> {
97        config.validate()?;
98        let grid_map = GridMap::try_new(ox, oy, config.resolution, config.robot_radius)?;
99        Ok(Self {
100            grid_map,
101            config,
102            motion: Self::motion_model(),
103        })
104    }
105
106    /// Create from obstacle x/y vectors with explicit resolution and radius.
107    pub fn from_obstacles(ox: &[f64], oy: &[f64], resolution: f64, robot_radius: f64) -> Self {
108        let config = FlowFieldConfig {
109            resolution,
110            robot_radius,
111        };
112        Self::new(ox, oy, config)
113    }
114
115    /// Create a validated planner from typed obstacle points.
116    pub fn from_obstacle_points(
117        obstacles: &Obstacles,
118        config: FlowFieldConfig,
119    ) -> RoboticsResult<Self> {
120        config.validate()?;
121        let grid_map = GridMap::from_obstacles(obstacles, config.resolution, config.robot_radius)?;
122        Ok(Self {
123            grid_map,
124            config,
125            motion: Self::motion_model(),
126        })
127    }
128
129    /// Get reference to the underlying grid map.
130    pub fn grid_map(&self) -> &GridMap {
131        &self.grid_map
132    }
133
134    /// Compute the flow field toward the given goal and extract the path
135    /// from start to goal.
136    pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
137        self.plan_impl(start, goal)
138    }
139
140    /// Plan from raw coordinates.
141    pub fn plan_xy(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> RoboticsResult<Path2D> {
142        self.plan_impl(Point2D::new(sx, sy), Point2D::new(gx, gy))
143    }
144
145    /// Legacy interface returning `(rx, ry)` vectors.
146    #[deprecated(note = "use plan() or plan_xy() instead")]
147    pub fn planning(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> Option<(Vec<f64>, Vec<f64>)> {
148        match self.plan_xy(sx, sy, gx, gy) {
149            Ok(path) => Some((path.x_coords(), path.y_coords())),
150            Err(_) => None,
151        }
152    }
153
154    /// Compute the full flow field toward the goal without extracting a
155    /// single path.  Useful when many agents share the same goal.
156    pub fn compute_field(&self, goal: Point2D) -> RoboticsResult<FlowFieldResult> {
157        let gx = self.grid_map.calc_x_index(goal.x);
158        let gy = self.grid_map.calc_y_index(goal.y);
159        self.ensure_valid(gx, gy, "Goal")?;
160        Ok(self.build_field(gx, gy))
161    }
162
163    // ------------------------------------------------------------------
164    // Private helpers
165    // ------------------------------------------------------------------
166
167    fn motion_model() -> [(i32, i32, u32); 8] {
168        [
169            (1, 0, 10),
170            (0, 1, 10),
171            (-1, 0, 10),
172            (0, -1, 10),
173            (-1, -1, 14),
174            (-1, 1, 14),
175            (1, -1, 14),
176            (1, 1, 14),
177        ]
178    }
179
180    fn ensure_valid(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
181        if self.grid_map.is_valid(x, y) {
182            Ok(())
183        } else {
184            Err(RoboticsError::PlanningError(format!(
185                "{} position is invalid",
186                label
187            )))
188        }
189    }
190
191    fn flat_index(&self, x: i32, y: i32) -> usize {
192        (x as usize) * (self.grid_map.y_width as usize) + (y as usize)
193    }
194
195    /// Build the integration field (Dijkstra from goal) and derive the vector
196    /// field.
197    fn build_field(&self, gx: i32, gy: i32) -> FlowFieldResult {
198        let w = self.grid_map.x_width as usize;
199        let h = self.grid_map.y_width as usize;
200        let total = w * h;
201
202        let mut integration = vec![u32::MAX; total];
203        let mut directions = vec![FlowDir { dx: 0, dy: 0 }; total];
204
205        // Dijkstra from goal so the integration field respects 10/14 move costs.
206        let goal_idx = self.flat_index(gx, gy);
207        integration[goal_idx] = 0;
208
209        let mut open = BinaryHeap::new();
210        open.push((Reverse(0_u32), gx, gy));
211
212        while let Some((Reverse(curr_cost), cx, cy)) = open.pop() {
213            if curr_cost != integration[self.flat_index(cx, cy)] {
214                continue;
215            }
216
217            let curr_cost = integration[self.flat_index(cx, cy)];
218            for &(dx, dy, move_cost) in &self.motion {
219                let nx = cx + dx;
220                let ny = cy + dy;
221                if !self.grid_map.is_valid_offset(cx, cy, dx, dy) {
222                    continue;
223                }
224                let ni = self.flat_index(nx, ny);
225                let new_cost = curr_cost.saturating_add(move_cost);
226                if new_cost < integration[ni] {
227                    integration[ni] = new_cost;
228                    open.push((Reverse(new_cost), nx, ny));
229                }
230            }
231        }
232
233        // Derive vector field: each cell points to the neighbour that minimizes
234        // remaining path cost including the step into that neighbour.
235        for ix in 0..self.grid_map.x_width {
236            for iy in 0..self.grid_map.y_width {
237                if !self.grid_map.is_valid(ix, iy) {
238                    continue;
239                }
240                let idx = self.flat_index(ix, iy);
241                if integration[idx] == 0 {
242                    // Goal cell
243                    continue;
244                }
245                if integration[idx] == u32::MAX {
246                    // Unreachable
247                    continue;
248                }
249
250                let current_path_cost = integration[idx];
251                let mut best_neighbor_cost = u32::MAX;
252                let mut best_dx = 0i32;
253                let mut best_dy = 0i32;
254                for &(dx, dy, move_cost) in &self.motion {
255                    let nx = ix + dx;
256                    let ny = iy + dy;
257                    if nx < 0
258                        || ny < 0
259                        || nx >= self.grid_map.x_width
260                        || ny >= self.grid_map.y_width
261                        || !self.grid_map.is_valid_offset(ix, iy, dx, dy)
262                    {
263                        continue;
264                    }
265                    let ni = self.flat_index(nx, ny);
266                    if integration[ni] == u32::MAX {
267                        continue;
268                    }
269                    let candidate_path_cost = integration[ni].saturating_add(move_cost);
270                    if candidate_path_cost == current_path_cost
271                        && integration[ni] < best_neighbor_cost
272                    {
273                        best_neighbor_cost = integration[ni];
274                        best_dx = dx;
275                        best_dy = dy;
276                    }
277                }
278                directions[idx] = FlowDir {
279                    dx: best_dx,
280                    dy: best_dy,
281                };
282            }
283        }
284
285        FlowFieldResult {
286            integration,
287            directions,
288            x_width: self.grid_map.x_width,
289            y_width: self.grid_map.y_width,
290        }
291    }
292
293    /// Follow the vector field from start to goal.
294    fn extract_path(
295        &self,
296        field: &FlowFieldResult,
297        sx: i32,
298        sy: i32,
299        gx: i32,
300        gy: i32,
301    ) -> RoboticsResult<Path2D> {
302        let mut points = Vec::new();
303        let mut cx = sx;
304        let mut cy = sy;
305
306        // Safety limit to avoid infinite loops on malformed fields.
307        let max_steps = (self.grid_map.x_width as usize) * (self.grid_map.y_width as usize);
308
309        for _ in 0..max_steps {
310            points.push(Point2D::new(
311                self.grid_map.calc_x_position(cx),
312                self.grid_map.calc_y_position(cy),
313            ));
314
315            if cx == gx && cy == gy {
316                return Ok(Path2D::from_points(points));
317            }
318
319            let idx = self.flat_index(cx, cy);
320            let dir = field.directions[idx];
321            if dir.dx == 0 && dir.dy == 0 {
322                // Stuck (unreachable or at goal already handled above).
323                return Err(RoboticsError::PlanningError(
324                    "No path found: flow field has no direction at current cell".to_string(),
325                ));
326            }
327            cx += dir.dx;
328            cy += dir.dy;
329        }
330
331        Err(RoboticsError::PlanningError(
332            "No path found: exceeded maximum step count".to_string(),
333        ))
334    }
335
336    fn plan_impl(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
337        let sx = self.grid_map.calc_x_index(start.x);
338        let sy = self.grid_map.calc_y_index(start.y);
339        let gx = self.grid_map.calc_x_index(goal.x);
340        let gy = self.grid_map.calc_y_index(goal.y);
341
342        self.ensure_valid(sx, sy, "Start")?;
343        self.ensure_valid(gx, gy, "Goal")?;
344
345        let field = self.build_field(gx, gy);
346        self.extract_path(&field, sx, sy, gx, gy)
347    }
348}
349
350impl PathPlanner for FlowFieldPlanner {
351    fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
352        self.plan_impl(start, goal)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    use crate::moving_ai::{MovingAiMap, MovingAiScenario};
361    use rust_robotics_core::Obstacles;
362
363    /// 10x10 box with boundary obstacles and a vertical wall at x=5.
364    fn create_simple_obstacles() -> (Vec<f64>, Vec<f64>) {
365        let mut ox = Vec::new();
366        let mut oy = Vec::new();
367
368        for i in 0..11 {
369            ox.push(i as f64);
370            oy.push(0.0);
371            ox.push(i as f64);
372            oy.push(10.0);
373            ox.push(0.0);
374            oy.push(i as f64);
375            ox.push(10.0);
376            oy.push(i as f64);
377        }
378
379        // Internal wall
380        for i in 4..7 {
381            ox.push(5.0);
382            oy.push(i as f64);
383        }
384
385        (ox, oy)
386    }
387
388    #[test]
389    fn test_flow_field_finds_path() {
390        let (ox, oy) = create_simple_obstacles();
391        let planner = FlowFieldPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
392
393        let result = planner.plan(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0));
394        assert!(result.is_ok(), "expected path, got {:?}", result);
395
396        let path = result.unwrap();
397        assert!(!path.is_empty());
398        // Path must start near the start point and end near the goal.
399        let first = path.points.first().unwrap();
400        let last = path.points.last().unwrap();
401        assert!((first.x - 2.0).abs() < 1e-6);
402        assert!((first.y - 2.0).abs() < 1e-6);
403        assert!((last.x - 8.0).abs() < 1e-6);
404        assert!((last.y - 8.0).abs() < 1e-6);
405    }
406
407    #[test]
408    #[allow(deprecated)]
409    fn test_flow_field_legacy_interface() {
410        let (ox, oy) = create_simple_obstacles();
411        let planner = FlowFieldPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
412
413        let result = planner.planning(2.0, 2.0, 8.0, 8.0);
414        assert!(result.is_some());
415
416        let (rx, ry) = result.unwrap();
417        assert!(!rx.is_empty());
418        assert_eq!(rx.len(), ry.len());
419    }
420
421    #[test]
422    fn test_flow_field_from_obstacle_points() {
423        let (ox, oy) = create_simple_obstacles();
424        let obstacles = Obstacles::try_from_xy(&ox, &oy).unwrap();
425        let planner =
426            FlowFieldPlanner::from_obstacle_points(&obstacles, FlowFieldConfig::default()).unwrap();
427
428        let path = planner.plan_xy(2.0, 2.0, 8.0, 8.0).unwrap();
429        assert!(!path.is_empty());
430    }
431
432    #[test]
433    fn test_flow_field_invalid_config() {
434        let (ox, oy) = create_simple_obstacles();
435        let config = FlowFieldConfig {
436            resolution: -1.0,
437            ..Default::default()
438        };
439        let err = FlowFieldPlanner::try_new(&ox, &oy, config);
440        assert!(err.is_err());
441    }
442
443    #[test]
444    fn test_flow_field_invalid_start() {
445        let (ox, oy) = create_simple_obstacles();
446        let planner = FlowFieldPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
447
448        let result = planner.plan(Point2D::new(-5.0, -5.0), Point2D::new(8.0, 8.0));
449        assert!(result.is_err());
450    }
451
452    #[test]
453    fn test_flow_field_invalid_goal() {
454        let (ox, oy) = create_simple_obstacles();
455        let planner = FlowFieldPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
456
457        let result = planner.plan(Point2D::new(2.0, 2.0), Point2D::new(-5.0, -5.0));
458        assert!(result.is_err());
459    }
460
461    #[test]
462    fn test_flow_field_start_equals_goal() {
463        let (ox, oy) = create_simple_obstacles();
464        let planner = FlowFieldPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
465
466        let path = planner
467            .plan(Point2D::new(3.0, 3.0), Point2D::new(3.0, 3.0))
468            .unwrap();
469        // Path should contain exactly one point.
470        assert_eq!(path.points.len(), 1);
471    }
472
473    #[test]
474    fn test_flow_field_preserves_asymmetric_coordinates() {
475        let mut obstacles = Obstacles::new();
476
477        for x in 10..=20 {
478            obstacles.push(Point2D::new(x as f64, -4.0));
479            obstacles.push(Point2D::new(x as f64, 6.0));
480        }
481        for y in -4..=6 {
482            obstacles.push(Point2D::new(10.0, y as f64));
483            obstacles.push(Point2D::new(20.0, y as f64));
484        }
485
486        let planner =
487            FlowFieldPlanner::from_obstacle_points(&obstacles, FlowFieldConfig::default()).unwrap();
488        let path = planner.plan_xy(12.0, -2.0, 18.0, 4.0).unwrap();
489
490        let first = path.points.first().unwrap();
491        let last = path.points.last().unwrap();
492        assert!((first.x - 12.0).abs() < 1e-6);
493        assert!((first.y + 2.0).abs() < 1e-6);
494        assert!((last.x - 18.0).abs() < 1e-6);
495        assert!((last.y - 4.0).abs() < 1e-6);
496    }
497
498    #[test]
499    fn test_compute_field_returns_valid_integration() {
500        let (ox, oy) = create_simple_obstacles();
501        let planner = FlowFieldPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
502
503        let field = planner.compute_field(Point2D::new(8.0, 8.0)).unwrap();
504
505        // Goal cell should have zero integration cost.
506        let gx = planner.grid_map().calc_x_index(8.0);
507        let gy = planner.grid_map().calc_y_index(8.0);
508        let goal_idx = (gx as usize) * (planner.grid_map().y_width as usize) + (gy as usize);
509        assert_eq!(field.integration[goal_idx], 0);
510    }
511
512    #[test]
513    fn test_flow_field_path_monotonically_decreases_cost() {
514        let (ox, oy) = create_simple_obstacles();
515        let planner = FlowFieldPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
516
517        let goal = Point2D::new(8.0, 8.0);
518        let field = planner.compute_field(goal).unwrap();
519
520        let path = planner.plan(Point2D::new(2.0, 2.0), goal).unwrap();
521
522        // Each successive point must have lower or equal integration cost.
523        let costs: Vec<u32> = path
524            .points
525            .iter()
526            .map(|p| {
527                let ix = planner.grid_map().calc_x_index(p.x);
528                let iy = planner.grid_map().calc_y_index(p.y);
529                let idx = (ix as usize) * (planner.grid_map().y_width as usize) + (iy as usize);
530                field.integration[idx]
531            })
532            .collect();
533
534        for window in costs.windows(2) {
535            assert!(
536                window[0] >= window[1],
537                "cost should decrease along path, got {} -> {}",
538                window[0],
539                window[1]
540            );
541        }
542    }
543
544    #[test]
545    #[ignore = "long-running MovingAI benchmark"]
546    fn test_flow_field_matches_moving_ai_random512_bucket_80_optimal_length() {
547        let map = MovingAiMap::parse_str(include_str!(
548            "../benchdata/moving_ai/random/random512-10-0.map"
549        ))
550        .expect("random512 map should parse");
551        let scenario = MovingAiScenario::parse_str(include_str!(
552            "../benchdata/moving_ai/random/random512-10-0.map.scen"
553        ))
554        .expect("random512 scenario should parse")
555        .into_iter()
556        .find(|entry| entry.bucket == 80)
557        .expect("random512 MovingAI bucket 80 scenario should exist");
558
559        let obstacles = map.to_obstacles();
560        let planner = FlowFieldPlanner::from_obstacle_points(
561            &obstacles,
562            FlowFieldConfig {
563                resolution: 1.0,
564                robot_radius: 0.0,
565            },
566        )
567        .expect("flow field planner should build from random512 obstacles");
568
569        let start = map
570            .planning_point(scenario.start_x, scenario.start_y)
571            .expect("random512 start should map to planner coordinates");
572        let goal = map
573            .planning_point(scenario.goal_x, scenario.goal_y)
574            .expect("random512 goal should map to planner coordinates");
575
576        let path = planner
577            .plan(start, goal)
578            .expect("FlowField should solve the random512 bucket 80 scenario");
579
580        assert!(
581            (path.total_length() - scenario.optimal_length).abs() < 1e-6,
582            "FlowField path length {} should match MovingAI optimal {}",
583            path.total_length(),
584            scenario.optimal_length
585        );
586    }
587}