Skip to main content

condor_grid/
replanning.rs

1//! Incremental grid replanning contracts for changing maps and costs.
2//!
3//! Two lanes share the initialize / update / replan lifecycle:
4//!
5//! | Lane | Trait | Coordinates | Primary solvers |
6//! |------|-------|-------------|-----------------|
7//! | Discrete | [`GridReplanner`] | integer [`Point`] cells | [`crate::algorithms::d_star_lite::DStarLite`], LPA* |
8//! | Interpolated | [`InterpolatedGridReplanner`] | fractional [`Point2`] | [`crate::algorithms::field_d_star::FieldDStar`] |
9//!
10//! Discrete replanning returns the standard invalid/found/no-path [`SearchResult`];
11//! interpolated replanning can also return partial or fallback
12//! [`InterpolatedPathOutcome`]s. Prefer ordinary [`crate::Pathfinder`] search when
13//! the grid is static, or [`crate::preprocessed_grid`] when it is static and queried
14//! repeatedly.
15
16use condor_core::Point2;
17use serde::{Deserialize, Serialize};
18use std::{collections::VecDeque, error::Error, fmt};
19
20use crate::{
21    grid::{Cell, Grid, GridEditError},
22    point::Point,
23    search::{SearchOutcome, SearchRequest, SearchResult},
24};
25
26const INTERPOLATED_EPSILON: f64 = 1e-9;
27
28/// Discrete-grid replanner: initialize once, apply cell/cost deltas, then replan.
29///
30/// # Contract
31///
32/// - `initialize` must succeed before `replan`; implementations may return
33///   [`crate::search::GridSearchError`] for invalid start/goal.
34/// - Cell and cost updates are deferred until the next `replan` call.
35/// - Outcomes are found / no-path via [`SearchResult`]; this lane does not
36///   emit interpolated partial or fallback path kinds.
37pub trait GridReplanner {
38    /// Stable algorithm label for capture reports and solver identity.
39    fn name(&self) -> &'static str;
40
41    /// Cold-start search on the current grid snapshot.
42    fn initialize(&mut self, grid: &Grid, request: SearchRequest) -> SearchResult;
43
44    /// Record a walkability change; takes effect on the next [`Self::replan`].
45    fn update_cell(&mut self, point: Point, cell: Cell);
46
47    /// Record a traversal-cost change; takes effect on the next [`Self::replan`].
48    ///
49    /// # Errors
50    ///
51    /// Returns [`GridEditError`] when `point` is out of bounds or the cost is
52    /// rejected by the underlying grid edit contract.
53    fn update_cost(&mut self, point: Point, cost: usize) -> Result<(), GridEditError>;
54
55    /// Recompute the shortest path, reusing prior search state when possible.
56    fn replan(&mut self) -> SearchResult;
57}
58
59/// Cost model for continuous polylines over a discrete weighted grid.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum InterpolatedTraversalCostModel {
63    /// Segment length weighted by the traversal costs of crossed cells (v0).
64    CellLengthWeightedV0,
65}
66
67/// Fractional start/goal coordinates for interpolated grid search.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct InterpolatedSearchRequest {
70    /// Continuous-space start (mapped onto a discrete cell by floor).
71    pub start: Point2,
72    /// Continuous-space goal (mapped onto a discrete cell by floor).
73    pub goal: Point2,
74}
75
76impl InterpolatedSearchRequest {
77    /// Creates a fractional start/goal pair without validating grid membership.
78    #[must_use]
79    pub const fn new(start: Point2, goal: Point2) -> Self {
80        Self { start, goal }
81    }
82}
83
84/// Continuous polyline over a grid with an associated traversal cost.
85///
86/// Construct via [`Self::from_points`] (trusted cost) or
87/// [`Self::from_points_on_grid`] (validated geometry + derived cost).
88#[derive(Debug, Clone, PartialEq)]
89pub struct InterpolatedPath {
90    points: Vec<Point2>,
91    cost: f64,
92}
93
94/// Interpolated path construction failed.
95#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
96#[non_exhaustive]
97pub enum InterpolatedPathBuildError {
98    /// Path had no vertices.
99    #[error("interpolated paths must contain at least one point")]
100    Empty,
101    /// A vertex was NaN or infinite.
102    #[error("interpolated path point {index} is not finite: {point:?}")]
103    NonFinitePoint { index: usize, point: Point2 },
104    /// A vertex fell outside the grid AABB.
105    #[error("interpolated path point {index} is outside the {width}x{height} grid: {point:?}")]
106    PointOutOfGrid {
107        index: usize,
108        point: Point2,
109        width: usize,
110        height: usize,
111    },
112    /// A vertex landed in a blocked cell.
113    #[error("interpolated path point {index} is in blocked cell {cell:?}: {point:?}")]
114    PointNotWalkable {
115        index: usize,
116        point: Point2,
117        cell: Point,
118    },
119    /// A segment crossed non-walkable space under the cost probe.
120    #[error("interpolated path segment {index} is not walkable from {start:?} to {end:?}")]
121    SegmentNotWalkable {
122        index: usize,
123        start: Point2,
124        end: Point2,
125    },
126    /// Segment cost was non-finite under the selected model.
127    #[error("interpolated path segment {index} produced a non-finite cost under {cost_model:?}")]
128    NonFiniteCost {
129        index: usize,
130        cost_model: InterpolatedTraversalCostModel,
131    },
132}
133
134impl InterpolatedPath {
135    /// Builds a path from vertices and a precomputed cost without grid checks.
136    ///
137    /// Prefer [`Self::from_points_on_grid`] when geometry and cost must be
138    /// validated against a concrete grid.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`InterpolatedPathBuildError::Empty`] when `points` is empty.
143    pub fn from_points(points: Vec<Point2>, cost: f64) -> Result<Self, InterpolatedPathBuildError> {
144        if points.is_empty() {
145            return Err(InterpolatedPathBuildError::Empty);
146        }
147        Ok(Self { points, cost })
148    }
149
150    /// Constructs a validated interpolated path and derives its cost from `grid`.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`InterpolatedPathBuildError`] when `points` is empty, a point is
155    /// non-finite, outside the grid, or blocked, a segment crosses non-walkable
156    /// space, or the selected cost model produces a non-finite cost.
157    pub fn from_points_on_grid(
158        grid: &Grid,
159        points: Vec<Point2>,
160        cost_model: InterpolatedTraversalCostModel,
161    ) -> Result<Self, InterpolatedPathBuildError> {
162        if points.is_empty() {
163            return Err(InterpolatedPathBuildError::Empty);
164        }
165
166        for (index, &point) in points.iter().enumerate() {
167            if !point.x.is_finite() || !point.y.is_finite() {
168                return Err(InterpolatedPathBuildError::NonFinitePoint { index, point });
169            }
170            if point.x < 0.0
171                || point.y < 0.0
172                || point.x >= grid.width() as f64
173                || point.y >= grid.height() as f64
174            {
175                return Err(InterpolatedPathBuildError::PointOutOfGrid {
176                    index,
177                    point,
178                    width: grid.width(),
179                    height: grid.height(),
180                });
181            }
182
183            let cell = Point::new(point.x.floor() as usize, point.y.floor() as usize);
184            if !grid.is_walkable(cell) {
185                return Err(InterpolatedPathBuildError::PointNotWalkable { index, point, cell });
186            }
187        }
188
189        let mut cost = 0.0;
190        for (index, pair) in points.windows(2).enumerate() {
191            let segment_cost = interpolated_segment_cost(grid, pair[0], pair[1], cost_model)
192                .ok_or(InterpolatedPathBuildError::SegmentNotWalkable {
193                    index,
194                    start: pair[0],
195                    end: pair[1],
196                })?;
197            if !segment_cost.is_finite() || !(cost + segment_cost).is_finite() {
198                return Err(InterpolatedPathBuildError::NonFiniteCost { index, cost_model });
199            }
200            cost += segment_cost;
201        }
202
203        Self::from_points(points, cost)
204    }
205
206    /// Ordered continuous vertices from start through the path end (inclusive).
207    #[must_use]
208    pub fn points(&self) -> &[Point2] {
209        &self.points
210    }
211
212    /// First path vertex (request start for well-formed outcomes).
213    #[must_use]
214    pub fn start(&self) -> Point2 {
215        self.points[0]
216    }
217
218    /// Last path vertex (goal, partial target, or fallback endpoint).
219    #[must_use]
220    pub fn goal(&self) -> Point2 {
221        self.points[self.points.len() - 1]
222    }
223
224    /// Accumulated interpolated traversal cost under the active cost model.
225    #[must_use]
226    pub const fn cost(&self) -> f64 {
227        self.cost
228    }
229}
230
231/// Work counters for one interpolated search or replan (solver-local).
232#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
233pub struct InterpolatedSearchStats {
234    /// Nodes expanded or visited during the search/replan step.
235    pub visited_nodes: usize,
236}
237
238/// Path quality returned by an interpolated search (full, partial, or fallback).
239///
240/// All three variants own an [`InterpolatedPath`]; the discriminant is what
241/// fixture packs and capture oracles assert via [`InterpolatedExpectedKind`].
242#[derive(Debug, Clone, PartialEq)]
243pub enum InterpolatedPathOutcome {
244    /// Continuous path reaches the goal.
245    Found(InterpolatedPath),
246    /// Goal unreachable; path ends at the best partial target cell.
247    PartialPath(InterpolatedPath),
248    /// Degenerate partial path (typically start-only) used as last resort.
249    Fallback(InterpolatedPath),
250}
251
252/// Invalid interpolated-search request or uninitialized replanner state.
253#[non_exhaustive]
254#[derive(Debug, Clone, Copy, PartialEq)]
255pub enum InterpolatedSearchError {
256    /// Start does not map to a walkable in-bounds cell.
257    InvalidStart { point: Point2 },
258    /// Goal does not map to a walkable in-bounds cell.
259    InvalidGoal { point: Point2 },
260    /// `replan` was called before a successful `initialize`.
261    NotInitialized,
262    /// Path materialization failed after a search candidate was produced.
263    PathBuild { source: InterpolatedPathBuildError },
264}
265
266impl fmt::Display for InterpolatedSearchError {
267    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
268        match self {
269            Self::InvalidStart { point } => {
270                write!(formatter, "invalid interpolated start: {point:?}")
271            }
272            Self::InvalidGoal { point } => {
273                write!(formatter, "invalid interpolated goal: {point:?}")
274            }
275            Self::NotInitialized => {
276                formatter.write_str("interpolated replanner is not initialized")
277            }
278            Self::PathBuild { source } => write!(formatter, "interpolated path build: {source}"),
279        }
280    }
281}
282
283impl Error for InterpolatedSearchError {
284    fn source(&self) -> Option<&(dyn Error + 'static)> {
285        match self {
286            Self::PathBuild { source } => Some(source),
287            Self::InvalidStart { .. } | Self::InvalidGoal { .. } | Self::NotInitialized => None,
288        }
289    }
290}
291
292impl From<InterpolatedPathBuildError> for InterpolatedSearchError {
293    fn from(source: InterpolatedPathBuildError) -> Self {
294        Self::PathBuild { source }
295    }
296}
297
298/// Grid-owned adapter for an interpolated found/no-path outcome.
299///
300/// The shared [`SearchOutcome`] operations remain available through
301/// [`std::ops::Deref`],
302/// while this wrapper owns the interpolated lane's path-quality semantics.
303#[repr(transparent)]
304#[derive(Debug, Clone, PartialEq)]
305pub struct InterpolatedSearchOutcome(
306    SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats>,
307);
308/// Validation error or search outcome for the interpolated lane.
309pub type InterpolatedSearchResult = Result<InterpolatedSearchOutcome, InterpolatedSearchError>;
310
311/// Discriminant for [`InterpolatedPathOutcome`] without owning the path.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum InterpolatedPathOutcomeKind {
314    /// Full path to the goal.
315    Found,
316    /// Best reachable approach toward an unreachable goal.
317    PartialPath,
318    /// Start-only or otherwise degenerate fallback path.
319    Fallback,
320}
321
322/// Borrowed view of a successful interpolated path plus its stats.
323///
324/// Produced by [`InterpolatedSearchOutcome::path_outcome`]; never constructed
325/// for `NoPath` outcomes.
326#[derive(Debug, Clone, Copy)]
327pub struct InterpolatedPathOutcomeRef<'a> {
328    kind: InterpolatedPathOutcomeKind,
329    path: &'a InterpolatedPath,
330    stats: &'a InterpolatedSearchStats,
331}
332
333impl<'a> InterpolatedPathOutcomeRef<'a> {
334    /// Returns the path-quality discriminant.
335    #[must_use]
336    pub const fn kind(&self) -> InterpolatedPathOutcomeKind {
337        self.kind
338    }
339
340    /// Maps this outcome onto the fixture/oracle expected-kind enum.
341    ///
342    /// Never returns [`InterpolatedExpectedKind::NoPath`] or the invalid
343    /// endpoint kinds; those apply only to full search outcomes.
344    #[must_use]
345    pub fn expected_kind(&self) -> InterpolatedExpectedKind {
346        match self.kind {
347            InterpolatedPathOutcomeKind::Found => InterpolatedExpectedKind::Found,
348            InterpolatedPathOutcomeKind::PartialPath => InterpolatedExpectedKind::PartialPath,
349            InterpolatedPathOutcomeKind::Fallback => InterpolatedExpectedKind::Fallback,
350        }
351    }
352
353    /// Returns the owned path reference carried by this outcome.
354    #[must_use]
355    pub const fn path(&self) -> &'a InterpolatedPath {
356        self.path
357    }
358
359    /// Returns the work counters for the search/replan that produced this path.
360    #[must_use]
361    pub const fn stats(&self) -> &'a InterpolatedSearchStats {
362        self.stats
363    }
364
365    /// Recomputes path cost from geometry under `cost_model`.
366    ///
367    /// Returns `None` when any segment is invalid on `grid` (oracle mismatch
368    /// signal when compared to [`InterpolatedPath::cost`]).
369    #[must_use]
370    pub fn derived_cost(
371        &self,
372        grid: &Grid,
373        cost_model: InterpolatedTraversalCostModel,
374    ) -> Option<f64> {
375        interpolated_path_cost(grid, self.path.points(), cost_model)
376    }
377
378    /// Returns the number of continuous vertices on the path (witness length).
379    #[must_use]
380    pub fn witness_points(&self) -> usize {
381        self.path.points().len()
382    }
383}
384
385impl InterpolatedPathOutcome {
386    /// Returns the path-quality discriminant without cloning the path.
387    #[must_use]
388    pub const fn kind(&self) -> InterpolatedPathOutcomeKind {
389        match self {
390            Self::Found(_) => InterpolatedPathOutcomeKind::Found,
391            Self::PartialPath(_) => InterpolatedPathOutcomeKind::PartialPath,
392            Self::Fallback(_) => InterpolatedPathOutcomeKind::Fallback,
393        }
394    }
395
396    /// Returns the path for any successful quality kind.
397    #[must_use]
398    pub const fn path(&self) -> &InterpolatedPath {
399        match self {
400            Self::Found(path) | Self::PartialPath(path) | Self::Fallback(path) => path,
401        }
402    }
403}
404
405/// Found outcome with a goal-reaching continuous path.
406pub(crate) fn interpolated_found(
407    path: InterpolatedPath,
408    visited_nodes: usize,
409) -> InterpolatedSearchResult {
410    Ok(InterpolatedSearchOutcome::found(
411        InterpolatedPathOutcome::Found(path),
412        InterpolatedSearchStats { visited_nodes },
413    ))
414}
415
416/// Path-bearing partial outcome (best incomplete route toward the goal).
417pub(crate) fn interpolated_partial(
418    path: InterpolatedPath,
419    visited_nodes: usize,
420) -> InterpolatedSearchResult {
421    Ok(InterpolatedSearchOutcome::found(
422        InterpolatedPathOutcome::PartialPath(path),
423        InterpolatedSearchStats { visited_nodes },
424    ))
425}
426
427/// Path-bearing fallback outcome when discrete connectivity is lost mid-route.
428pub(crate) fn interpolated_fallback(
429    path: InterpolatedPath,
430    visited_nodes: usize,
431) -> InterpolatedSearchResult {
432    Ok(InterpolatedSearchOutcome::found(
433        InterpolatedPathOutcome::Fallback(path),
434        InterpolatedSearchStats { visited_nodes },
435    ))
436}
437
438/// Completed no-path outcome for the interpolated lane (valid request, no route).
439pub(crate) const fn interpolated_not_found(visited_nodes: usize) -> InterpolatedSearchResult {
440    Ok(InterpolatedSearchOutcome::no_path(
441        InterpolatedSearchStats { visited_nodes },
442    ))
443}
444
445impl InterpolatedSearchOutcome {
446    /// Creates a path-bearing interpolated outcome.
447    #[must_use]
448    pub const fn found(path: InterpolatedPathOutcome, stats: InterpolatedSearchStats) -> Self {
449        Self(SearchOutcome::found(path, stats))
450    }
451
452    /// Creates an interpolated outcome where no path was available.
453    #[must_use]
454    pub const fn no_path(stats: InterpolatedSearchStats) -> Self {
455        Self(SearchOutcome::no_path(stats))
456    }
457
458    /// Borrows the underlying shared search outcome.
459    #[must_use]
460    pub const fn as_search_outcome(
461        &self,
462    ) -> &SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats> {
463        &self.0
464    }
465
466    /// Consumes this adapter and returns the underlying shared search outcome.
467    #[must_use]
468    pub fn into_search_outcome(
469        self,
470    ) -> SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats> {
471        self.0
472    }
473
474    /// Returns the path cost when a path outcome is present.
475    #[must_use]
476    pub fn cost(&self) -> Option<f64> {
477        self.path().map(|outcome| outcome.path().cost())
478    }
479
480    /// Maps this search outcome onto the fixture/oracle expected-kind enum.
481    ///
482    /// `NoPath` becomes [`InterpolatedExpectedKind::NoPath`]; found outcomes
483    /// preserve found / partial / fallback quality.
484    #[must_use]
485    pub fn expected_kind(&self) -> InterpolatedExpectedKind {
486        match self.path() {
487            Some(path) => match path {
488                InterpolatedPathOutcome::Found(_) => InterpolatedExpectedKind::Found,
489                InterpolatedPathOutcome::PartialPath(_) => InterpolatedExpectedKind::PartialPath,
490                InterpolatedPathOutcome::Fallback(_) => InterpolatedExpectedKind::Fallback,
491            },
492            None => InterpolatedExpectedKind::NoPath,
493        }
494    }
495
496    /// Borrows the path outcome when the search found a path of any quality.
497    #[must_use]
498    pub fn path_outcome(&self) -> Option<InterpolatedPathOutcomeRef<'_>> {
499        self.path().map(|path| InterpolatedPathOutcomeRef {
500            kind: path.kind(),
501            path: path.path(),
502            stats: self.stats(),
503        })
504    }
505}
506
507impl std::ops::Deref for InterpolatedSearchOutcome {
508    type Target = SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats>;
509
510    fn deref(&self) -> &Self::Target {
511        self.as_search_outcome()
512    }
513}
514
515impl From<SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats>>
516    for InterpolatedSearchOutcome
517{
518    fn from(outcome: SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats>) -> Self {
519        Self(outcome)
520    }
521}
522
523impl From<InterpolatedSearchOutcome>
524    for SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats>
525{
526    fn from(outcome: InterpolatedSearchOutcome) -> Self {
527        outcome.into_search_outcome()
528    }
529}
530
531/// Continuous-coordinate replanner over a discrete grid backing store.
532///
533/// # Contract
534///
535/// - Same lifecycle as [`GridReplanner`], but requests and outcomes use
536///   fractional coordinates and [`InterpolatedPathOutcome`] quality kinds.
537/// - `update_cell` / `update_cost` edit the discrete backing grid; continuous
538///   endpoints stay fixed until a moving-goal extension changes them.
539/// - `replan` without a prior successful `initialize` should return
540///   [`InterpolatedSearchError::NotInitialized`].
541pub trait InterpolatedGridReplanner {
542    /// Stable algorithm label for capture reports and solver identity.
543    fn name(&self) -> &'static str;
544
545    /// Cold-start search from fractional start/goal on the current grid.
546    fn initialize(
547        &mut self,
548        grid: &Grid,
549        request: InterpolatedSearchRequest,
550    ) -> InterpolatedSearchResult;
551
552    /// Record a discrete walkability change; takes effect on the next [`Self::replan`].
553    fn update_cell(&mut self, point: Point, cell: Cell);
554
555    /// Record a discrete traversal-cost change; takes effect on the next [`Self::replan`].
556    ///
557    /// # Errors
558    ///
559    /// Returns [`GridEditError`] when the edit is rejected by the grid contract.
560    fn update_cost(&mut self, point: Point, cost: usize) -> Result<(), GridEditError>;
561
562    /// Recompute under the last request, reusing prior continuous search state when possible.
563    fn replan(&mut self) -> InterpolatedSearchResult;
564}
565
566/// Interpolated replanner lane that also accepts moving-goal updates between replans.
567///
568/// `update_goal` records a new fractional goal; it takes effect on the next
569/// [`InterpolatedGridReplanner::replan`]. Start remains the last initialize/replan start.
570pub trait InterpolatedMovingGoalReplanner: InterpolatedGridReplanner {
571    /// Queue a fractional goal change for the next replan.
572    fn update_goal(&mut self, goal: Point2);
573}
574
575/// Connectivity probe for fractional endpoints mapped onto discrete cells.
576///
577/// Produced by [`query_interpolated_grid`]; does not construct a continuous path.
578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
579pub enum InterpolatedQueryResult {
580    /// Both endpoints map to walkable cells that are 4-connected.
581    Connected { start_cell: Point, goal_cell: Point },
582    /// Both endpoints map, but no 4-connected discrete path exists.
583    NoPath { start_cell: Point, goal_cell: Point },
584    /// Start is non-finite, out of bounds, or blocked.
585    InvalidStart,
586    /// Goal is non-finite, out of bounds, or blocked.
587    InvalidGoal,
588}
589
590/// Maps fractional start/goal onto cells and tests 4-connected reachability.
591#[must_use]
592pub fn query_interpolated_grid(
593    grid: &Grid,
594    request: InterpolatedSearchRequest,
595) -> InterpolatedQueryResult {
596    let Some(start_cell) = interpolated_point_to_cell(grid, request.start) else {
597        return InterpolatedQueryResult::InvalidStart;
598    };
599    let Some(goal_cell) = interpolated_point_to_cell(grid, request.goal) else {
600        return InterpolatedQueryResult::InvalidGoal;
601    };
602
603    if interpolated_cells_connected(grid, start_cell, goal_cell) {
604        InterpolatedQueryResult::Connected {
605            start_cell,
606            goal_cell,
607        }
608    } else {
609        InterpolatedQueryResult::NoPath {
610            start_cell,
611            goal_cell,
612        }
613    }
614}
615
616/// Total cost of a continuous polyline under `cost_model`, if every segment is valid.
617#[must_use]
618pub fn interpolated_path_cost(
619    grid: &Grid,
620    points: &[Point2],
621    cost_model: InterpolatedTraversalCostModel,
622) -> Option<f64> {
623    if points.is_empty() {
624        return None;
625    }
626    if points.len() == 1 {
627        return interpolated_point_to_cell(grid, points[0]).map(|_| 0.0);
628    }
629
630    points.windows(2).try_fold(0.0, |acc, pair| {
631        interpolated_segment_cost(grid, pair[0], pair[1], cost_model).map(|cost| acc + cost)
632    })
633}
634
635/// Cost of one continuous segment under `cost_model`, if both ends map onto the grid.
636#[must_use]
637pub fn interpolated_segment_cost(
638    grid: &Grid,
639    start: Point2,
640    end: Point2,
641    cost_model: InterpolatedTraversalCostModel,
642) -> Option<f64> {
643    match cost_model {
644        InterpolatedTraversalCostModel::CellLengthWeightedV0 => {
645            interpolated_segment_cost_cell_length_weighted_v0(grid, start, end)
646        }
647    }
648}
649
650/// Expected outcome kind asserted by interpolated replan fixtures and oracles.
651///
652/// Aligns with [`InterpolatedPathOutcome`] plus `NoPath` and endpoint validation
653/// failures so packs can encode the full observed surface.
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
655#[serde(rename_all = "kebab-case")]
656pub enum InterpolatedExpectedKind {
657    /// Full continuous path to the goal.
658    Found,
659    /// Best partial path toward an unreachable goal.
660    PartialPath,
661    /// Degenerate fallback path.
662    Fallback,
663    /// No path of any quality was produced.
664    NoPath,
665    /// Start failed discrete mapping / walkability checks.
666    InvalidStart,
667    /// Goal failed discrete mapping / walkability checks.
668    InvalidGoal,
669}
670
671/// Best partial path toward an unreachable goal: route to the closest reachable cell.
672///
673/// Returns `Ok(None)` when the pair is already connected or no reachable partial
674/// target exists (callers should run a full search instead). Invalid endpoints are
675/// returned as typed [`InterpolatedSearchError`] values.
676///
677/// # Errors
678///
679/// Returns [`InterpolatedSearchError`] when an endpoint is invalid or the selected
680/// partial route cannot be represented as a valid interpolated path.
681pub fn best_partial_interpolated_path(
682    grid: &Grid,
683    request: InterpolatedSearchRequest,
684    cost_model: InterpolatedTraversalCostModel,
685) -> Result<Option<InterpolatedPath>, InterpolatedSearchError> {
686    let Some(start_cell) = interpolated_point_to_cell(grid, request.start) else {
687        return Err(InterpolatedSearchError::InvalidStart {
688            point: request.start,
689        });
690    };
691    let Some(goal_cell) = interpolated_point_to_cell(grid, request.goal) else {
692        return Err(InterpolatedSearchError::InvalidGoal {
693            point: request.goal,
694        });
695    };
696    if interpolated_cells_connected(grid, start_cell, goal_cell) {
697        return Ok(None);
698    }
699
700    let (parents, distances) = reachable_interpolated_cells(grid, start_cell);
701    let Some(target) = select_partial_target(grid, request, &distances) else {
702        return Ok(None);
703    };
704    let Some(cell_path) = reconstruct_cell_path(grid, start_cell, target, &parents) else {
705        return Ok(None);
706    };
707
708    let mut points = vec![request.start];
709    for cell in cell_path.into_iter().skip(1) {
710        let center = interpolated_cell_center(cell);
711        if same_interpolated_point(points[points.len() - 1], center) {
712            continue;
713        }
714        points.push(center);
715    }
716
717    InterpolatedPath::from_points_on_grid(grid, points, cost_model)
718        .map(Some)
719        .map_err(Into::into)
720}
721
722/// Fallback path when only the start cell is reachable (degenerate partial path).
723///
724/// # Errors
725///
726/// Returns [`InterpolatedSearchError`] when an endpoint is invalid or partial-path
727/// construction fails.
728pub fn best_fallback_interpolated_path(
729    grid: &Grid,
730    request: InterpolatedSearchRequest,
731    cost_model: InterpolatedTraversalCostModel,
732) -> Result<Option<InterpolatedPath>, InterpolatedSearchError> {
733    let Some(path) = best_partial_interpolated_path(grid, request, cost_model)? else {
734        return Ok(None);
735    };
736    Ok((path.points().len() == 1).then_some(path))
737}
738
739fn interpolated_segment_cost_cell_length_weighted_v0(
740    grid: &Grid,
741    start: Point2,
742    end: Point2,
743) -> Option<f64> {
744    interpolated_point_to_cell(grid, start)?;
745    interpolated_point_to_cell(grid, end)?;
746
747    let total_length = start.distance_to(end);
748    if total_length <= INTERPOLATED_EPSILON {
749        return Some(0.0);
750    }
751
752    let dx = end.x - start.x;
753    let dy = end.y - start.y;
754    let mut parameters = vec![0.0, 1.0];
755
756    if dx.abs() > INTERPOLATED_EPSILON {
757        for boundary in 1..grid.width() {
758            let boundary = boundary as f64;
759            let t = (boundary - start.x) / dx;
760            if t > INTERPOLATED_EPSILON && t < 1.0 - INTERPOLATED_EPSILON {
761                parameters.push(t);
762            }
763        }
764    }
765
766    if dy.abs() > INTERPOLATED_EPSILON {
767        for boundary in 1..grid.height() {
768            let boundary = boundary as f64;
769            let t = (boundary - start.y) / dy;
770            if t > INTERPOLATED_EPSILON && t < 1.0 - INTERPOLATED_EPSILON {
771                parameters.push(t);
772            }
773        }
774    }
775
776    parameters.sort_by(|left, right| left.total_cmp(right));
777    parameters.dedup_by(|left, right| (*left - *right).abs() <= INTERPOLATED_EPSILON);
778
779    let mut cost = 0.0;
780    for interval in parameters.windows(2) {
781        let start_t = interval[0];
782        let end_t = interval[1];
783        if end_t - start_t <= INTERPOLATED_EPSILON {
784            continue;
785        }
786
787        let midpoint = interpolate_segment(start, end, (start_t + end_t) / 2.0);
788        let cell = interpolated_point_to_cell(grid, midpoint)?;
789        let traversal_cost = grid.traversal_cost(cell)? as f64;
790        cost += total_length * (end_t - start_t) * traversal_cost;
791    }
792
793    Some(cost)
794}
795
796fn interpolated_point_to_cell(grid: &Grid, point: Point2) -> Option<Point> {
797    if !point.x.is_finite() || !point.y.is_finite() {
798        return None;
799    }
800    if point.x < 0.0
801        || point.y < 0.0
802        || point.x >= grid.width() as f64
803        || point.y >= grid.height() as f64
804    {
805        return None;
806    }
807
808    let cell = Point::new(point.x.floor() as usize, point.y.floor() as usize);
809    grid.is_walkable(cell).then_some(cell)
810}
811
812fn reachable_interpolated_cells(
813    grid: &Grid,
814    start: Point,
815) -> (Vec<Option<Point>>, Vec<Option<usize>>) {
816    let mut parents = vec![None; grid.cell_count()];
817    let mut distances = vec![None; grid.cell_count()];
818    let mut queue = VecDeque::new();
819
820    let start_index = grid
821        .index_of(start)
822        .expect("start point should index into the grid");
823    distances[start_index] = Some(0);
824    queue.push_back(start);
825
826    while let Some(point) = queue.pop_front() {
827        let point_index = grid
828            .index_of(point)
829            .expect("reachable point should index into the grid");
830        let distance = distances[point_index].expect("reachable cells carry distance");
831        for neighbor in grid.neighbors4(point) {
832            if !grid.is_walkable(neighbor) {
833                continue;
834            }
835            let neighbor_index = grid
836                .index_of(neighbor)
837                .expect("neighbor should index into the grid");
838            if distances[neighbor_index].is_some() {
839                continue;
840            }
841
842            parents[neighbor_index] = Some(point);
843            distances[neighbor_index] = Some(distance + 1);
844            queue.push_back(neighbor);
845        }
846    }
847
848    (parents, distances)
849}
850
851fn select_partial_target(
852    grid: &Grid,
853    request: InterpolatedSearchRequest,
854    distances: &[Option<usize>],
855) -> Option<Point> {
856    let mut best: Option<(Point, f64, usize)> = None;
857
858    for (index, distance) in distances
859        .iter()
860        .copied()
861        .enumerate()
862        .take(grid.cell_count())
863    {
864        let Some(distance) = distance else {
865            continue;
866        };
867        let point = grid.point_from_index(index);
868        if !grid.is_walkable(point) {
869            continue;
870        }
871
872        let goal_distance = interpolated_cell_center(point).distance_to(request.goal);
873        match best {
874            None => best = Some((point, goal_distance, distance)),
875            Some((current_point, current_goal_distance, current_steps)) => {
876                if goal_distance + INTERPOLATED_EPSILON < current_goal_distance
877                    || ((goal_distance - current_goal_distance).abs() <= INTERPOLATED_EPSILON
878                        && (distance < current_steps
879                            || (distance == current_steps
880                                && (point.y < current_point.y
881                                    || (point.y == current_point.y && point.x < current_point.x)))))
882                {
883                    best = Some((point, goal_distance, distance));
884                }
885            }
886        }
887    }
888
889    best.map(|(point, _, _)| point)
890}
891
892fn reconstruct_cell_path(
893    grid: &Grid,
894    start: Point,
895    target: Point,
896    parents: &[Option<Point>],
897) -> Option<Vec<Point>> {
898    let mut cursor = target;
899    let mut path = vec![cursor];
900
901    while cursor != start {
902        let cursor_index = grid
903            .index_of(cursor)
904            .expect("path cursor should index into the grid");
905        let parent = parents[cursor_index]?;
906        cursor = parent;
907        path.push(cursor);
908    }
909
910    path.reverse();
911    Some(path)
912}
913
914fn interpolated_cell_center(point: Point) -> Point2 {
915    Point2::new(point.x as f64 + 0.5, point.y as f64 + 0.5)
916}
917
918fn same_interpolated_point(left: Point2, right: Point2) -> bool {
919    (left.x - right.x).abs() <= INTERPOLATED_EPSILON
920        && (left.y - right.y).abs() <= INTERPOLATED_EPSILON
921}
922
923fn interpolated_cells_connected(grid: &Grid, start: Point, goal: Point) -> bool {
924    if start == goal {
925        return true;
926    }
927
928    let mut seen = vec![false; grid.cell_count()];
929    let mut frontier = std::collections::VecDeque::from([start]);
930    let start_index = (start.y * grid.width()) + start.x;
931    seen[start_index] = true;
932
933    while let Some(cell) = frontier.pop_front() {
934        if cell == goal {
935            return true;
936        }
937
938        for neighbor in grid.neighbors4(cell) {
939            let index = (neighbor.y * grid.width()) + neighbor.x;
940            if seen[index] {
941                continue;
942            }
943            seen[index] = true;
944            frontier.push_back(neighbor);
945        }
946    }
947
948    false
949}
950
951fn interpolate_segment(start: Point2, end: Point2, t: f64) -> Point2 {
952    Point2::new(
953        start.x + ((end.x - start.x) * t),
954        start.y + ((end.y - start.y) * t),
955    )
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use crate::{
962        Cell, InterpolatedGridReplanner, InterpolatedMovingGoalReplanner, SearchOutcome,
963        algorithms::field_d_star::FieldDStar, best_partial_interpolated_path,
964        interpolated_path_cost,
965    };
966
967    const EPSILON: f64 = 1e-9;
968
969    #[test]
970    fn on_grid_path_build_reports_precise_validation_variants() {
971        let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
972        let cost_model = InterpolatedTraversalCostModel::CellLengthWeightedV0;
973
974        assert_eq!(
975            InterpolatedPath::from_points_on_grid(&grid, Vec::new(), cost_model),
976            Err(InterpolatedPathBuildError::Empty)
977        );
978
979        let non_finite = Point2::new(f64::NAN, 0.5);
980        assert!(matches!(
981            InterpolatedPath::from_points_on_grid(&grid, vec![non_finite], cost_model),
982            Err(InterpolatedPathBuildError::NonFinitePoint { index: 0, point })
983                if point.x.is_nan() && point.y == 0.5
984        ));
985
986        let outside = Point2::new(3.0, 0.5);
987        assert_eq!(
988            InterpolatedPath::from_points_on_grid(&grid, vec![outside], cost_model),
989            Err(InterpolatedPathBuildError::PointOutOfGrid {
990                index: 0,
991                point: outside,
992                width: 3,
993                height: 1,
994            })
995        );
996
997        grid.set_cell(Point::new(1, 0), Cell::Blocked)
998            .expect("blocked test cell should be valid");
999        let blocked = Point2::new(1.5, 0.5);
1000        assert_eq!(
1001            InterpolatedPath::from_points_on_grid(&grid, vec![blocked], cost_model),
1002            Err(InterpolatedPathBuildError::PointNotWalkable {
1003                index: 0,
1004                point: blocked,
1005                cell: Point::new(1, 0),
1006            })
1007        );
1008
1009        let start = Point2::new(0.5, 0.5);
1010        let end = Point2::new(2.5, 0.5);
1011        assert_eq!(
1012            InterpolatedPath::from_points_on_grid(&grid, vec![start, end], cost_model),
1013            Err(InterpolatedPathBuildError::SegmentNotWalkable {
1014                index: 0,
1015                start,
1016                end,
1017            })
1018        );
1019    }
1020
1021    #[test]
1022    fn partial_path_helper_distinguishes_not_applicable_from_a_path() {
1023        let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
1024        let request = InterpolatedSearchRequest::new(Point2::new(0.5, 0.5), Point2::new(2.5, 0.5));
1025        let cost_model = InterpolatedTraversalCostModel::CellLengthWeightedV0;
1026
1027        let connected = best_partial_interpolated_path(&grid, request, cost_model)
1028            .expect("valid partial-path construction");
1029        assert!(connected.is_none(), "connected requests are not applicable");
1030
1031        grid.set_cell(Point::new(1, 0), Cell::Blocked)
1032            .expect("barrier point should be valid");
1033        let partial = best_partial_interpolated_path(&grid, request, cost_model)
1034            .expect("valid partial-path construction")
1035            .expect("disconnected request should produce a partial path");
1036        assert_eq!(partial.points(), &[request.start]);
1037    }
1038
1039    #[test]
1040    fn shared_result_surface_classifies_all_interpolated_outcomes() {
1041        let grid = Grid::new(2, 1).expect("grid dimensions are valid");
1042        let cost_model = InterpolatedTraversalCostModel::CellLengthWeightedV0;
1043        let path = InterpolatedPath::from_points_on_grid(
1044            &grid,
1045            vec![Point2::new(0.5, 0.5), Point2::new(1.5, 0.5)],
1046            cost_model,
1047        )
1048        .expect("test path should build");
1049
1050        for (result, expected_kind, expected_outcome_kind, expected_visits) in [
1051            (
1052                InterpolatedSearchOutcome::found(
1053                    InterpolatedPathOutcome::Found(path.clone()),
1054                    InterpolatedSearchStats { visited_nodes: 3 },
1055                ),
1056                InterpolatedExpectedKind::Found,
1057                Some(InterpolatedPathOutcomeKind::Found),
1058                3,
1059            ),
1060            (
1061                InterpolatedSearchOutcome::found(
1062                    InterpolatedPathOutcome::PartialPath(path.clone()),
1063                    InterpolatedSearchStats { visited_nodes: 5 },
1064                ),
1065                InterpolatedExpectedKind::PartialPath,
1066                Some(InterpolatedPathOutcomeKind::PartialPath),
1067                5,
1068            ),
1069            (
1070                InterpolatedSearchOutcome::found(
1071                    InterpolatedPathOutcome::Fallback(path.clone()),
1072                    InterpolatedSearchStats { visited_nodes: 7 },
1073                ),
1074                InterpolatedExpectedKind::Fallback,
1075                Some(InterpolatedPathOutcomeKind::Fallback),
1076                7,
1077            ),
1078            (
1079                InterpolatedSearchOutcome::no_path(InterpolatedSearchStats { visited_nodes: 11 }),
1080                InterpolatedExpectedKind::NoPath,
1081                None,
1082                11,
1083            ),
1084        ] {
1085            assert_eq!(result.is_found(), expected_outcome_kind.is_some());
1086            assert_eq!(result.path().is_some(), expected_outcome_kind.is_some());
1087            assert_eq!(result.stats().visited_nodes, expected_visits);
1088            assert_eq!(result.expected_kind(), expected_kind);
1089            assert_eq!(result.cost(), expected_outcome_kind.map(|_| path.cost()));
1090            let outcome = result.path_outcome();
1091            assert_eq!(outcome.map(|outcome| outcome.kind()), expected_outcome_kind);
1092
1093            if let Some(outcome) = outcome {
1094                assert_eq!(outcome.expected_kind(), expected_kind);
1095                assert_eq!(outcome.path().points(), path.points());
1096                assert_eq!(outcome.path().cost(), path.cost());
1097                assert_eq!(outcome.witness_points(), path.points().len());
1098                let derived_cost = outcome
1099                    .derived_cost(&grid, cost_model)
1100                    .expect("path-bearing outcome should derive a cost");
1101                assert!((derived_cost - path.cost()).abs() <= EPSILON);
1102            }
1103
1104            let shared: SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats> =
1105                result.clone().into();
1106            let wrapped = InterpolatedSearchOutcome::from(shared.clone());
1107            assert_eq!(wrapped.as_search_outcome(), &shared);
1108            let round_trip: SearchOutcome<InterpolatedPathOutcome, InterpolatedSearchStats> =
1109                wrapped.into();
1110            assert_eq!(round_trip, shared);
1111        }
1112
1113        assert!(matches!(
1114            InterpolatedSearchError::InvalidStart {
1115                point: Point2::new(-1.0, 0.5),
1116            },
1117            InterpolatedSearchError::InvalidStart { .. }
1118        ));
1119        assert!(matches!(
1120            InterpolatedSearchError::InvalidGoal {
1121                point: Point2::new(2.5, 0.5),
1122            },
1123            InterpolatedSearchError::InvalidGoal { .. }
1124        ));
1125    }
1126
1127    #[test]
1128    fn shared_result_surface_reuses_fixed_and_moving_goal_field_d_star_outputs() {
1129        let mut partial_grid = Grid::new(4, 1).expect("grid dimensions are valid");
1130        let request = InterpolatedSearchRequest::new(Point2::new(0.5, 0.5), Point2::new(3.5, 0.5));
1131        let mut replanner = FieldDStar::new();
1132
1133        let initial = replanner
1134            .initialize(&partial_grid, request)
1135            .expect("test request should be valid");
1136        let initial_outcome = initial
1137            .path_outcome()
1138            .expect("initial result should expose a path outcome");
1139        assert_eq!(initial.expected_kind(), InterpolatedExpectedKind::Found);
1140        assert_eq!(initial_outcome.kind(), InterpolatedPathOutcomeKind::Found);
1141        assert_eq!(
1142            initial_outcome.derived_cost(
1143                &partial_grid,
1144                InterpolatedTraversalCostModel::CellLengthWeightedV0
1145            ),
1146            Some(initial_outcome.path().cost())
1147        );
1148
1149        let partial_barrier = Point::new(2, 0);
1150        partial_grid
1151            .set_cell(partial_barrier, Cell::Blocked)
1152            .expect("partial barrier should be valid");
1153        replanner.update_cell(partial_barrier, Cell::Blocked);
1154        let partial = replanner.replan().expect("test request should be valid");
1155        let partial_outcome = partial
1156            .path_outcome()
1157            .expect("partial-path result should expose a path outcome");
1158        assert_eq!(
1159            partial.expected_kind(),
1160            InterpolatedExpectedKind::PartialPath
1161        );
1162        assert_eq!(
1163            partial_outcome.kind(),
1164            InterpolatedPathOutcomeKind::PartialPath
1165        );
1166        let derived_partial_cost = partial_outcome
1167            .derived_cost(
1168                &partial_grid,
1169                InterpolatedTraversalCostModel::CellLengthWeightedV0,
1170            )
1171            .expect("partial-path result should derive a cost");
1172        let explicit_partial_cost = interpolated_path_cost(
1173            &partial_grid,
1174            partial_outcome.path().points(),
1175            InterpolatedTraversalCostModel::CellLengthWeightedV0,
1176        )
1177        .expect("partial-path result should stay valid on the grid");
1178        assert!((derived_partial_cost - explicit_partial_cost).abs() <= EPSILON);
1179
1180        replanner.update_goal(Point2::new(4.5, 0.5));
1181        assert_eq!(
1182            replanner.replan(),
1183            Err(InterpolatedSearchError::InvalidGoal {
1184                point: Point2::new(4.5, 0.5),
1185            })
1186        );
1187
1188        let mut fallback_grid = Grid::new(3, 1).expect("grid dimensions are valid");
1189        let fallback_request =
1190            InterpolatedSearchRequest::new(Point2::new(0.5, 0.5), Point2::new(2.5, 0.5));
1191        replanner
1192            .initialize(&fallback_grid, fallback_request)
1193            .expect("test request should be valid");
1194        let fallback_barrier = Point::new(1, 0);
1195        fallback_grid
1196            .set_cell(fallback_barrier, Cell::Blocked)
1197            .expect("fallback barrier should be valid");
1198        replanner.update_cell(fallback_barrier, Cell::Blocked);
1199        let fallback = replanner.replan().expect("test request should be valid");
1200        let fallback_outcome = fallback
1201            .path_outcome()
1202            .expect("fallback result should expose a path outcome");
1203        assert_eq!(fallback.expected_kind(), InterpolatedExpectedKind::Fallback);
1204        assert_eq!(
1205            fallback_outcome.kind(),
1206            InterpolatedPathOutcomeKind::Fallback
1207        );
1208        assert_eq!(fallback_outcome.witness_points(), 1);
1209    }
1210}