Skip to main content

condor_grid/
flow_field.rs

1//! Same-goal flow field for amortizing many independent 4-connected paths.
2//!
3//! [`FlowFieldBuilder::preprocess`] builds a reverse-BFS integration and direction
4//! field for one walkable goal on a uniform-cost grid; [`PreparedFlowField::sample_path`]
5//! then returns the standard found/no-path [`SearchResult`] for each start. Its unit
6//! hop cost matches BFS distance. This is not collision-aware MAPF: use [`crate::mapf`]
7//! when agents must reserve time and avoid one another. The harness owns corpus and
8//! conformance evidence for this lane.
9
10use std::collections::VecDeque;
11
12use crate::{
13    Grid, Path, Point,
14    search::{GridSearchError, SearchResult},
15};
16
17/// Cardinal step stored in a prepared flow field cell.
18///
19/// [`Self::None`] marks the goal, blocked/out-of-bounds probes, or unreachable cells.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FlowDirection {
22    /// Unreachable or goal cell (no step).
23    None,
24    /// Step toward decreasing x.
25    Left,
26    /// Step toward increasing x.
27    Right,
28    /// Step toward decreasing y.
29    Up,
30    /// Step toward increasing y.
31    Down,
32}
33
34impl FlowDirection {
35    /// Returns the `(dx, dy)` step for this direction, or `None` for [`Self::None`].
36    #[must_use]
37    pub const fn delta(self) -> Option<(isize, isize)> {
38        match self {
39            Self::None => None,
40            Self::Left => Some((-1, 0)),
41            Self::Right => Some((1, 0)),
42            Self::Up => Some((0, -1)),
43            Self::Down => Some((0, 1)),
44        }
45    }
46}
47
48/// Builds a [`PreparedFlowField`] for one goal on a uniform-cost 4-connected grid.
49///
50/// Preprocess is reverse BFS (integration + directions). Rejects non-unit
51/// `traversal_cost`. Prefer when many agents share one static goal without MAPF.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct FlowFieldBuilder;
54
55impl FlowFieldBuilder {
56    /// Creates a default builder (stateless).
57    #[must_use]
58    pub const fn new() -> Self {
59        Self
60    }
61
62    /// Preprocess a static grid for many agents sharing `goal`.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`FlowFieldBuildError::InvalidGoal`] when `goal` is blocked or
67    /// out of bounds, and [`FlowFieldBuildError::NonUniformCost`] when any
68    /// walkable cell has a traversal cost other than `1`.
69    pub fn preprocess(
70        &self,
71        grid: &Grid,
72        goal: Point,
73    ) -> Result<PreparedFlowField, FlowFieldBuildError> {
74        if !grid.contains(goal) || !grid.is_walkable(goal) {
75            return Err(FlowFieldBuildError::InvalidGoal { goal });
76        }
77        ensure_uniform_costs(grid)?;
78
79        let cell_count = grid.cell_count();
80        let mut integration = vec![None; cell_count];
81        let mut directions = vec![FlowDirection::None; cell_count];
82        let goal_index = grid.index_of(goal).expect("goal in bounds");
83
84        let mut queue = VecDeque::from([goal_index]);
85        integration[goal_index] = Some(0u32);
86
87        while let Some(index) = queue.pop_front() {
88            let cost = integration[index].expect("enqueued cells have integration");
89            let point = grid.point_from_index(index);
90            for next in grid.neighbors4(point) {
91                let Some(next_index) = grid.index_of(next) else {
92                    continue;
93                };
94                if integration[next_index].is_some() {
95                    continue;
96                }
97                integration[next_index] = Some(cost + 1);
98                queue.push_back(next_index);
99            }
100        }
101
102        for index in 0..cell_count {
103            let point = grid.point_from_index(index);
104            if !grid.is_walkable(point) {
105                continue;
106            }
107            let Some(here) = integration[index] else {
108                continue;
109            };
110            if point == goal {
111                directions[index] = FlowDirection::None;
112                continue;
113            }
114
115            let mut best_dir = FlowDirection::None;
116            let mut best_cost = here;
117            for neighbor in grid.neighbors4(point) {
118                let Some(n_index) = grid.index_of(neighbor) else {
119                    continue;
120                };
121                let Some(n_cost) = integration[n_index] else {
122                    continue;
123                };
124                if n_cost >= best_cost {
125                    continue;
126                }
127                best_cost = n_cost;
128                best_dir = if neighbor.x + 1 == point.x {
129                    FlowDirection::Left
130                } else if neighbor.x == point.x + 1 {
131                    FlowDirection::Right
132                } else if neighbor.y + 1 == point.y {
133                    FlowDirection::Up
134                } else {
135                    FlowDirection::Down
136                };
137            }
138            directions[index] = best_dir;
139        }
140
141        Ok(PreparedFlowField {
142            grid: grid.clone(),
143            goal,
144            integration,
145            directions,
146        })
147    }
148}
149
150/// Prepared same-goal flow field (integration + direction per cell).
151///
152/// Immutable after [`FlowFieldBuilder::preprocess`]. Safe to sample from many
153/// independent starts without mutating the field.
154#[derive(Debug, Clone)]
155pub struct PreparedFlowField {
156    grid: Grid,
157    goal: Point,
158    integration: Vec<Option<u32>>,
159    directions: Vec<FlowDirection>,
160}
161
162impl PreparedFlowField {
163    /// Stable algorithm label for capture reports.
164    #[must_use]
165    pub fn name(&self) -> &'static str {
166        "flow-field"
167    }
168
169    /// Grid snapshot used during preprocess.
170    #[must_use]
171    pub fn grid(&self) -> &Grid {
172        &self.grid
173    }
174
175    /// Shared goal the field was built for.
176    #[must_use]
177    pub fn goal(&self) -> Point {
178        self.goal
179    }
180
181    /// Grid width in cells.
182    #[must_use]
183    pub fn width(&self) -> usize {
184        self.grid.width()
185    }
186
187    /// Grid height in cells.
188    #[must_use]
189    pub fn height(&self) -> usize {
190        self.grid.height()
191    }
192
193    /// Distance-to-goal in grid steps, if the cell can reach the goal.
194    #[must_use]
195    pub fn integration_at(&self, point: Point) -> Option<u32> {
196        let index = self.grid.index_of(point)?;
197        self.integration[index]
198    }
199
200    /// Greedy cardinal step stored for `point`, or [`FlowDirection::None`] when
201    /// out of bounds, blocked, goal, or unreachable.
202    #[must_use]
203    pub fn direction_at(&self, point: Point) -> FlowDirection {
204        self.grid
205            .index_of(point)
206            .map(|index| self.directions[index])
207            .unwrap_or(FlowDirection::None)
208    }
209
210    /// Greedy walk along the flow field from `start` toward the goal.
211    ///
212    /// On uniform unit-cost 4-way grids, length matches A\*. Stops on goal,
213    /// stuck cell, or `max_steps` (default: width\*height).
214    ///
215    /// # Errors
216    ///
217    /// Returns [`GridSearchError::InvalidStart`] when `start` is blocked or
218    /// outside the prepared grid.
219    pub fn sample_path(&self, start: Point) -> SearchResult {
220        self.sample_path_limited(
221            start,
222            self.grid.width().saturating_mul(self.grid.height()).max(1),
223        )
224    }
225
226    /// Samples at most `max_steps` flow edges.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`GridSearchError::InvalidStart`] when `start` is blocked or
231    /// outside the prepared grid. [`GridSearchError::StepLimitReached`] reports
232    /// a reachable sample truncated by `max_steps`.
233    pub fn sample_path_limited(&self, start: Point, max_steps: usize) -> SearchResult {
234        if !self.grid.is_walkable(start) {
235            return Err(GridSearchError::InvalidStart { point: start });
236        }
237        if self.integration_at(start).is_none() {
238            return crate::search::not_found(0);
239        }
240        if start == self.goal {
241            return crate::search::found(
242                Path::from_steps(vec![start]).expect("flow paths always contain their start"),
243                1,
244            );
245        }
246
247        let mut steps = vec![start];
248        let mut current = start;
249        let mut visited = 1usize;
250
251        for _ in 0..max_steps {
252            let dir = self.direction_at(current);
253            let Some((dx, dy)) = dir.delta() else {
254                break;
255            };
256            let nx = current.x as isize + dx;
257            let ny = current.y as isize + dy;
258            if nx < 0 || ny < 0 {
259                break;
260            }
261            let next = Point::new(nx as usize, ny as usize);
262            if !self.grid.contains(next) || !self.grid.is_walkable(next) {
263                break;
264            }
265            // Stop if the field would bounce between two cells.
266            if steps.len() >= 2 && steps[steps.len() - 2] == next {
267                break;
268            }
269            steps.push(next);
270            visited += 1;
271            current = next;
272            if current == self.goal {
273                return crate::search::found(
274                    Path::from_steps(steps).expect("flow paths always contain their start"),
275                    visited,
276                );
277            }
278        }
279
280        if current == self.goal {
281            crate::search::found(
282                Path::from_steps(steps).expect("flow paths always contain their start"),
283                visited,
284            )
285        } else {
286            Err(GridSearchError::StepLimitReached {
287                max_steps,
288                reached: current,
289            })
290        }
291    }
292}
293
294/// Error when building a flow field.
295#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
296#[non_exhaustive]
297pub enum FlowFieldBuildError {
298    /// Goal is blocked or outside the grid.
299    #[error("flow field goal {goal:?} must be a walkable in-bounds cell")]
300    InvalidGoal { goal: Point },
301    /// Grid has a non-unit traversal cost (v0 supports uniform cost only).
302    #[error("flow field supports only uniform cost; cell {point:?} has cost {cost}")]
303    NonUniformCost { point: Point, cost: usize },
304}
305
306fn ensure_uniform_costs(grid: &Grid) -> Result<(), FlowFieldBuildError> {
307    for y in 0..grid.height() {
308        for x in 0..grid.width() {
309            let p = Point::new(x, y);
310            if let Some(cost) = grid.traversal_cost(p)
311                && cost != 1
312            {
313                return Err(FlowFieldBuildError::NonUniformCost { point: p, cost });
314            }
315        }
316    }
317    Ok(())
318}