#![allow(missing_docs)]
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
#[inline]
fn dot2(a: [f64; 2], b: [f64; 2]) -> f64 {
a[0] * b[0] + a[1] * b[1]
}
#[inline]
fn sub2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
[a[0] - b[0], a[1] - b[1]]
}
#[inline]
fn cross2(a: [f64; 2], b: [f64; 2]) -> f64 {
a[0] * b[1] - a[1] * b[0]
}
#[inline]
fn len2(a: [f64; 2]) -> f64 {
dot2(a, a).sqrt()
}
#[inline]
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[inline]
fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
#[inline]
fn len3(a: [f64; 3]) -> f64 {
dot3(a, a).sqrt()
}
#[inline]
fn centroid(verts: &[[f64; 3]], tri: [u32; 3]) -> [f64; 3] {
let v0 = verts[tri[0] as usize];
let v1 = verts[tri[1] as usize];
let v2 = verts[tri[2] as usize];
[
(v0[0] + v1[0] + v2[0]) / 3.0,
(v0[1] + v1[1] + v2[1]) / 3.0,
(v0[2] + v1[2] + v2[2]) / 3.0,
]
}
fn point_in_tri_xz(p: [f64; 3], verts: &[[f64; 3]], tri: [u32; 3]) -> bool {
let v0 = verts[tri[0] as usize];
let v1 = verts[tri[1] as usize];
let v2 = verts[tri[2] as usize];
let e0 = sub2([v1[0] - v0[0], v1[2] - v0[2]], [0.0, 0.0]);
let _ = e0; let d0 = [v1[0] - v0[0], v1[2] - v0[2]];
let d1 = [v2[0] - v0[0], v2[2] - v0[2]];
let dp = [p[0] - v0[0], p[2] - v0[2]];
let d00 = dot2(d0, d0);
let d01 = dot2(d0, d1);
let d11 = dot2(d1, d1);
let d20 = dot2(dp, d0);
let d21 = dot2(dp, d1);
let denom = d00 * d11 - d01 * d01;
if denom.abs() < 1e-14 {
return false; }
let inv = 1.0 / denom;
let v = (d11 * d20 - d01 * d21) * inv;
let w = (d00 * d21 - d01 * d20) * inv;
let u = 1.0 - v - w;
const EPS: f64 = -1e-8;
u >= EPS && v >= EPS && w >= EPS
}
#[inline]
fn midpoint3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
scale3(add3(a, b), 0.5)
}
fn edge_blocked(a: [f64; 3], b: [f64; 3], agent_radius: f64, obstacles: &[Circle2D]) -> bool {
let mid = midpoint3(a, b);
let mx = mid[0];
let mz = mid[2];
for obs in obstacles {
let dx = mx - obs.center[0];
let dz = mz - obs.center[1];
let dist = (dx * dx + dz * dz).sqrt();
if dist < obs.radius + agent_radius {
return true;
}
}
false
}
#[derive(Clone, Copy, PartialEq)]
struct Cost(f64);
impl Eq for Cost {}
impl PartialOrd for Cost {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Cost {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&other.0)
}
}
#[derive(Clone, Copy, Eq, PartialEq)]
struct OpenEntry {
priority: Reverse<Cost>,
tri: usize,
}
impl PartialOrd for OpenEntry {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for OpenEntry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.priority.cmp(&other.priority)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NavMesh {
pub vertices: Vec<[f64; 3]>,
pub tris: Vec<[u32; 3]>,
pub adjacency: Vec<[i32; 3]>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Circle2D {
pub center: [f64; 2],
pub radius: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Path {
pub waypoints: Vec<[f64; 3]>,
pub length: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum NavError {
NoPath,
StartOutsideMesh,
GoalOutsideMesh,
EmptyMesh,
}
impl std::fmt::Display for NavError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NavError::NoPath => write!(f, "no path found"),
NavError::StartOutsideMesh => write!(f, "start point is outside the navigation mesh"),
NavError::GoalOutsideMesh => write!(f, "goal point is outside the navigation mesh"),
NavError::EmptyMesh => write!(f, "navigation mesh has no triangles"),
}
}
}
impl std::error::Error for NavError {}
impl NavMesh {
pub fn from_triangles(vertices: Vec<[f64; 3]>, tris: Vec<[u32; 3]>) -> Self {
let n = tris.len();
let mut adjacency = vec![[-1i32; 3]; n];
let mut edge_map: HashMap<(u32, u32), Vec<(usize, usize)>> = HashMap::new();
for (ti, tri) in tris.iter().enumerate() {
let edges = [
(tri[1], tri[2]), (tri[0], tri[2]), (tri[0], tri[1]), ];
for (slot, &(va, vb)) in edges.iter().enumerate() {
let key = if va < vb { (va, vb) } else { (vb, va) };
edge_map.entry(key).or_default().push((ti, slot));
}
}
for entries in edge_map.values() {
if entries.len() == 2 {
let (ti, si) = entries[0];
let (tj, sj) = entries[1];
adjacency[ti][si] = tj as i32;
adjacency[tj][sj] = ti as i32;
}
}
Self {
vertices,
tris,
adjacency,
}
}
pub fn find_triangle(&self, point: [f64; 3]) -> Option<usize> {
for (i, &tri) in self.tris.iter().enumerate() {
if point_in_tri_xz(point, &self.vertices, tri) {
return Some(i);
}
}
None
}
pub fn find_path(
&self,
start: [f64; 3],
goal: [f64; 3],
agent_radius: f64,
obstacles: &[Circle2D],
) -> Result<Path, NavError> {
if self.tris.is_empty() {
return Err(NavError::EmptyMesh);
}
let start_tri = self
.find_triangle(start)
.ok_or(NavError::StartOutsideMesh)?;
let goal_tri = self.find_triangle(goal).ok_or(NavError::GoalOutsideMesh)?;
if start_tri == goal_tri {
let length = len3(sub3(goal, start));
return Ok(Path {
waypoints: vec![start, goal],
length,
});
}
let mut came_from: HashMap<usize, (usize, usize)> = HashMap::new();
let mut g_score: HashMap<usize, f64> = HashMap::new();
let mut open: BinaryHeap<OpenEntry> = BinaryHeap::new();
g_score.insert(start_tri, 0.0);
open.push(OpenEntry {
priority: Reverse(Cost(0.0)),
tri: start_tri,
});
let goal_centroid = centroid(&self.vertices, self.tris[goal_tri]);
let mut found = false;
while let Some(entry) = open.pop() {
let current = entry.tri;
if current == goal_tri {
found = true;
break;
}
let current_g = *g_score.get(¤t).unwrap_or(&f64::INFINITY);
let adj = self.adjacency[current];
for (slot, &neighbor_idx) in adj.iter().enumerate() {
if neighbor_idx < 0 {
continue; }
let neighbor = neighbor_idx as usize;
let va = self.vertices[self.tris[current][(slot + 1) % 3] as usize];
let vb = self.vertices[self.tris[current][(slot + 2) % 3] as usize];
if edge_blocked(va, vb, agent_radius, obstacles) {
continue;
}
let c_cur = centroid(&self.vertices, self.tris[current]);
let c_nbr = centroid(&self.vertices, self.tris[neighbor]);
let step_cost = len3(sub3(c_nbr, c_cur));
let tent_g = current_g + step_cost;
let prev_g = *g_score.get(&neighbor).unwrap_or(&f64::INFINITY);
if tent_g < prev_g {
g_score.insert(neighbor, tent_g);
came_from.insert(neighbor, (current, slot));
let h = len3(sub3(goal_centroid, c_nbr));
let f = tent_g + h;
open.push(OpenEntry {
priority: Reverse(Cost(f)),
tri: neighbor,
});
if neighbor == goal_tri {
let _ = found; }
}
}
}
if !found && !came_from.contains_key(&goal_tri) && start_tri != goal_tri {
return Err(NavError::NoPath);
}
let mut corridor: Vec<usize> = Vec::new();
{
let mut cur = goal_tri;
loop {
corridor.push(cur);
if cur == start_tri {
break;
}
match came_from.get(&cur) {
Some(&(parent, _)) => cur = parent,
None => return Err(NavError::NoPath),
}
}
}
corridor.reverse();
let mut portals: Vec<([f64; 3], [f64; 3])> = Vec::new();
for i in 0..corridor.len().saturating_sub(1) {
let from_tri = corridor[i];
let to_tri = corridor[i + 1];
let adj = self.adjacency[from_tri];
let found_slot = adj.iter().position(|&a| a == to_tri as i32);
let slot = match found_slot {
Some(s) => s,
None => return Err(NavError::NoPath),
};
let lv = self.tris[from_tri][(slot + 1) % 3] as usize;
let rv = self.tris[from_tri][(slot + 2) % 3] as usize;
portals.push((self.vertices[lv], self.vertices[rv]));
}
portals.push((goal, goal));
let waypoints = funnel_smooth(start, &portals);
let length = compute_path_length(&waypoints);
Ok(Path { waypoints, length })
}
}
#[inline]
fn xz(p: [f64; 3]) -> [f64; 2] {
[p[0], p[2]]
}
#[inline]
fn xz_to_3d(xz_pt: [f64; 2], y: f64) -> [f64; 3] {
[xz_pt[0], y, xz_pt[1]]
}
fn funnel_smooth(start: [f64; 3], portals: &[([f64; 3], [f64; 3])]) -> Vec<[f64; 3]> {
if portals.is_empty() {
return vec![start];
}
let mut waypoints: Vec<[f64; 3]> = vec![start];
let apex = xz(start);
let apex_3d = start;
let mut left_leg = apex;
let mut right_leg = apex;
let mut portal_idx = 0usize;
while portal_idx < portals.len() {
let (pl, pr) = portals[portal_idx];
let new_left = xz(pl);
let new_right = xz(pr);
let apex_to_left = sub2(left_leg, apex);
let apex_to_right = sub2(right_leg, apex);
let apex_to_new_left = sub2(new_left, apex);
let apex_to_new_right = sub2(new_right, apex);
if cross2(apex_to_right, apex_to_new_right) >= 0.0 {
if cross2(apex_to_left, apex_to_new_right) > 0.0 {
waypoints.push(apex_3d);
let new_apex_3d = find_portal_vertex_3d(portals, left_leg, portal_idx);
let restart_idx = find_apex_portal_idx(portals, left_leg, portal_idx);
let funnel_tail = funnel_smooth(new_apex_3d, &portals[restart_idx..]);
for w in funnel_tail.into_iter().skip(1) {
waypoints.push(w);
}
return waypoints;
}
right_leg = new_right;
}
if cross2(apex_to_left, apex_to_new_left) <= 0.0 {
if cross2(apex_to_right, apex_to_new_left) < 0.0 {
waypoints.push(apex_3d);
let new_apex_3d = find_portal_vertex_3d(portals, right_leg, portal_idx);
let restart_idx = find_apex_portal_idx(portals, right_leg, portal_idx);
let funnel_tail = funnel_smooth(new_apex_3d, &portals[restart_idx..]);
for w in funnel_tail.into_iter().skip(1) {
waypoints.push(w);
}
return waypoints;
}
left_leg = new_left;
}
portal_idx += 1;
}
if let Some(&(goal, _)) = portals.last() {
waypoints.push(goal);
}
waypoints
}
fn find_portal_vertex_3d(
portals: &[([f64; 3], [f64; 3])],
xz_pt: [f64; 2],
limit: usize,
) -> [f64; 3] {
for &(pl, pr) in portals.iter().take(limit + 1) {
if approx_eq2(xz(pl), xz_pt) {
return pl;
}
if approx_eq2(xz(pr), xz_pt) {
return pr;
}
}
xz_to_3d(xz_pt, 0.0)
}
fn find_apex_portal_idx(portals: &[([f64; 3], [f64; 3])], xz_pt: [f64; 2], limit: usize) -> usize {
for (i, &(pl, pr)) in portals.iter().take(limit + 1).enumerate() {
if approx_eq2(xz(pl), xz_pt) || approx_eq2(xz(pr), xz_pt) {
return i + 1;
}
}
limit + 1
}
#[inline]
fn approx_eq2(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9
}
fn compute_path_length(waypoints: &[[f64; 3]]) -> f64 {
if waypoints.len() < 2 {
return 0.0;
}
waypoints.windows(2).map(|w| len3(sub3(w[1], w[0]))).sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_grid_mesh(rows: usize, cols: usize, cell_size: f64) -> NavMesh {
let mut verts: Vec<[f64; 3]> = Vec::new();
for i in 0..=cols {
for j in 0..=rows {
verts.push([i as f64 * cell_size, 0.0, j as f64 * cell_size]);
}
}
let vi = |i: usize, j: usize| -> u32 { (i * (rows + 1) + j) as u32 };
let mut tris: Vec<[u32; 3]> = Vec::new();
for i in 0..cols {
for j in 0..rows {
tris.push([vi(i, j), vi(i + 1, j), vi(i, j + 1)]);
tris.push([vi(i + 1, j), vi(i + 1, j + 1), vi(i, j + 1)]);
}
}
NavMesh::from_triangles(verts, tris)
}
#[test]
fn test_adjacency_counts() {
let mesh = make_grid_mesh(2, 2, 1.0);
assert_eq!(mesh.tris.len(), 8, "2x2 grid should have 8 triangles");
let border_count: usize = mesh
.adjacency
.iter()
.flat_map(|adj| adj.iter())
.filter(|&&a| a == -1)
.count();
let interior_count: usize = mesh
.adjacency
.iter()
.flat_map(|adj| adj.iter())
.filter(|&&a| a >= 0)
.count();
assert_eq!(border_count + interior_count, 24, "total edge slots = 24");
assert!(border_count > 0, "grid must have border edges");
assert!(interior_count > 0, "grid must have interior edges");
assert!(
interior_count >= 2,
"should have at least some interior adjacency"
);
}
#[test]
fn test_find_triangle_inside() {
let mesh = make_grid_mesh(1, 1, 1.0);
let result = mesh.find_triangle([0.5, 0.0, 0.5]);
assert!(result.is_some(), "point [0.5,0,0.5] should be inside mesh");
}
#[test]
fn test_find_triangle_outside() {
let mesh = make_grid_mesh(1, 1, 1.0);
let result = mesh.find_triangle([2.0, 0.0, 2.0]);
assert!(result.is_none(), "point [2,0,2] should be outside mesh");
}
#[test]
fn test_simple_path() {
let mesh = make_grid_mesh(10, 10, 1.0);
let path = mesh
.find_path([0.5, 0.0, 0.5], [9.5, 0.0, 9.5], 0.0, &[])
.expect("path should exist");
assert!(
path.waypoints.len() >= 2,
"path should have at least start and goal"
);
assert!(path.length > 0.0, "path length should be positive");
}
#[test]
fn test_obstacle_forces_detour_or_nopath() {
let mesh = make_grid_mesh(10, 10, 1.0);
let obstacles: Vec<Circle2D> = (1..9)
.map(|k| Circle2D {
center: [k as f64 + 0.5, k as f64 + 0.5],
radius: 1.5,
})
.collect();
let result = mesh.find_path([0.5, 0.0, 0.5], [9.5, 0.0, 9.5], 0.0, &obstacles);
match result {
Ok(path) => {
assert!(path.waypoints.len() >= 2);
assert!(path.length > 0.0);
}
Err(NavError::NoPath) => {
}
Err(e) => panic!("unexpected error: {e}"),
}
}
#[test]
fn test_obstacle_blocks_corridor() {
let mesh = make_grid_mesh(1, 5, 1.0);
let obstacles = vec![Circle2D {
center: [2.5, 0.5],
radius: 2.0, }];
let result = mesh.find_path([0.5, 0.0, 0.5], [4.5, 0.0, 0.5], 0.0, &obstacles);
assert!(
matches!(result, Err(NavError::NoPath)),
"large obstacle should block all portals → NoPath"
);
}
#[test]
fn test_funnel_smoothing_straight_corridor() {
let mesh = make_grid_mesh(1, 10, 1.0);
let path = mesh
.find_path([0.5, 0.0, 0.5], [9.5, 0.0, 0.5], 0.0, &[])
.expect("straight corridor path should exist");
let corridor_tri_count = 20; assert!(
path.waypoints.len() < corridor_tri_count,
"funnel should reduce waypoints; got {} for {} triangles",
path.waypoints.len(),
corridor_tri_count
);
}
#[test]
fn test_serde_round_trip() {
let mesh = make_grid_mesh(3, 3, 1.0);
let json = serde_json::to_string(&mesh).expect("serialize mesh");
let mesh2: NavMesh = serde_json::from_str(&json).expect("deserialize mesh");
assert_eq!(
mesh.vertices.len(),
mesh2.vertices.len(),
"vertex count should survive round-trip"
);
assert_eq!(
mesh.tris.len(),
mesh2.tris.len(),
"tri count should survive round-trip"
);
let path = mesh
.find_path([0.5, 0.0, 0.5], [2.5, 0.0, 2.5], 0.0, &[])
.expect("path in 3x3 mesh");
let pjson = serde_json::to_string(&path).expect("serialize path");
let path2: Path = serde_json::from_str(&pjson).expect("deserialize path");
assert_eq!(
path.waypoints.len(),
path2.waypoints.len(),
"waypoint count should survive round-trip"
);
}
#[test]
fn test_start_outside_mesh() {
let mesh = make_grid_mesh(10, 10, 1.0);
let result = mesh.find_path([100.0, 0.0, 100.0], [5.0, 0.0, 5.0], 0.0, &[]);
assert!(
matches!(result, Err(NavError::StartOutsideMesh)),
"point far outside should give StartOutsideMesh"
);
}
#[test]
fn test_empty_mesh_error() {
let mesh = NavMesh {
vertices: vec![],
tris: vec![],
adjacency: vec![],
};
let result = mesh.find_path([0.0, 0.0, 0.0], [1.0, 0.0, 1.0], 0.0, &[]);
assert!(matches!(result, Err(NavError::EmptyMesh)));
}
#[test]
fn test_same_triangle_path() {
let mesh = make_grid_mesh(1, 1, 1.0);
let path = mesh
.find_path([0.1, 0.0, 0.1], [0.2, 0.0, 0.2], 0.0, &[])
.expect("trivial path");
assert_eq!(path.waypoints.len(), 2, "trivial path = just start + goal");
}
#[test]
fn test_nav_error_display() {
let e = NavError::NoPath;
assert!(!format!("{e}").is_empty());
}
}