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