Skip to main content

condor_navmesh/algorithms/
ta_star.rs

1//! Static [`NavmeshPathfinder`]: TA* with local portal refinement.
2//!
3//! # Surface
4//!
5//! One-shot over a validated static [`Navmesh`], same
6//! lifecycle as [`ChannelSearch`](super::channel_search::ChannelSearch)—no preprocess
7//! and no dynamic overlay. Prepared multi-query routing is TRA*, not TA*.
8//!
9//! # Cost and behavior
10//!
11//! **Pipeline**: connectivity precheck → shared cell-corridor BFS → midpoint funnel
12//! baseline → local passes that may snap each portal seed to a portal endpoint when
13//! the two-segment turn cost strictly drops and both legs stay walkable → final
14//! funnel on the chosen seed.
15//!
16//! **Cost model**: Euclidean segment length. Baseline midpoints win unless refinement
17//! is cheaper (epsilon-strict) and walkable.
18
19use crate::navmesh::points_equal;
20use crate::{
21    Navmesh, NavmeshPathfinder, NavmeshQuery, NavmeshQueryResult, NavmeshSearchResult, Point2,
22    PolygonPath,
23};
24
25use super::channel_search::{portal_midpoint, search_cell_corridor};
26
27const EPSILON: f64 = 1e-9;
28
29/// Stateless pathfinder: corridor BFS, funnel, then optional local portal-endpoint refinement.
30///
31/// Same static-mesh contract as channel search; refinement never expands beyond the
32/// corridor already chosen by BFS.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct TAStar;
35
36impl NavmeshPathfinder for TAStar {
37    fn name(&self) -> &'static str {
38        "ta-star"
39    }
40
41    fn search(&self, navmesh: &Navmesh, query: NavmeshQuery) -> NavmeshSearchResult {
42        let (start_cell, goal_cell) = match navmesh.query(query) {
43            NavmeshQueryResult::Connected {
44                start_cell,
45                goal_cell,
46            } => (start_cell, goal_cell),
47            NavmeshQueryResult::InvalidStart => {
48                return Err(crate::NavmeshSearchError::InvalidStart { point: query.start });
49            }
50            NavmeshQueryResult::InvalidGoal => {
51                return Err(crate::NavmeshSearchError::InvalidGoal { point: query.goal });
52            }
53            NavmeshQueryResult::NoPath { .. } => return crate::navmesh::search_not_found(0),
54        };
55
56        if points_equal(query.start, query.goal) {
57            return crate::navmesh::search_found(
58                PolygonPath::from_points(vec![query.start])
59                    .expect("polygon path contains at least one point"),
60                1,
61            );
62        }
63
64        let (Some(cells), visited_nodes) =
65            search_cell_corridor(navmesh, start_cell, goal_cell, query.budget)?
66        else {
67            return crate::navmesh::search_not_found(0);
68        };
69
70        let Some(corridor) = crate::navmesh::corridor::NavmeshCorridor::from_cells(
71            navmesh,
72            query.start,
73            query.goal,
74            &cells,
75        ) else {
76            return crate::navmesh::search_not_found(visited_nodes);
77        };
78
79        let midpoint_seed = corridor
80            .portals
81            .iter()
82            .map(portal_midpoint)
83            .collect::<Vec<_>>();
84        let baseline_points =
85            crate::navmesh::funnel::pull_string(navmesh, &corridor, midpoint_seed.clone());
86        if baseline_points.len() >= 2 && !navmesh.path_is_walkable(&baseline_points) {
87            return crate::navmesh::search_not_found(visited_nodes);
88        }
89
90        let refined_seed = refine_query_locally(navmesh, &corridor, midpoint_seed);
91        let refined_points = crate::navmesh::funnel::pull_string(navmesh, &corridor, refined_seed);
92
93        let chosen_points = if refined_points.len() >= 2
94            && navmesh.path_is_walkable(&refined_points)
95            && path_cost(&refined_points) + EPSILON < path_cost(&baseline_points)
96        {
97            refined_points
98        } else {
99            baseline_points
100        };
101
102        crate::navmesh::search_found(
103            PolygonPath::from_points(chosen_points)
104                .expect("polygon path contains at least one point"),
105            visited_nodes,
106        )
107    }
108}
109
110fn refine_query_locally(
111    navmesh: &Navmesh,
112    corridor: &crate::navmesh::corridor::NavmeshCorridor,
113    seed_points: Vec<Point2>,
114) -> Vec<Point2> {
115    if seed_points.is_empty() {
116        return seed_points;
117    }
118
119    let mut refined = seed_points;
120    let max_passes = corridor.portals.len().max(1);
121
122    for _ in 0..max_passes {
123        let mut improved = false;
124
125        for index in 0..corridor.portals.len() {
126            let portal = corridor.portals[index];
127            let prev = if index == 0 {
128                corridor.start
129            } else {
130                refined[index - 1]
131            };
132            let next = if index + 1 == refined.len() {
133                corridor.goal
134            } else {
135                refined[index + 1]
136            };
137
138            let current = refined[index];
139            let current_cost = local_turn_cost(prev, current, next);
140
141            let mut best_point = current;
142            let mut best_cost = current_cost;
143            for candidate in [portal.start, portal.end] {
144                if !navmesh.segment_is_walkable(prev, candidate)
145                    || !navmesh.segment_is_walkable(candidate, next)
146                {
147                    continue;
148                }
149
150                let candidate_cost = local_turn_cost(prev, candidate, next);
151                if candidate_cost + EPSILON < best_cost {
152                    best_point = candidate;
153                    best_cost = candidate_cost;
154                }
155            }
156
157            if !points_equal(best_point, current) {
158                refined[index] = best_point;
159                improved = true;
160            }
161        }
162
163        if !improved {
164            break;
165        }
166    }
167
168    refined
169}
170
171fn local_turn_cost(prev: Point2, current: Point2, next: Point2) -> f64 {
172    segment_cost(prev, current) + segment_cost(current, next)
173}
174
175fn path_cost(points: &[Point2]) -> f64 {
176    points
177        .windows(2)
178        .map(|segment| segment_cost(segment[0], segment[1]))
179        .sum()
180}
181
182fn segment_cost(a: Point2, b: Point2) -> f64 {
183    ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
184}