Skip to main content

proof_engine/pathfinding/
navmesh.rs

1// src/pathfinding/navmesh.rs
2// Navigation mesh implementation with convex polygon graph, portal edges,
3// string-pulling path smoothing, dynamic obstacle cutting, and area cost modifiers.
4
5use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
6use std::cmp::Ordering;
7
8// ── Basic geometry types ─────────────────────────────────────────────────────
9
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct Vec2 {
12    pub x: f32,
13    pub y: f32,
14}
15
16impl Vec2 {
17    #[inline] pub fn new(x: f32, y: f32) -> Self { Self { x, y } }
18    #[inline] pub fn zero() -> Self { Self { x: 0.0, y: 0.0 } }
19    #[inline] pub fn dot(self, o: Self) -> f32 { self.x * o.x + self.y * o.y }
20    #[inline] pub fn cross(self, o: Self) -> f32 { self.x * o.y - self.y * o.x }
21    #[inline] pub fn len_sq(self) -> f32 { self.dot(self) }
22    #[inline] pub fn len(self) -> f32 { self.len_sq().sqrt() }
23    #[inline] pub fn norm(self) -> Self {
24        let l = self.len();
25        if l < 1e-9 { Self::zero() } else { Self::new(self.x / l, self.y / l) }
26    }
27    #[inline] pub fn sub(self, o: Self) -> Self { Self::new(self.x - o.x, self.y - o.y) }
28    #[inline] pub fn add(self, o: Self) -> Self { Self::new(self.x + o.x, self.y + o.y) }
29    #[inline] pub fn scale(self, s: f32) -> Self { Self::new(self.x * s, self.y * s) }
30    #[inline] pub fn lerp(self, o: Self, t: f32) -> Self {
31        Self::new(self.x + (o.x - self.x) * t, self.y + (o.y - self.y) * t)
32    }
33    #[inline] pub fn dist(self, o: Self) -> f32 { self.sub(o).len() }
34    #[inline] pub fn dist_sq(self, o: Self) -> f32 { self.sub(o).len_sq() }
35    #[inline] pub fn perp(self) -> Self { Self::new(-self.y, self.x) }
36}
37
38// ── Area flags and cost ──────────────────────────────────────────────────────
39
40/// Bit flags for polygon area types (walkable, water, etc.)
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
42pub struct AreaFlags(pub u32);
43
44impl AreaFlags {
45    pub const WALKABLE: Self  = Self(1 << 0);
46    pub const WATER:    Self  = Self(1 << 1);
47    pub const ROAD:     Self  = Self(1 << 2);
48    pub const GRASS:    Self  = Self(1 << 3);
49    pub const HAZARD:   Self  = Self(1 << 4);
50    pub const BLOCKED:  Self  = Self(1 << 5);
51    pub const ALL:      Self  = Self(u32::MAX);
52    pub const NONE:     Self  = Self(0);
53
54    #[inline] pub fn contains(self, other: Self) -> bool { (self.0 & other.0) == other.0 }
55    #[inline] pub fn union(self, other: Self) -> Self { Self(self.0 | other.0) }
56    #[inline] pub fn intersect(self, other: Self) -> Self { Self(self.0 & other.0) }
57}
58
59/// Per-area traversal cost modifier. Default 1.0 = normal speed.
60#[derive(Clone, Debug)]
61pub struct AreaCost {
62    pub costs: HashMap<AreaFlags, f32>,
63}
64
65impl Default for AreaCost {
66    fn default() -> Self {
67        let mut costs = HashMap::new();
68        costs.insert(AreaFlags::WALKABLE, 1.0);
69        costs.insert(AreaFlags::WATER,    3.0);
70        costs.insert(AreaFlags::ROAD,     0.8);
71        costs.insert(AreaFlags::GRASS,    1.2);
72        costs.insert(AreaFlags::HAZARD,   5.0);
73        Self { costs }
74    }
75}
76
77impl AreaCost {
78    pub fn get(&self, flags: AreaFlags) -> f32 {
79        for (k, v) in &self.costs {
80            if flags.contains(*k) { return *v; }
81        }
82        1.0
83    }
84    pub fn set(&mut self, flags: AreaFlags, cost: f32) {
85        self.costs.insert(flags, cost);
86    }
87}
88
89// ── NavPoly ──────────────────────────────────────────────────────────────────
90
91/// Unique identifier for a navigation polygon.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
93pub struct NavPolyId(pub u32);
94
95/// A convex polygon on the navigation mesh.
96#[derive(Clone, Debug)]
97pub struct NavPoly {
98    pub id:       NavPolyId,
99    pub verts:    Vec<Vec2>,      // vertices in CCW order
100    pub centroid: Vec2,
101    pub area:     AreaFlags,
102    pub cost:     f32,            // base traversal cost
103    // indices into NavMesh::portals
104    pub portals:  Vec<usize>,
105}
106
107impl NavPoly {
108    pub fn new(id: NavPolyId, verts: Vec<Vec2>, area: AreaFlags, cost: f32) -> Self {
109        let centroid = Self::compute_centroid(&verts);
110        Self { id, verts, centroid, area, cost, portals: Vec::new() }
111    }
112
113    fn compute_centroid(verts: &[Vec2]) -> Vec2 {
114        if verts.is_empty() { return Vec2::zero(); }
115        let sum = verts.iter().fold(Vec2::zero(), |a, &v| a.add(v));
116        sum.scale(1.0 / verts.len() as f32)
117    }
118
119    /// Test whether a 2-D point lies inside this convex polygon (CCW winding).
120    pub fn contains_point(&self, p: Vec2) -> bool {
121        let n = self.verts.len();
122        if n < 3 { return false; }
123        for i in 0..n {
124            let a = self.verts[i];
125            let b = self.verts[(i + 1) % n];
126            let ab = b.sub(a);
127            let ap = p.sub(a);
128            if ab.cross(ap) < 0.0 { return false; }
129        }
130        true
131    }
132
133    /// Closest point on the polygon boundary (or inside) to `p`.
134    pub fn closest_point(&self, p: Vec2) -> Vec2 {
135        if self.contains_point(p) { return p; }
136        let n = self.verts.len();
137        let mut best = self.verts[0];
138        let mut best_dist = f32::MAX;
139        for i in 0..n {
140            let a = self.verts[i];
141            let b = self.verts[(i + 1) % n];
142            let c = closest_point_on_segment(a, b, p);
143            let d = c.dist_sq(p);
144            if d < best_dist { best_dist = d; best = c; }
145        }
146        best
147    }
148
149    /// Signed area (positive = CCW).
150    pub fn signed_area(&self) -> f32 {
151        let n = self.verts.len();
152        let mut area = 0.0f32;
153        for i in 0..n {
154            let a = self.verts[i];
155            let b = self.verts[(i + 1) % n];
156            area += a.cross(b);
157        }
158        area * 0.5
159    }
160}
161
162fn closest_point_on_segment(a: Vec2, b: Vec2, p: Vec2) -> Vec2 {
163    let ab = b.sub(a);
164    let ap = p.sub(a);
165    let t = ap.dot(ab) / (ab.len_sq() + 1e-12);
166    let t = t.clamp(0.0, 1.0);
167    a.add(ab.scale(t))
168}
169
170// ── Portal edge ──────────────────────────────────────────────────────────────
171
172/// A portal is the shared edge between two adjacent polygons.
173#[derive(Clone, Debug)]
174pub struct NavPortal {
175    pub poly_a: NavPolyId,
176    pub poly_b: NavPolyId,
177    pub left:   Vec2,   // left endpoint of the portal edge
178    pub right:  Vec2,   // right endpoint of the portal edge
179}
180
181impl NavPortal {
182    pub fn midpoint(&self) -> Vec2 {
183        self.left.lerp(self.right, 0.5)
184    }
185    pub fn width(&self) -> f32 {
186        self.left.dist(self.right)
187    }
188    /// The "other" polygon given one side.
189    pub fn other(&self, poly: NavPolyId) -> NavPolyId {
190        if poly == self.poly_a { self.poly_b } else { self.poly_a }
191    }
192}
193
194// ── NavMesh ──────────────────────────────────────────────────────────────────
195
196/// The navigation mesh: a graph of convex polygons connected by portals.
197#[derive(Clone, Debug, Default)]
198pub struct NavMesh {
199    pub polys:   Vec<NavPoly>,
200    pub portals: Vec<NavPortal>,
201    /// Lookup: poly id → index in polys vec
202    poly_index:  HashMap<NavPolyId, usize>,
203    next_id:     u32,
204    pub area_cost: AreaCost,
205}
206
207impl NavMesh {
208    pub fn new() -> Self { Self::default() }
209
210    // ── Building ─────────────────────────────────────────────────────────────
211
212    pub fn add_poly(&mut self, verts: Vec<Vec2>, area: AreaFlags, cost: f32) -> NavPolyId {
213        let id = NavPolyId(self.next_id);
214        self.next_id += 1;
215        let idx = self.polys.len();
216        self.polys.push(NavPoly::new(id, verts, area, cost));
217        self.poly_index.insert(id, idx);
218        id
219    }
220
221    /// Connect two polygons through a shared edge defined by endpoints.
222    /// The edge is ordered so that `left` and `right` are from the perspective
223    /// of standing in poly_a looking into poly_b.
224    pub fn add_portal(&mut self, poly_a: NavPolyId, poly_b: NavPolyId, left: Vec2, right: Vec2) -> usize {
225        let portal_idx = self.portals.len();
226        self.portals.push(NavPortal { poly_a, poly_b, left, right });
227        if let Some(&ia) = self.poly_index.get(&poly_a) {
228            self.polys[ia].portals.push(portal_idx);
229        }
230        if let Some(&ib) = self.poly_index.get(&poly_b) {
231            self.polys[ib].portals.push(portal_idx);
232        }
233        portal_idx
234    }
235
236    /// Auto-connect all pairs of adjacent polygons that share an edge.
237    pub fn build_portals_from_edges(&mut self) {
238        let n = self.polys.len();
239        for i in 0..n {
240            for j in (i + 1)..n {
241                let pid_a = self.polys[i].id;
242                let pid_b = self.polys[j].id;
243                if let Some((left, right)) = Self::find_shared_edge(&self.polys[i], &self.polys[j]) {
244                    let portal_idx = self.portals.len();
245                    self.portals.push(NavPortal { poly_a: pid_a, poly_b: pid_b, left, right });
246                    self.polys[i].portals.push(portal_idx);
247                    self.polys[j].portals.push(portal_idx);
248                }
249            }
250        }
251    }
252
253    fn find_shared_edge(a: &NavPoly, b: &NavPoly) -> Option<(Vec2, Vec2)> {
254        const EPS: f32 = 1e-4;
255        let na = a.verts.len();
256        let nb = b.verts.len();
257        for i in 0..na {
258            let va0 = a.verts[i];
259            let va1 = a.verts[(i + 1) % na];
260            for j in 0..nb {
261                let vb0 = b.verts[j];
262                let vb1 = b.verts[(j + 1) % nb];
263                // shared edge if endpoints match (in any order)
264                let fwd = va0.dist_sq(vb0) < EPS && va1.dist_sq(vb1) < EPS;
265                let rev = va0.dist_sq(vb1) < EPS && va1.dist_sq(vb0) < EPS;
266                if fwd || rev { return Some((va0, va1)); }
267            }
268        }
269        None
270    }
271
272    // ── Queries ───────────────────────────────────────────────────────────────
273
274    pub fn poly_at_point(&self, p: Vec2) -> Option<NavPolyId> {
275        for poly in &self.polys {
276            if !poly.area.contains(AreaFlags::BLOCKED) && poly.contains_point(p) {
277                return Some(poly.id);
278            }
279        }
280        None
281    }
282
283    pub fn poly_by_id(&self, id: NavPolyId) -> Option<&NavPoly> {
284        self.poly_index.get(&id).map(|&i| &self.polys[i])
285    }
286
287    fn poly_by_id_mut(&mut self, id: NavPolyId) -> Option<&mut NavPoly> {
288        let i = *self.poly_index.get(&id)?;
289        Some(&mut self.polys[i])
290    }
291
292    /// Returns the closest point on the navmesh to `p`.
293    pub fn closest_point_on_mesh(&self, p: Vec2) -> NavPoint {
294        let mut best_pt = p;
295        let mut best_poly = NavPolyId(0);
296        let mut best_dist = f32::MAX;
297        for poly in &self.polys {
298            if poly.area.contains(AreaFlags::BLOCKED) { continue; }
299            let cp = poly.closest_point(p);
300            let d = cp.dist_sq(p);
301            if d < best_dist {
302                best_dist = d;
303                best_pt = cp;
304                best_poly = poly.id;
305            }
306        }
307        NavPoint { pos: best_pt, poly: best_poly }
308    }
309
310    // ── A* over navmesh ───────────────────────────────────────────────────────
311
312    /// Pathfind from `start` to `end` position, returning polygon corridor and portals.
313    pub fn find_path(&self, start: Vec2, end: Vec2, filter: AreaFlags) -> Option<NavPath> {
314        let start_poly = self.poly_at_point(start)
315            .unwrap_or_else(|| self.closest_point_on_mesh(start).poly);
316        let end_poly = self.poly_at_point(end)
317            .unwrap_or_else(|| self.closest_point_on_mesh(end).poly);
318
319        if start_poly == end_poly {
320            return Some(NavPath {
321                polys:   vec![start_poly],
322                portals: Vec::new(),
323                waypoints: vec![start, end],
324            });
325        }
326
327        // A* over polygon graph
328        let mut open: BinaryHeap<AStarEntry> = BinaryHeap::new();
329        let mut came_from: HashMap<NavPolyId, (NavPolyId, usize)> = HashMap::new(); // poly -> (prev_poly, portal_idx)
330        let mut g_score: HashMap<NavPolyId, f32> = HashMap::new();
331
332        g_score.insert(start_poly, 0.0);
333        open.push(AStarEntry {
334            poly: start_poly,
335            f: self.heuristic(start_poly, end_poly),
336        });
337
338        while let Some(AStarEntry { poly: current, .. }) = open.pop() {
339            if current == end_poly {
340                let (polys, portal_indices) = self.reconstruct_corridor(start_poly, end_poly, &came_from);
341                let portals: Vec<NavPortal> = portal_indices.iter().map(|&i| self.portals[i].clone()).collect();
342                let waypoints = self.string_pull(start, end, &polys, &portals);
343                return Some(NavPath { polys, portals, waypoints });
344            }
345
346            let current_g = *g_score.get(&current).unwrap_or(&f32::MAX);
347            let portal_idxs: Vec<usize> = if let Some(p) = self.poly_by_id(current) {
348                p.portals.clone()
349            } else { continue };
350
351            for pidx in portal_idxs {
352                let portal = &self.portals[pidx];
353                let neighbor = portal.other(current);
354                if let Some(npoly) = self.poly_by_id(neighbor) {
355                    if !npoly.area.intersect(filter).contains(AreaFlags::WALKABLE) { continue; }
356                    if npoly.area.contains(AreaFlags::BLOCKED) { continue; }
357                    let cost_mod = self.area_cost.get(npoly.area);
358                    let edge_cost = current_g + portal.midpoint().dist(
359                        self.poly_by_id(current).map(|p| p.centroid).unwrap_or(Vec2::zero())
360                    ) * cost_mod;
361                    let ng = current_g + edge_cost.max(0.1);
362                    if ng < *g_score.get(&neighbor).unwrap_or(&f32::MAX) {
363                        g_score.insert(neighbor, ng);
364                        came_from.insert(neighbor, (current, pidx));
365                        let h = self.heuristic(neighbor, end_poly);
366                        open.push(AStarEntry { poly: neighbor, f: ng + h });
367                    }
368                }
369            }
370        }
371        None
372    }
373
374    fn heuristic(&self, a: NavPolyId, b: NavPolyId) -> f32 {
375        let ca = self.poly_by_id(a).map(|p| p.centroid).unwrap_or(Vec2::zero());
376        let cb = self.poly_by_id(b).map(|p| p.centroid).unwrap_or(Vec2::zero());
377        ca.dist(cb)
378    }
379
380    fn reconstruct_corridor(
381        &self,
382        start: NavPolyId,
383        end: NavPolyId,
384        came_from: &HashMap<NavPolyId, (NavPolyId, usize)>,
385    ) -> (Vec<NavPolyId>, Vec<usize>) {
386        let mut polys = Vec::new();
387        let mut portal_indices = Vec::new();
388        let mut cur = end;
389        while cur != start {
390            polys.push(cur);
391            if let Some(&(prev, pidx)) = came_from.get(&cur) {
392                portal_indices.push(pidx);
393                cur = prev;
394            } else { break; }
395        }
396        polys.push(start);
397        polys.reverse();
398        portal_indices.reverse();
399        (polys, portal_indices)
400    }
401
402    // ── String-pulling (Simple Stupid Funnel Algorithm) ───────────────────────
403
404    /// Smooth the polygon corridor into a minimal waypoint path using funnel algorithm.
405    pub fn string_pull(&self, start: Vec2, end: Vec2, _polys: &[NavPolyId], portals: &[NavPortal]) -> Vec<Vec2> {
406        if portals.is_empty() { return vec![start, end]; }
407
408        // Build portal list: start point, portal edges, end point
409        let mut port_lefts: Vec<Vec2> = Vec::new();
410        let mut port_rights: Vec<Vec2> = Vec::new();
411
412        port_lefts.push(start);
413        port_rights.push(start);
414
415        for portal in portals {
416            port_lefts.push(portal.left);
417            port_rights.push(portal.right);
418        }
419        port_lefts.push(end);
420        port_rights.push(end);
421
422        // SSFA
423        let mut path = vec![start];
424        let mut apex = start;
425        let mut left = port_lefts[1];
426        let mut right = port_rights[1];
427        let mut apex_idx = 0usize;
428        let mut left_idx = 1usize;
429        let mut right_idx = 1usize;
430
431        let n = port_lefts.len();
432        for i in 2..n {
433            let new_left = port_lefts[i];
434            let new_right = port_rights[i];
435
436            // Update right leg
437            if triangle_area2(apex, right, new_right) <= 0.0 {
438                if apex == right || triangle_area2(apex, left, new_right) > 0.0 {
439                    right = new_right;
440                    right_idx = i;
441                } else {
442                    // Right crossed left — left is next waypoint
443                    path.push(left);
444                    apex = left;
445                    apex_idx = left_idx;
446                    right = apex;
447                    right_idx = apex_idx;
448                    // Restart
449                    if apex_idx + 1 < n { left = port_lefts[apex_idx + 1]; left_idx = apex_idx + 1; }
450                    if apex_idx + 1 < n { right = port_rights[apex_idx + 1]; right_idx = apex_idx + 1; }
451                    // Back up i to restart scanning from apex
452                    // (simplified: continue, the next iteration re-evaluates)
453                    continue;
454                }
455            }
456
457            // Update left leg
458            if triangle_area2(apex, left, new_left) >= 0.0 {
459                if apex == left || triangle_area2(apex, right, new_left) < 0.0 {
460                    left = new_left;
461                    left_idx = i;
462                } else {
463                    path.push(right);
464                    apex = right;
465                    apex_idx = right_idx;
466                    left = apex;
467                    left_idx = apex_idx;
468                    if apex_idx + 1 < n { right = port_rights[apex_idx + 1]; right_idx = apex_idx + 1; }
469                    if apex_idx + 1 < n { left = port_lefts[apex_idx + 1]; left_idx = apex_idx + 1; }
470                    continue;
471                }
472            }
473        }
474
475        path.push(end);
476        // Remove duplicate consecutive points
477        path.dedup_by(|a, b| a.dist_sq(*b) < 1e-8);
478        path
479    }
480}
481
482// Signed 2D triangle area × 2
483#[inline]
484fn triangle_area2(a: Vec2, b: Vec2, c: Vec2) -> f32 {
485    (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)
486}
487
488/// A point with polygon context.
489#[derive(Clone, Copy, Debug)]
490pub struct NavPoint {
491    pub pos:  Vec2,
492    pub poly: NavPolyId,
493}
494
495/// Result of navmesh pathfinding: polygon corridor + smoothed waypoints.
496#[derive(Clone, Debug)]
497pub struct NavPath {
498    pub polys:     Vec<NavPolyId>,
499    pub portals:   Vec<NavPortal>,
500    pub waypoints: Vec<Vec2>,
501}
502
503// ── A* priority queue entry ───────────────────────────────────────────────────
504
505#[derive(PartialEq)]
506struct AStarEntry {
507    poly: NavPolyId,
508    f:    f32,
509}
510
511impl Eq for AStarEntry {}
512
513impl PartialOrd for AStarEntry {
514    fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
515}
516
517impl Ord for AStarEntry {
518    fn cmp(&self, other: &Self) -> Ordering {
519        other.f.partial_cmp(&self.f).unwrap_or(Ordering::Equal)
520    }
521}
522
523// ── Dynamic obstacle cutting (Recast-style) ───────────────────────────────────
524
525/// An obstacle that can be cut into the navmesh (marks polygons as blocked).
526#[derive(Clone, Debug)]
527pub struct Obstacle {
528    pub id:     u32,
529    pub center: Vec2,
530    pub radius: f32,
531    /// Which polys were affected
532    affected:   Vec<NavPolyId>,
533}
534
535/// Manages dynamic obstacle cutting on a NavMesh.
536pub struct ObstacleCutter {
537    obstacles: HashMap<u32, Obstacle>,
538    next_id:   u32,
539}
540
541impl ObstacleCutter {
542    pub fn new() -> Self {
543        Self { obstacles: HashMap::new(), next_id: 0 }
544    }
545
546    /// Add a circular obstacle, marking overlapping polygons as blocked.
547    pub fn add_obstacle(&mut self, mesh: &mut NavMesh, center: Vec2, radius: f32) -> u32 {
548        let id = self.next_id;
549        self.next_id += 1;
550        let mut affected = Vec::new();
551
552        for poly in &mut mesh.polys {
553            if poly_overlaps_circle(&poly.verts, center, radius) {
554                poly.area = poly.area.union(AreaFlags::BLOCKED);
555                affected.push(poly.id);
556            }
557        }
558        self.obstacles.insert(id, Obstacle { id, center, radius, affected });
559        id
560    }
561
562    /// Remove an obstacle and restore polygon flags.
563    pub fn remove_obstacle(&mut self, mesh: &mut NavMesh, id: u32) {
564        if let Some(obs) = self.obstacles.remove(&id) {
565            for pid in obs.affected {
566                if let Some(idx) = mesh.poly_index.get(&pid) {
567                    let poly = &mut mesh.polys[*idx];
568                    poly.area = AreaFlags(poly.area.0 & !AreaFlags::BLOCKED.0);
569                }
570            }
571        }
572    }
573
574    /// Move an obstacle (remove old, add new).
575    pub fn move_obstacle(&mut self, mesh: &mut NavMesh, id: u32, new_center: Vec2) {
576        if let Some(obs) = self.obstacles.get(&id).cloned() {
577            self.remove_obstacle(mesh, id);
578            self.add_obstacle(mesh, new_center, obs.radius);
579        }
580    }
581}
582
583fn poly_overlaps_circle(verts: &[Vec2], center: Vec2, radius: f32) -> bool {
584    let r2 = radius * radius;
585    // Check if center inside poly
586    if verts.len() >= 3 {
587        let mut inside = true;
588        let n = verts.len();
589        for i in 0..n {
590            let a = verts[i];
591            let b = verts[(i + 1) % n];
592            if b.sub(a).cross(center.sub(a)) < 0.0 { inside = false; break; }
593        }
594        if inside { return true; }
595    }
596    // Check if any edge is close enough
597    let n = verts.len();
598    for i in 0..n {
599        let a = verts[i];
600        let b = verts[(i + 1) % n];
601        let cp = closest_point_on_segment(a, b, center);
602        if cp.dist_sq(center) <= r2 { return true; }
603    }
604    false
605}
606
607// ── NavMeshQuery facade ───────────────────────────────────────────────────────
608
609/// High-level query interface wrapping NavMesh for typical game use.
610pub struct NavMeshQuery<'a> {
611    pub mesh:   &'a NavMesh,
612    pub filter: AreaFlags,
613}
614
615impl<'a> NavMeshQuery<'a> {
616    pub fn new(mesh: &'a NavMesh) -> Self {
617        Self { mesh, filter: AreaFlags::WALKABLE }
618    }
619
620    pub fn with_filter(mut self, filter: AreaFlags) -> Self {
621        self.filter = filter;
622        self
623    }
624
625    /// Find path from `start` to `end`, returning smoothed waypoints.
626    pub fn find_path(&self, start: Vec2, end: Vec2) -> Vec<Vec2> {
627        self.mesh.find_path(start, end, self.filter)
628            .map(|p| p.waypoints)
629            .unwrap_or_default()
630    }
631
632    /// Snap point to navmesh.
633    pub fn snap(&self, p: Vec2) -> Vec2 {
634        self.mesh.closest_point_on_mesh(p).pos
635    }
636
637    /// Raycast on navmesh, returns None if unobstructed, Some(hit) if blocked.
638    pub fn raycast(&self, start: Vec2, end: Vec2) -> Option<Vec2> {
639        let start_poly = self.mesh.poly_at_point(start)?;
640        let dir = end.sub(start);
641        let len = dir.len();
642        if len < 1e-9 { return None; }
643        let step = dir.scale(1.0 / len);
644        let steps = (len / 0.5) as usize + 1;
645        for i in 1..=steps {
646            let t = (i as f32 * 0.5).min(len);
647            let p = start.add(step.scale(t));
648            if let Some(poly_id) = self.mesh.poly_at_point(p) {
649                if let Some(poly) = self.mesh.poly_by_id(poly_id) {
650                    if poly.area.contains(AreaFlags::BLOCKED) { return Some(p); }
651                }
652            } else {
653                return Some(p);
654            }
655        }
656        None
657    }
658}
659
660// ── Precomputed navmesh region graph for hierarchical planning ────────────────
661
662/// A region groups several polygons for hierarchical pathfinding.
663#[derive(Clone, Debug)]
664pub struct NavRegion {
665    pub id:    u32,
666    pub polys: HashSet<NavPolyId>,
667    pub entry_portals: Vec<usize>, // portals connecting to other regions
668}
669
670/// Graph of regions for hierarchical pathfinding pre-computation.
671#[derive(Clone, Debug, Default)]
672pub struct RegionGraph {
673    pub regions: Vec<NavRegion>,
674    pub poly_to_region: HashMap<NavPolyId, u32>,
675}
676
677impl RegionGraph {
678    /// Build by flood-filling polygons into clusters of `max_size`.
679    pub fn build(mesh: &NavMesh, max_cluster_size: usize) -> Self {
680        let mut graph = Self::default();
681        let mut visited: HashSet<NavPolyId> = HashSet::new();
682        let mut region_id = 0u32;
683
684        for poly in &mesh.polys {
685            if visited.contains(&poly.id) { continue; }
686            if poly.area.contains(AreaFlags::BLOCKED) { continue; }
687
688            // BFS flood fill
689            let mut cluster = HashSet::new();
690            let mut queue = VecDeque::new();
691            queue.push_back(poly.id);
692
693            while let Some(pid) = queue.pop_front() {
694                if visited.contains(&pid) { continue; }
695                if cluster.len() >= max_cluster_size { break; }
696                visited.insert(pid);
697                cluster.insert(pid);
698                if let Some(p) = mesh.poly_by_id(pid) {
699                    for &portal_idx in &p.portals {
700                        let portal = &mesh.portals[portal_idx];
701                        let neighbor = portal.other(pid);
702                        if !visited.contains(&neighbor) {
703                            queue.push_back(neighbor);
704                        }
705                    }
706                }
707            }
708
709            for &pid in &cluster {
710                graph.poly_to_region.insert(pid, region_id);
711            }
712            graph.regions.push(NavRegion { id: region_id, polys: cluster, entry_portals: Vec::new() });
713            region_id += 1;
714        }
715        // Mark entry portals
716        let region_count = graph.regions.len();
717        for (pidx, portal) in mesh.portals.iter().enumerate() {
718            let ra = graph.poly_to_region.get(&portal.poly_a).copied();
719            let rb = graph.poly_to_region.get(&portal.poly_b).copied();
720            if let (Some(ra), Some(rb)) = (ra, rb) {
721                if ra != rb && ra < region_count as u32 && rb < region_count as u32 {
722                    graph.regions[ra as usize].entry_portals.push(pidx);
723                    graph.regions[rb as usize].entry_portals.push(pidx);
724                }
725            }
726        }
727        graph
728    }
729}
730
731// ── Additional path utilities ─────────────────────────────────────────────────
732
733/// Compute the total length of a waypoint path.
734pub fn path_length(waypoints: &[Vec2]) -> f32 {
735    waypoints.windows(2).map(|w| w[0].dist(w[1])).sum()
736}
737
738/// Sample a position along a waypoint path at arc-length parameter `t` in \[0,1\].
739pub fn path_sample(waypoints: &[Vec2], t: f32) -> Vec2 {
740    if waypoints.is_empty() { return Vec2::zero(); }
741    if waypoints.len() == 1 { return waypoints[0]; }
742    let total = path_length(waypoints);
743    if total < 1e-9 { return waypoints[0]; }
744    let target = (t.clamp(0.0, 1.0) * total).min(total - 1e-9);
745    let mut acc = 0.0f32;
746    for i in 0..waypoints.len() - 1 {
747        let seg = waypoints[i].dist(waypoints[i + 1]);
748        if acc + seg >= target {
749            let local_t = (target - acc) / seg.max(1e-9);
750            return waypoints[i].lerp(waypoints[i + 1], local_t);
751        }
752        acc += seg;
753    }
754    *waypoints.last().unwrap()
755}
756
757/// Find the index of the nearest waypoint to position `p`.
758pub fn nearest_waypoint_index(waypoints: &[Vec2], p: Vec2) -> usize {
759    waypoints.iter().enumerate()
760        .min_by(|(_, a), (_, b)| a.dist_sq(p).partial_cmp(&b.dist_sq(p)).unwrap_or(Ordering::Equal))
761        .map(|(i, _)| i)
762        .unwrap_or(0)
763}
764
765/// Simplify a path by removing intermediate points within `tolerance` of the line.
766pub fn simplify_path(waypoints: &[Vec2], tolerance: f32) -> Vec<Vec2> {
767    if waypoints.len() <= 2 { return waypoints.to_vec(); }
768    let tol2 = tolerance * tolerance;
769    let mut result = vec![waypoints[0]];
770    let mut i = 0usize;
771    while i < waypoints.len() - 1 {
772        let mut farthest = i + 1;
773        for j in (i + 1)..waypoints.len() {
774            let cp = closest_point_on_segment(waypoints[i], waypoints[j.min(waypoints.len()-1)], waypoints[j]);
775            if cp.dist_sq(waypoints[j]) > tol2 { break; }
776            farthest = j;
777        }
778        result.push(waypoints[farthest]);
779        i = farthest;
780    }
781    result
782}
783
784// ── Tests ─────────────────────────────────────────────────────────────────────
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    fn square_poly(id: u32, ox: f32, oy: f32, sz: f32, area: AreaFlags) -> (NavPolyId, Vec<Vec2>) {
791        let verts = vec![
792            Vec2::new(ox, oy),
793            Vec2::new(ox + sz, oy),
794            Vec2::new(ox + sz, oy + sz),
795            Vec2::new(ox, oy + sz),
796        ];
797        (NavPolyId(id), verts)
798    }
799
800    #[test]
801    fn test_point_in_poly() {
802        let verts = vec![
803            Vec2::new(0.0, 0.0),
804            Vec2::new(4.0, 0.0),
805            Vec2::new(4.0, 4.0),
806            Vec2::new(0.0, 4.0),
807        ];
808        let poly = NavPoly::new(NavPolyId(0), verts, AreaFlags::WALKABLE, 1.0);
809        assert!(poly.contains_point(Vec2::new(2.0, 2.0)));
810        assert!(!poly.contains_point(Vec2::new(5.0, 5.0)));
811    }
812
813    #[test]
814    fn test_navmesh_build_and_path() {
815        let mut mesh = NavMesh::new();
816        let a = mesh.add_poly(vec![
817            Vec2::new(0.0,0.0), Vec2::new(4.0,0.0),
818            Vec2::new(4.0,4.0), Vec2::new(0.0,4.0),
819        ], AreaFlags::WALKABLE, 1.0);
820        let b = mesh.add_poly(vec![
821            Vec2::new(4.0,0.0), Vec2::new(8.0,0.0),
822            Vec2::new(8.0,4.0), Vec2::new(4.0,4.0),
823        ], AreaFlags::WALKABLE, 1.0);
824        mesh.build_portals_from_edges();
825        let path = mesh.find_path(Vec2::new(1.0, 2.0), Vec2::new(7.0, 2.0), AreaFlags::WALKABLE);
826        assert!(path.is_some());
827        let wp = path.unwrap().waypoints;
828        assert!(wp.len() >= 2);
829    }
830
831    #[test]
832    fn test_closest_point_on_segment() {
833        let a = Vec2::new(0.0, 0.0);
834        let b = Vec2::new(4.0, 0.0);
835        let p = Vec2::new(2.0, 3.0);
836        let c = closest_point_on_segment(a, b, p);
837        assert!((c.x - 2.0).abs() < 1e-5);
838        assert!((c.y - 0.0).abs() < 1e-5);
839    }
840
841    #[test]
842    fn test_obstacle_cutter() {
843        let mut mesh = NavMesh::new();
844        let id = mesh.add_poly(vec![
845            Vec2::new(0.0,0.0), Vec2::new(4.0,0.0),
846            Vec2::new(4.0,4.0), Vec2::new(0.0,4.0),
847        ], AreaFlags::WALKABLE, 1.0);
848        let mut cutter = ObstacleCutter::new();
849        let oid = cutter.add_obstacle(&mut mesh, Vec2::new(2.0, 2.0), 1.0);
850        assert!(mesh.poly_by_id(id).unwrap().area.contains(AreaFlags::BLOCKED));
851        cutter.remove_obstacle(&mut mesh, oid);
852        assert!(!mesh.poly_by_id(id).unwrap().area.contains(AreaFlags::BLOCKED));
853    }
854
855    #[test]
856    fn test_path_sample() {
857        let pts = vec![Vec2::new(0.0,0.0), Vec2::new(10.0,0.0)];
858        let mid = path_sample(&pts, 0.5);
859        assert!((mid.x - 5.0).abs() < 1e-4);
860    }
861}