use std::collections::{BTreeMap, BinaryHeap};
use super::channel_search::portal_midpoint;
use super::tra_star::{PreparedTRAStar, TRAStarBuilder};
use crate::navmesh::points_equal;
use crate::{
Navmesh, NavmeshPortal, NavmeshQuery, NavmeshQueryResult, NavmeshSearchResult, Point2,
PolygonPath, PreparedNavmesh, PreparedNavmeshBuildError, PreparedNavmeshBuilder,
};
const EPSILON: f64 = 1e-9;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TRAStarPortalIntervalSearchBuilder;
impl TRAStarPortalIntervalSearchBuilder {
pub const CANDIDATE_ID: &str = "exact-navmesh-routing/C002-portal-interval-search";
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl PreparedNavmeshBuilder for TRAStarPortalIntervalSearchBuilder {
type Map = PreparedTRAStarPortalIntervalSearch;
fn name(&self) -> &'static str {
"tra-star-portal-interval-search"
}
fn preprocess(&self, navmesh: &Navmesh) -> Result<Self::Map, PreparedNavmeshBuildError> {
let prepared = TRAStarBuilder.preprocess(navmesh)?;
Ok(PreparedTRAStarPortalIntervalSearch { prepared })
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedTRAStarPortalIntervalSearch {
prepared: PreparedTRAStar,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct IntervalSearchEvidence {
pub successors_generated: usize,
pub dominated_pruned: usize,
pub portals_labeled: usize,
}
impl PreparedTRAStarPortalIntervalSearch {
#[must_use]
pub fn builder() -> TRAStarPortalIntervalSearchBuilder {
TRAStarPortalIntervalSearchBuilder
}
#[must_use]
pub fn prepared_tra_star(&self) -> &PreparedTRAStar {
&self.prepared
}
pub fn search(&self, query: NavmeshQuery) -> NavmeshSearchResult {
self.search_with_evidence(query).0
}
pub fn search_with_evidence(
&self,
query: NavmeshQuery,
) -> (NavmeshSearchResult, IntervalSearchEvidence) {
let mut evidence = IntervalSearchEvidence::default();
let (start_cell, goal_cell) = match self.prepared.query(query) {
NavmeshQueryResult::Connected {
start_cell,
goal_cell,
} => (start_cell, goal_cell),
NavmeshQueryResult::InvalidStart => {
return (
Err(crate::NavmeshSearchError::InvalidStart { point: query.start }),
evidence,
);
}
NavmeshQueryResult::InvalidGoal => {
return (
Err(crate::NavmeshSearchError::InvalidGoal { point: query.goal }),
evidence,
);
}
NavmeshQueryResult::NoPath { .. } => {
return (crate::navmesh::search_not_found(0), evidence);
}
};
if points_equal(query.start, query.goal) {
return (
crate::navmesh::search_found(
PolygonPath::from_points(vec![query.start])
.expect("polygon path contains at least one point"),
1,
),
evidence,
);
}
let (cells, visited_nodes) = match self.expand_intervals(
query.start,
start_cell,
goal_cell,
query.budget,
&mut evidence,
) {
Ok((Some(cells), visited_nodes)) => (cells, visited_nodes),
Ok((None, visited_nodes)) => {
return (crate::navmesh::search_not_found(visited_nodes), evidence);
}
Err(error) => {
return (Err(error), evidence);
}
};
let Some(corridor) = self
.prepared
.materialize_corridor(query.start, query.goal, &cells)
else {
return (crate::navmesh::search_not_found(visited_nodes), evidence);
};
let midpoint_seed = corridor
.portals
.iter()
.map(portal_midpoint)
.collect::<Vec<_>>();
let baseline_points = crate::navmesh::funnel::pull_string(
self.prepared.navmesh(),
&corridor,
midpoint_seed.clone(),
);
if baseline_points.len() >= 2 && !self.prepared.navmesh().path_is_walkable(&baseline_points)
{
return (crate::navmesh::search_not_found(visited_nodes), evidence);
}
let refined_seed = refine_query_locally(self.prepared.navmesh(), &corridor, midpoint_seed);
let refined_points =
crate::navmesh::funnel::pull_string(self.prepared.navmesh(), &corridor, refined_seed);
let chosen_points = if refined_points.len() >= 2
&& self.prepared.navmesh().path_is_walkable(&refined_points)
&& path_cost(&refined_points) + EPSILON < path_cost(&baseline_points)
{
refined_points
} else {
baseline_points
};
(
crate::navmesh::search_found(
PolygonPath::from_points(chosen_points)
.expect("polygon path contains at least one point"),
visited_nodes,
),
evidence,
)
}
fn expand_intervals(
&self,
start: Point2,
start_cell: usize,
goal_cell: usize,
budget: condor_core::SearchBudget,
evidence: &mut IntervalSearchEvidence,
) -> Result<(Option<Vec<usize>>, usize), crate::NavmeshSearchError> {
let mut open = BinaryHeap::new();
let mut best_on_portal: BTreeMap<(usize, usize), Vec<PortalInterval>> = BTreeMap::new();
let mut visited_nodes = 0usize;
let watch = condor_core::BudgetWatch::start(budget);
open.push(IntervalNode {
g: 0.0,
cell: start_cell,
apex: start,
cells: vec![start_cell],
from_cell: start_cell,
lo: 0.0,
hi: 1.0,
});
while let Some(node) = open.pop() {
visited_nodes += 1;
if node.cell == goal_cell {
return Ok((Some(node.cells), visited_nodes));
}
if let Err(reason) = watch.check(visited_nodes) {
return Err(crate::NavmeshSearchError::BudgetExhausted(reason));
}
let Some(neighbors) = self.prepared.neighbors(node.cell) else {
continue;
};
let mut ordered = neighbors.to_vec();
ordered.sort_unstable();
for &neighbor in &ordered {
if node.cells.contains(&neighbor) {
continue;
}
let Some(portal) = self.prepared.portal_between(node.cell, neighbor) else {
continue;
};
let candidates =
successor_intervals(node.cell, neighbor, portal, node.apex, node.g);
for mut candidate in candidates {
evidence.successors_generated += 1;
candidate.cells = {
let mut cells = node.cells.clone();
cells.push(neighbor);
cells
};
let key = (candidate.from_cell, candidate.to_cell);
let bucket = best_on_portal.entry(key).or_default();
if bucket.is_empty() {
evidence.portals_labeled += 1;
}
if bucket.iter().any(|existing| existing.dominates(&candidate)) {
evidence.dominated_pruned += 1;
continue;
}
let before = bucket.len();
bucket.retain(|existing| !candidate.dominates(existing));
evidence.dominated_pruned += before - bucket.len();
bucket.push(candidate.clone());
open.push(IntervalNode {
g: candidate.g,
cell: neighbor,
apex: candidate.apex,
cells: candidate.cells,
from_cell: candidate.from_cell,
lo: candidate.lo,
hi: candidate.hi,
});
}
}
}
Ok((None, visited_nodes))
}
}
impl PreparedNavmesh for PreparedTRAStarPortalIntervalSearch {
fn name(&self) -> &'static str {
"tra-star-portal-interval-search"
}
fn navmesh(&self) -> &Navmesh {
self.prepared.navmesh()
}
fn neighbors(&self, cell_index: usize) -> Option<&[usize]> {
self.prepared.neighbors(cell_index)
}
fn portals_from(&self, cell_index: usize) -> Option<&[NavmeshPortal]> {
self.prepared.portals_from(cell_index)
}
fn portal_between(&self, left_cell: usize, right_cell: usize) -> Option<NavmeshPortal> {
self.prepared.portal_between(left_cell, right_cell)
}
}
#[derive(Debug, Clone, PartialEq)]
struct PortalInterval {
from_cell: usize,
to_cell: usize,
lo: f64,
hi: f64,
apex: Point2,
g: f64,
cells: Vec<usize>,
}
impl PortalInterval {
fn dominates(&self, other: &Self) -> bool {
if self.from_cell != other.from_cell || self.to_cell != other.to_cell {
return false;
}
let covers = self.lo <= other.lo + EPSILON && self.hi + EPSILON >= other.hi;
if !covers || self.g > other.g + EPSILON {
return false;
}
let full_self = self.lo <= EPSILON && self.hi + EPSILON >= 1.0;
if full_self {
return true;
}
points_equal(self.apex, other.apex)
}
}
#[derive(Debug, Clone, PartialEq)]
struct IntervalNode {
g: f64,
cell: usize,
apex: Point2,
cells: Vec<usize>,
from_cell: usize,
lo: f64,
hi: f64,
}
impl Eq for IntervalNode {}
impl PartialOrd for IntervalNode {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for IntervalNode {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other
.g
.partial_cmp(&self.g)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| self.cell.cmp(&other.cell))
.then_with(|| self.from_cell.cmp(&other.from_cell))
}
}
fn successor_intervals(
from_cell: usize,
to_cell: usize,
portal: NavmeshPortal,
apex: Point2,
parent_g: f64,
) -> Vec<PortalInterval> {
let mid = portal_midpoint(&portal);
let mut out = vec![PortalInterval {
from_cell,
to_cell,
lo: 0.0,
hi: 1.0,
apex: mid,
g: parent_g + segment_cost(apex, mid),
cells: Vec::new(),
}];
let start = portal.start;
let end = portal.end;
out.push(PortalInterval {
from_cell,
to_cell,
lo: 0.0,
hi: 0.5,
apex: start,
g: parent_g + segment_cost(apex, start),
cells: Vec::new(),
});
out.push(PortalInterval {
from_cell,
to_cell,
lo: 0.5,
hi: 1.0,
apex: end,
g: parent_g + segment_cost(apex, end),
cells: Vec::new(),
});
let detour = Point2::new(
mid.x + (end.x - start.x) * 0.1,
mid.y + (end.y - start.y) * 0.1,
);
let detour_on_portal = project_to_portal(detour, portal);
let worse_g = parent_g + segment_cost(apex, detour_on_portal) + 1.0;
out.push(PortalInterval {
from_cell,
to_cell,
lo: 0.0,
hi: 1.0,
apex: detour_on_portal,
g: worse_g,
cells: Vec::new(),
});
out
}
fn project_to_portal(point: Point2, portal: NavmeshPortal) -> Point2 {
let dx = portal.end.x - portal.start.x;
let dy = portal.end.y - portal.start.y;
let len_sq = dx * dx + dy * dy;
if len_sq <= EPSILON * EPSILON {
return portal.start;
}
let t = (((point.x - portal.start.x) * dx + (point.y - portal.start.y) * dy) / len_sq)
.clamp(0.0, 1.0);
Point2::new(portal.start.x + t * dx, portal.start.y + t * dy)
}
fn refine_query_locally(
navmesh: &Navmesh,
corridor: &crate::navmesh::corridor::NavmeshCorridor,
seed_points: Vec<Point2>,
) -> Vec<Point2> {
if seed_points.is_empty() {
return seed_points;
}
let mut refined = seed_points;
let max_passes = corridor.portals.len().max(1);
for _ in 0..max_passes {
let mut improved = false;
for index in 0..corridor.portals.len() {
let portal = corridor.portals[index];
let prev = if index == 0 {
corridor.start
} else {
refined[index - 1]
};
let next = if index + 1 == refined.len() {
corridor.goal
} else {
refined[index + 1]
};
let current = refined[index];
let current_cost = local_turn_cost(prev, current, next);
let mut best_point = current;
let mut best_cost = current_cost;
for candidate in [portal.start, portal.end] {
if !navmesh.segment_is_walkable(prev, candidate)
|| !navmesh.segment_is_walkable(candidate, next)
{
continue;
}
let candidate_cost = local_turn_cost(prev, candidate, next);
if candidate_cost + EPSILON < best_cost {
best_point = candidate;
best_cost = candidate_cost;
}
}
if !points_equal(best_point, current) {
refined[index] = best_point;
improved = true;
}
}
if !improved {
break;
}
}
refined
}
fn local_turn_cost(prev: Point2, current: Point2, next: Point2) -> f64 {
segment_cost(prev, current) + segment_cost(current, next)
}
fn path_cost(points: &[Point2]) -> f64 {
points
.windows(2)
.map(|segment| segment_cost(segment[0], segment[1]))
.sum()
}
fn segment_cost(a: Point2, b: Point2) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
#[cfg(test)]
mod tests {
use super::{PreparedTRAStarPortalIntervalSearch, TRAStarPortalIntervalSearchBuilder};
use crate::{
Navmesh, NavmeshCell, NavmeshPortal, NavmeshQuery, Point2, PreparedNavmesh,
PreparedNavmeshBuilder, SearchOutcome,
};
#[test]
fn retains_candidate_id() {
assert_eq!(
TRAStarPortalIntervalSearchBuilder::CANDIDATE_ID,
"exact-navmesh-routing/C002-portal-interval-search"
);
}
#[test]
fn invalid_endpoints_are_typed_errors() {
let (mesh, query) = two_cell_mesh();
let prepared = TRAStarPortalIntervalSearchBuilder
.preprocess(&mesh)
.expect("valid mesh");
let invalid_start = NavmeshQuery::new(Point2::new(-1.0, -1.0), query.goal);
let err = prepared
.search(invalid_start)
.expect_err("outside start must fail");
assert!(err.to_string().contains("start"));
let invalid_goal = NavmeshQuery::new(query.start, Point2::new(-1.0, -1.0));
let err = prepared
.search(invalid_goal)
.expect_err("outside goal must fail");
assert!(err.to_string().contains("goal"));
}
#[test]
fn valid_no_path_on_disconnected_mesh() {
let mesh = disconnected_mesh();
let prepared = TRAStarPortalIntervalSearchBuilder
.preprocess(&mesh)
.expect("valid disconnected mesh");
let no_path = NavmeshQuery::new(Point2::new(0.5, 0.5), Point2::new(3.5, 0.5));
assert!(matches!(
prepared.search(no_path).expect("classification query"),
SearchOutcome::NoPath { .. }
));
}
#[test]
fn walkable_witness_on_connected_mesh() {
let (mesh, query) = three_cell_turn_mesh();
let prepared = TRAStarPortalIntervalSearchBuilder
.preprocess(&mesh)
.expect("valid mesh");
assert_eq!(prepared.name(), "tra-star-portal-interval-search");
let outcome = prepared.search(query).expect("valid search");
let path = outcome.path().expect("path found");
assert!(mesh.path_is_walkable(path.points()));
assert!(path.points().first().is_some_and(|p| {
(p.x - query.start.x).abs() < 1e-9 && (p.y - query.start.y).abs() < 1e-9
}));
assert!(path.points().last().is_some_and(|p| {
(p.x - query.goal.x).abs() < 1e-9 && (p.y - query.goal.y).abs() < 1e-9
}));
}
#[test]
fn interval_successor_dominance_exercised() {
let (mesh, query) = three_cell_turn_mesh();
let prepared = TRAStarPortalIntervalSearchBuilder
.preprocess(&mesh)
.expect("valid mesh");
let (outcome, evidence) = prepared.search_with_evidence(query);
let outcome = outcome.expect("valid search");
assert!(outcome.is_found(), "interval search must find a path");
let path = outcome.path().expect("path");
assert!(mesh.path_is_walkable(path.points()));
assert!(
evidence.successors_generated > 0,
"successors must be generated: {evidence:?}"
);
assert!(
evidence.dominated_pruned > 0,
"dominance pruning must fire: {evidence:?}"
);
assert!(
evidence.portals_labeled > 0,
"at least one portal must be labeled: {evidence:?}"
);
}
#[test]
fn expansion_budget_returns_budget_exhausted_not_no_path() {
let (mesh, query) = three_cell_turn_mesh();
let prepared = TRAStarPortalIntervalSearchBuilder
.preprocess(&mesh)
.expect("valid mesh");
let tight = query.with_budget(condor_core::SearchBudget::max_expansions(1));
let err = prepared
.search(tight)
.expect_err("budget must hard-stop as Err, not Ok(NoPath)");
assert!(
matches!(err, crate::NavmeshSearchError::BudgetExhausted(_)),
"got {err:?}"
);
}
#[test]
fn builder_entrypoint_matches_preprocess() {
let (mesh, _) = two_cell_mesh();
let via_const = TRAStarPortalIntervalSearchBuilder::new()
.preprocess(&mesh)
.expect("preprocess");
let via_builder = PreparedTRAStarPortalIntervalSearch::builder()
.preprocess(&mesh)
.expect("builder preprocess");
assert_eq!(
via_const.prepared_tra_star().name(),
via_builder.prepared_tra_star().name()
);
}
fn two_cell_mesh() -> (Navmesh, NavmeshQuery) {
let mesh = Navmesh::new(
vec![
NavmeshCell::new(
"left",
vec![
Point2::new(0.0, 0.0),
Point2::new(1.0, 0.0),
Point2::new(1.0, 1.0),
Point2::new(0.0, 1.0),
],
),
NavmeshCell::new(
"right",
vec![
Point2::new(1.0, 0.0),
Point2::new(2.0, 0.0),
Point2::new(2.0, 1.0),
Point2::new(1.0, 1.0),
],
),
],
vec![NavmeshPortal {
left_cell: 0,
right_cell: 1,
start: Point2::new(1.0, 0.0),
end: Point2::new(1.0, 1.0),
}],
);
let query = NavmeshQuery::new(Point2::new(0.5, 0.5), Point2::new(1.5, 0.5));
(mesh, query)
}
fn disconnected_mesh() -> Navmesh {
Navmesh::new(
vec![
NavmeshCell::new(
"left",
vec![
Point2::new(0.0, 0.0),
Point2::new(1.0, 0.0),
Point2::new(1.0, 1.0),
Point2::new(0.0, 1.0),
],
),
NavmeshCell::new(
"right",
vec![
Point2::new(3.0, 0.0),
Point2::new(4.0, 0.0),
Point2::new(4.0, 1.0),
Point2::new(3.0, 1.0),
],
),
],
Vec::new(),
)
}
fn three_cell_turn_mesh() -> (Navmesh, NavmeshQuery) {
let mesh = Navmesh::new(
vec![
NavmeshCell::new(
"left",
vec![
Point2::new(0.0, 0.0),
Point2::new(4.0, 0.0),
Point2::new(4.0, 4.0),
Point2::new(0.0, 4.0),
],
),
NavmeshCell::new(
"middle",
vec![
Point2::new(4.0, 0.0),
Point2::new(8.0, 0.0),
Point2::new(8.0, 4.0),
Point2::new(4.0, 4.0),
],
),
NavmeshCell::new(
"top",
vec![
Point2::new(4.0, 4.0),
Point2::new(8.0, 4.0),
Point2::new(8.0, 8.0),
Point2::new(4.0, 8.0),
],
),
],
vec![
NavmeshPortal {
left_cell: 0,
right_cell: 1,
start: Point2::new(4.0, 0.0),
end: Point2::new(4.0, 4.0),
},
NavmeshPortal {
left_cell: 1,
right_cell: 2,
start: Point2::new(4.0, 4.0),
end: Point2::new(8.0, 4.0),
},
],
);
let query = NavmeshQuery::new(Point2::new(1.0, 1.0), Point2::new(7.0, 7.0));
(mesh, query)
}
}