Skip to main content

condor_navmesh/navmesh/
mod.rs

1//! Deterministic convex-cell navmesh substrate: geometry, walkability, and availability.
2//!
3//! This module owns the **mesh contract** that solvers and adapters consume. It
4//! does **not** implement online routing algorithms—those live under
5//! [`crate::algorithms`] (and optional [`crate::polyanya`]) and call into a
6//! validated [`Navmesh`] (or a prepared/dynamic snapshot derived from it).
7//! Continuous polygonal free-space types remain in the geometry crate; this
8//! module is the navmesh-specific cell/portal graph and walkability layer on top.
9//!
10//! # Lifecycle
11//!
12//! 1. **Build / validate** — assemble [`NavmeshCell`]s and [`NavmeshPortal`]s, then
13//!    [`Navmesh::validate`]. Invalid geometry fails closed before any query.
14//! 2. **Locate** — map continuous points into cell indices with [`Navmesh::locate_point`] /
15//!    [`Navmesh::locate_cells`] (shared by connectivity checks and pathfinders).
16//! 3. **Query connectivity or route** — [`Navmesh::query`] answers cell-graph reachability
17//!    without a geometric path; [`NavmeshPathfinder`]s produce polylines via corridor + funnel.
18//! 4. **Prepared snapshots** — [`PreparedNavmeshBuilder`] clones a static mesh and indexes
19//!    adjacency for repeated neighbor/portal lookup ([`prepared`]); TRA* builders layer on this.
20//! 5. **Dynamic availability** — [`DynamicNavmeshState`] overlays cell/portal enable flags.
21//!    Each update sets [`DynamicNavmeshState::prepared_stale`]; consumers
22//!    [`DynamicNavmeshState::materialize`] a static snapshot and rebuild prepared data
23//!    (for example via [`DynamicPreparedNavmeshQuery::run`]). No in-place prepared repair.
24//!
25//! # Submodules
26//!
27//! - [`adapter`] — fan-triangulate cells for the external Polyanya mesh API (`polyanya` feature)
28//! - [`corridor`] — ordered portal chain along a known cell sequence
29//! - [`funnel`] — string-pull a seed polyline inside a corridor
30//! - [`prepared`] — build-once / query-many adjacency contracts
31
32use std::{
33    borrow::Borrow,
34    collections::{BTreeMap, BTreeSet, VecDeque},
35};
36
37/// Fan-triangulate cells for the external Polyanya mesh API (`polyanya` feature).
38#[cfg(feature = "polyanya")]
39pub mod adapter;
40/// Ordered portal chain construction along a known cell sequence.
41pub mod corridor;
42/// String-pull a seed polyline inside a corridor.
43pub mod funnel;
44/// Build-once / query-many adjacency contracts for static navmesh snapshots.
45pub mod prepared;
46
47use condor_core::{Point2, SearchOutcome, SearchVisitStats};
48use condor_geometry::continuous::PolygonPath;
49pub use prepared::{
50    PreparedNavmesh, PreparedNavmeshBuildError, PreparedNavmeshBuilder, StaticPreparedNavmesh,
51    StaticPreparedNavmeshBuilder,
52};
53
54/// Static navmesh cell or portal validation failure.
55///
56/// Raised by [`NavmeshCell::validate`] and [`Navmesh::validate`] when geometry or
57/// indexing violates the convex-cell / portal-boundary contract. Build and prepare
58/// paths fail closed on these errors rather than searching an inconsistent mesh.
59#[derive(Debug, Clone, PartialEq, thiserror::Error)]
60#[non_exhaustive]
61pub enum NavmeshValidationError {
62    /// Cell id is empty or whitespace-only.
63    #[error("navmesh cell id must not be empty")]
64    EmptyCellId,
65    /// Cell polygon has fewer than three vertices.
66    #[error("navmesh cell '{cell_id}' needs at least three vertices (found {actual})")]
67    TooFewCellVertices {
68        /// Offending cell id.
69        cell_id: String,
70        /// Observed vertex count.
71        actual: usize,
72    },
73    /// Cell polygon has near-zero signed area.
74    #[error("navmesh cell '{cell_id}' must have non-zero area")]
75    DegenerateCell {
76        /// Offending cell id.
77        cell_id: String,
78    },
79    /// Cell polygon fails the convexity check.
80    #[error("navmesh cell '{cell_id}' must be convex")]
81    NonConvexCell {
82        /// Offending cell id.
83        cell_id: String,
84    },
85    /// Two cells share the same non-empty `cell_id`.
86    #[error("duplicate navmesh cell id '{cell_id}'")]
87    DuplicateCellId {
88        /// Duplicated cell id.
89        cell_id: String,
90    },
91    /// Portal indexes a cell outside the mesh cell list.
92    #[error("navmesh portal {portal_index} references missing cell {cell_index}")]
93    MissingPortalCell {
94        /// Portal index in the mesh portal list.
95        portal_index: usize,
96        /// Out-of-range cell index referenced by the portal.
97        cell_index: usize,
98    },
99    /// Portal's left and right cell indices are equal.
100    #[error("navmesh portal {portal_index} must connect two different cells")]
101    SelfPortal {
102        /// Portal index in the mesh portal list.
103        portal_index: usize,
104    },
105    /// Portal start and end coincide (zero-length segment).
106    #[error("navmesh portal {portal_index} endpoints must span a non-zero segment")]
107    DegeneratePortal {
108        /// Portal index in the mesh portal list.
109        portal_index: usize,
110    },
111    /// Portal segment is not on the named cell's boundary.
112    #[error("navmesh portal {portal_index} is not on the boundary of cell '{cell_id}'")]
113    PortalOutsideCellBoundary {
114        /// Portal index in the mesh portal list.
115        portal_index: usize,
116        /// Cell id whose boundary does not contain the portal segment.
117        cell_id: String,
118    },
119}
120
121/// Failure while applying or materializing dynamic navmesh availability.
122///
123/// Covers empty bases, invalid base meshes, updates that reference unknown cell or
124/// portal ids, and prepared rebuild failures after materialization.
125#[derive(Debug, thiserror::Error)]
126#[non_exhaustive]
127pub enum DynamicNavmeshError {
128    /// Base mesh had no cells when constructing dynamic state.
129    #[error("dynamic navmesh base must contain at least one cell")]
130    EmptyBase,
131    /// Base mesh failed [`Navmesh::validate`] during construction or materialize.
132    #[error(transparent)]
133    InvalidNavmesh(#[from] NavmeshValidationError),
134    /// An update referenced a `cell_id` absent from the base mesh.
135    #[error("dynamic navmesh update references missing cell '{cell_id}'")]
136    MissingCell {
137        /// Unknown cell id from the update.
138        cell_id: String,
139    },
140    /// An update referenced a portal whose cell-id pair is not on the base mesh.
141    #[error(
142        "dynamic navmesh update references missing portal '{left_cell_id}'<->'{right_cell_id}'"
143    )]
144    MissingPortal {
145        /// One cell id of the missing portal pair.
146        left_cell_id: String,
147        /// Other cell id of the missing portal pair.
148        right_cell_id: String,
149    },
150    /// Prepared rebuild after materialization failed validation or preprocess.
151    #[error("failed to rebuild prepared navmesh: {source}")]
152    PreparedRebuild {
153        /// Underlying prepared-build failure.
154        #[source]
155        source: PreparedNavmeshBuildError,
156    },
157}
158const EPSILON: f64 = 1e-9;
159const ENDPOINT_PROBE_PARAMETER: f64 = 1e-6;
160
161/// One convex walkable polygon in a navmesh, keyed by a stable `cell_id`.
162///
163/// Cell ids are the durable identity used by dynamic availability updates; portal
164/// endpoints must lie on the cell boundary. Construction does not validate—call
165/// [`Self::validate`] (or [`Navmesh::validate`]) before search.
166#[derive(Debug, Clone, PartialEq)]
167pub struct NavmeshCell {
168    cell_id: String,
169    vertices: Vec<Point2>,
170}
171
172impl NavmeshCell {
173    /// Creates an unvalidated cell; geometry and id contracts are checked by [`Self::validate`].
174    #[must_use]
175    pub fn new(cell_id: impl Into<String>, vertices: Vec<Point2>) -> Self {
176        Self {
177            cell_id: cell_id.into(),
178            vertices,
179        }
180    }
181
182    /// Stable string identity for this cell within a mesh.
183    #[must_use]
184    pub fn cell_id(&self) -> &str {
185        &self.cell_id
186    }
187
188    /// Convex polygon ring in world coordinates (CCW or CW; validation checks area).
189    #[must_use]
190    pub fn vertices(&self) -> &[Point2] {
191        &self.vertices
192    }
193
194    /// Checks non-empty id, at least three vertices, non-zero area, and convexity.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`NavmeshValidationError`] when the cell id or polygon geometry
199    /// violates the navmesh contract.
200    pub fn validate(&self) -> Result<(), NavmeshValidationError> {
201        if self.cell_id.trim().is_empty() {
202            return Err(NavmeshValidationError::EmptyCellId);
203        }
204
205        if self.vertices.len() < 3 {
206            return Err(NavmeshValidationError::TooFewCellVertices {
207                cell_id: self.cell_id.clone(),
208                actual: self.vertices.len(),
209            });
210        }
211
212        if is_degenerate_polygon(&self.vertices) {
213            return Err(NavmeshValidationError::DegenerateCell {
214                cell_id: self.cell_id.clone(),
215            });
216        }
217
218        if !is_convex_polygon(&self.vertices) {
219            return Err(NavmeshValidationError::NonConvexCell {
220                cell_id: self.cell_id.clone(),
221            });
222        }
223
224        Ok(())
225    }
226
227    /// Inclusive membership: interior points and boundary edges (including vertices) count.
228    #[must_use]
229    pub fn contains_point_inclusive(&self, point: Point2) -> bool {
230        if polygon_edges(&self.vertices).any(|(start, end)| point_on_segment(point, start, end)) {
231            return true;
232        }
233
234        let mut reference_sign = 0.0_f64;
235        for (start, end) in polygon_edges(&self.vertices) {
236            let sign = orientation(start, end, point);
237            if sign.abs() <= EPSILON {
238                continue;
239            }
240
241            if reference_sign.abs() <= EPSILON {
242                reference_sign = sign;
243                continue;
244            }
245
246            if sign.signum() != reference_sign.signum() {
247                return false;
248            }
249        }
250
251        true
252    }
253
254    /// Whether both endpoints of a portal segment lie on a single cell boundary edge.
255    #[must_use]
256    pub fn has_boundary_segment(&self, start: Point2, end: Point2) -> bool {
257        polygon_edges(&self.vertices).any(|(edge_start, edge_end)| {
258            point_on_segment(start, edge_start, edge_end)
259                && point_on_segment(end, edge_start, edge_end)
260        })
261    }
262}
263
264/// Adjacency segment between two cells; endpoints must lie on both cell boundaries.
265///
266/// Cell indices refer into the owning [`Navmesh`]'s cell list. Direction is not
267/// semantic—either endpoint order may appear—but both sides must share the segment.
268#[derive(Debug, Clone, Copy, PartialEq)]
269pub struct NavmeshPortal {
270    /// Index of one adjacent cell in the owning [`Navmesh`] cell list.
271    pub left_cell: usize,
272    /// Index of the other adjacent cell (must differ from [`Self::left_cell`]).
273    pub right_cell: usize,
274    /// First endpoint of the shared boundary segment (world coordinates).
275    pub start: Point2,
276    /// Second endpoint of the shared boundary segment (world coordinates).
277    pub end: Point2,
278}
279
280/// Graph of convex cells and portal segments for connectivity and walkability.
281///
282/// Construction does not validate. Treat a mesh as authoritative only after
283/// [`Self::validate`] (or after materialization from a valid [`DynamicNavmeshState`]).
284/// Solvers and prepared builders clone this snapshot; they do not mutate it in place.
285#[derive(Debug, Clone, PartialEq)]
286pub struct Navmesh {
287    cells: Vec<NavmeshCell>,
288    portals: Vec<NavmeshPortal>,
289}
290
291impl Navmesh {
292    /// Creates an unvalidated mesh; call [`Self::validate`] before search or preprocess.
293    #[must_use]
294    pub fn new(cells: Vec<NavmeshCell>, portals: Vec<NavmeshPortal>) -> Self {
295        Self { cells, portals }
296    }
297
298    /// Convex cells that define walkable free space.
299    #[must_use]
300    pub fn cells(&self) -> &[NavmeshCell] {
301        &self.cells
302    }
303
304    /// Shared boundary portals linking adjacent cells.
305    #[must_use]
306    pub fn portals(&self) -> &[NavmeshPortal] {
307        &self.portals
308    }
309
310    /// Validates every cell and every portal's indices, non-degeneracy, and boundary endpoints.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`NavmeshValidationError`] when a cell is invalid, cell ids are
315    /// duplicated, or a portal violates its endpoint contract.
316    pub fn validate(&self) -> Result<(), NavmeshValidationError> {
317        let mut cell_ids = BTreeMap::new();
318        for (index, cell) in self.cells.iter().enumerate() {
319            cell.validate()?;
320            if cell_ids.insert(cell.cell_id(), index).is_some() {
321                return Err(NavmeshValidationError::DuplicateCellId {
322                    cell_id: cell.cell_id().to_owned(),
323                });
324            }
325        }
326
327        for (portal_index, portal) in self.portals.iter().enumerate() {
328            if portal.left_cell >= self.cells.len() || portal.right_cell >= self.cells.len() {
329                let cell_index = if portal.left_cell >= self.cells.len() {
330                    portal.left_cell
331                } else {
332                    portal.right_cell
333                };
334                return Err(NavmeshValidationError::MissingPortalCell {
335                    portal_index,
336                    cell_index,
337                });
338            }
339            if portal.left_cell == portal.right_cell {
340                return Err(NavmeshValidationError::SelfPortal { portal_index });
341            }
342            if points_equal(portal.start, portal.end) {
343                return Err(NavmeshValidationError::DegeneratePortal { portal_index });
344            }
345
346            let left = &self.cells[portal.left_cell];
347            let right = &self.cells[portal.right_cell];
348            if !left.has_boundary_segment(portal.start, portal.end) {
349                return Err(NavmeshValidationError::PortalOutsideCellBoundary {
350                    portal_index,
351                    cell_id: left.cell_id().to_owned(),
352                });
353            }
354            if !right.has_boundary_segment(portal.start, portal.end) {
355                return Err(NavmeshValidationError::PortalOutsideCellBoundary {
356                    portal_index,
357                    cell_id: right.cell_id().to_owned(),
358                });
359            }
360        }
361
362        Ok(())
363    }
364
365    /// Index of the first cell that contains `point` under inclusive membership, if any.
366    ///
367    /// When a point lies on a shared boundary, the lowest-index containing cell wins.
368    /// Prefer [`Self::locate_cells`] when multi-membership matters (connectivity BFS).
369    #[must_use]
370    pub fn locate_point(&self, point: Point2) -> Option<usize> {
371        self.locate_cells(point).into_iter().next()
372    }
373
374    /// All cell indices whose polygons contain `point` (inclusive boundary).
375    ///
376    /// Order follows cell list order. Overlapping or boundary-sharing cells all appear.
377    #[must_use]
378    pub fn locate_cells(&self, point: Point2) -> Vec<usize> {
379        self.cells
380            .iter()
381            .enumerate()
382            .filter_map(|(index, cell)| cell.contains_point_inclusive(point).then_some(index))
383            .collect()
384    }
385
386    /// Cell indices reachable in one portal hop from `cell_index` (unsorted, portal order).
387    ///
388    /// Out-of-range indices yield an empty list. Duplicate portals to the same neighbor
389    /// produce duplicate entries.
390    #[must_use]
391    pub fn neighbors(&self, cell_index: usize) -> Vec<usize> {
392        self.portals
393            .iter()
394            .filter_map(|portal| {
395                if portal.left_cell == cell_index {
396                    Some(portal.right_cell)
397                } else if portal.right_cell == cell_index {
398                    Some(portal.left_cell)
399                } else {
400                    None
401                }
402            })
403            .collect()
404    }
405
406    /// Portals incident to `cell_index`, in mesh portal order.
407    #[must_use]
408    pub fn portals_from(&self, cell_index: usize) -> Vec<&NavmeshPortal> {
409        self.portals
410            .iter()
411            .filter(|portal| portal.left_cell == cell_index || portal.right_cell == cell_index)
412            .collect()
413    }
414
415    /// Cell-graph connectivity between query endpoints (no geometric path).
416    ///
417    /// Locates every cell covering start and goal. If either endpoint lies outside all
418    /// cells, returns [`NavmeshQueryResult::InvalidStart`] or
419    /// [`NavmeshQueryResult::InvalidGoal`]. Otherwise BFS over portals from the full start
420    /// cell set; when any goal cell is reachable, returns
421    /// [`NavmeshQueryResult::Connected`] with the source start cell and hit goal cell.
422    /// Multi-cell endpoints use the first located cell only for the reported
423    /// `NoPath` indices when disconnected.
424    #[must_use]
425    pub fn query(&self, query: NavmeshQuery) -> NavmeshQueryResult {
426        // First located cell wins when start/goal overlap multiple polygons.
427        let start_cells = self.locate_cells(query.start);
428        let Some(&start_cell) = start_cells.first() else {
429            return NavmeshQueryResult::InvalidStart;
430        };
431        let goal_cells = self.locate_cells(query.goal);
432        let Some(&goal_cell) = goal_cells.first() else {
433            return NavmeshQueryResult::InvalidGoal;
434        };
435
436        if let Some((start_cell, goal_cell)) = self.connected_cell_pair(&start_cells, &goal_cells) {
437            NavmeshQueryResult::Connected {
438                start_cell,
439                goal_cell,
440            }
441        } else {
442            NavmeshQueryResult::NoPath {
443                start_cell,
444                goal_cell,
445            }
446        }
447    }
448
449    fn connected_cell_pair(
450        &self,
451        start_cells: &[usize],
452        goal_cells: &[usize],
453    ) -> Option<(usize, usize)> {
454        let goal_set: BTreeSet<usize> = goal_cells.iter().copied().collect();
455        let mut seen = vec![false; self.cells.len()];
456        let mut frontier = VecDeque::new();
457
458        for &start_cell in start_cells {
459            if start_cell >= seen.len() || seen[start_cell] {
460                continue;
461            }
462            if goal_set.contains(&start_cell) {
463                return Some((start_cell, start_cell));
464            }
465            seen[start_cell] = true;
466            frontier.push_back((start_cell, start_cell));
467        }
468
469        while let Some((cell_index, source_start_cell)) = frontier.pop_front() {
470            for neighbor in self.neighbors(cell_index) {
471                if neighbor >= seen.len() || seen[neighbor] {
472                    continue;
473                }
474                if goal_set.contains(&neighbor) {
475                    return Some((source_start_cell, neighbor));
476                }
477                seen[neighbor] = true;
478                frontier.push_back((neighbor, source_start_cell));
479            }
480        }
481
482        None
483    }
484
485    /// Whether `point` lies in at least one cell (inclusive boundary).
486    #[must_use]
487    pub fn is_walkable(&self, point: Point2) -> bool {
488        !self.locate_cells(point).is_empty()
489    }
490
491    /// Whether the open segment from `start` to `end` stays on walkable cells.
492    ///
493    /// Endpoints must be walkable. The check samples cell-edge intersections along the
494    /// segment and requires each open sub-interval to lie inside some cell; when adjacent
495    /// intervals occupy disjoint cell sets, a portal must cover the transition point.
496    /// Used by funnel string-pull and path validation—not a free-space raycast.
497    #[must_use]
498    pub fn segment_is_walkable(&self, start: Point2, end: Point2) -> bool {
499        if !self.is_walkable(start) || !self.is_walkable(end) {
500            return false;
501        }
502
503        let mut parameters = vec![0.0, 1.0];
504        for cell in &self.cells {
505            for (edge_start, edge_end) in polygon_edges(cell.vertices()) {
506                parameters.extend(segment_intersection_parameters(
507                    start, end, edge_start, edge_end,
508                ));
509            }
510        }
511
512        sort_and_dedup_parameters(&mut parameters);
513
514        for parameter in &parameters {
515            let point = interpolate_segment(start, end, *parameter);
516            if !self.is_walkable(point) {
517                return false;
518            }
519        }
520
521        let mut interval_cells = Vec::new();
522        for interval in parameters.windows(2) {
523            let start_parameter = interval[0];
524            let end_parameter = interval[1];
525            if end_parameter - start_parameter <= EPSILON {
526                continue;
527            }
528
529            let midpoint = interpolate_segment(start, end, (start_parameter + end_parameter) / 2.0);
530            let cells = self.locate_cells(midpoint);
531            if cells.is_empty() {
532                return false;
533            }
534            interval_cells.push(cells);
535        }
536
537        for (index, boundary_parameter) in parameters
538            .iter()
539            .copied()
540            .enumerate()
541            .skip(1)
542            .take(interval_cells.len().saturating_sub(1))
543        {
544            let left_cells = &interval_cells[index - 1];
545            let right_cells = &interval_cells[index];
546            if shares_any_cell(left_cells, right_cells) {
547                continue;
548            }
549
550            let boundary_point = interpolate_segment(start, end, boundary_parameter);
551            if !self.portal_transition_allowed(left_cells, right_cells, boundary_point) {
552                return false;
553            }
554        }
555
556        true
557    }
558
559    /// Whether every consecutive segment of `path` is walkable and vertices admit portal transitions.
560    ///
561    /// Empty paths are not walkable. A single point is walkable iff it lies in some cell.
562    /// At interior vertices, incoming and outgoing probes must share a cell or cross a portal.
563    #[must_use]
564    pub fn path_is_walkable(&self, path: &[Point2]) -> bool {
565        match path {
566            [] => return false,
567            [point] => return self.locate_point(*point).is_some(),
568            _ => {}
569        }
570
571        for pair in path.windows(2) {
572            if !self.segment_is_walkable(pair[0], pair[1]) {
573                return false;
574            }
575        }
576
577        for index in 1..(path.len() - 1) {
578            let incoming_cells = self.endpoint_probe_cells(path[index - 1], path[index], true);
579            let outgoing_cells = self.endpoint_probe_cells(path[index], path[index + 1], false);
580            if shares_any_cell(&incoming_cells, &outgoing_cells) {
581                continue;
582            }
583
584            if !self.portal_transition_allowed(&incoming_cells, &outgoing_cells, path[index]) {
585                return false;
586            }
587        }
588
589        true
590    }
591
592    fn endpoint_probe_cells(&self, start: Point2, end: Point2, near_end: bool) -> Vec<usize> {
593        let parameter = if near_end {
594            1.0 - ENDPOINT_PROBE_PARAMETER
595        } else {
596            ENDPOINT_PROBE_PARAMETER
597        };
598        self.locate_cells(interpolate_segment(start, end, parameter))
599    }
600
601    fn portal_transition_allowed(
602        &self,
603        left_cells: &[usize],
604        right_cells: &[usize],
605        boundary_point: Point2,
606    ) -> bool {
607        self.portals.iter().any(|portal| {
608            let connects_left_to_right =
609                left_cells.contains(&portal.left_cell) && right_cells.contains(&portal.right_cell);
610            let connects_right_to_left =
611                left_cells.contains(&portal.right_cell) && right_cells.contains(&portal.left_cell);
612
613            (connects_left_to_right || connects_right_to_left)
614                && point_on_segment(boundary_point, portal.start, portal.end)
615        })
616    }
617}
618
619/// Start/goal endpoints for connectivity checks and route search.
620///
621/// Points are continuous world coordinates; cell membership is resolved by the mesh.
622/// Optional [`condor_core::SearchBudget`] caps expansions and/or wall-clock time for
623/// pathfinders; connectivity-only [`Navmesh::query`] ignores the budget.
624#[derive(Debug, Clone, Copy, PartialEq)]
625pub struct NavmeshQuery {
626    /// Continuous start endpoint; must locate in at least one walkable cell.
627    pub start: Point2,
628    /// Continuous goal endpoint; must locate in at least one walkable cell.
629    pub goal: Point2,
630    /// Optional expansion / wall-clock caps for route search (default unlimited).
631    pub budget: condor_core::SearchBudget,
632}
633
634impl NavmeshQuery {
635    /// Pairs start and goal world points with an unlimited budget; cell membership is resolved at query time.
636    #[must_use]
637    pub const fn new(start: Point2, goal: Point2) -> Self {
638        Self {
639            start,
640            goal,
641            budget: condor_core::SearchBudget::UNLIMITED,
642        }
643    }
644
645    /// Returns a copy of this query with the given budget.
646    #[must_use]
647    pub const fn with_budget(mut self, budget: condor_core::SearchBudget) -> Self {
648        self.budget = budget;
649        self
650    }
651}
652
653/// Connectivity outcome for a start/goal pair (no geometric path polyline).
654///
655/// Distinct from [`NavmeshSearchResult`]: this answers only whether the cell graph
656/// links the endpoints. Pathfinders use it as a precheck before corridor search.
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658pub enum NavmeshQueryResult {
659    /// Portal-connected cells cover start and goal; indices are the BFS-chosen pair.
660    Connected {
661        /// Source start cell chosen by multi-source connectivity BFS.
662        start_cell: usize,
663        /// Goal cell reached first by the connectivity BFS.
664        goal_cell: usize,
665    },
666    /// Both endpoints locate, but no portal path joins their cell sets.
667    NoPath {
668        /// First located start cell (report index when disconnected).
669        start_cell: usize,
670        /// First located goal cell (report index when disconnected).
671        goal_cell: usize,
672    },
673    /// Start lies outside every cell.
674    InvalidStart,
675    /// Goal lies outside every cell (start was valid).
676    InvalidGoal,
677}
678
679impl NavmeshQueryResult {
680    /// Whether the outcome is [`Self::Connected`].
681    #[must_use]
682    pub fn is_connected(self) -> bool {
683        matches!(self, Self::Connected { .. })
684    }
685}
686
687/// Geometric navmesh route; same continuous polyline type as polygonal free-space paths.
688///
689/// Euclidean cost and walkable-witness vertices live on [`PolygonPath`]; navmesh
690/// solvers produce this after corridor + funnel (or external adapt + funnel).
691pub type NavmeshPath = PolygonPath;
692
693/// Work counters for a navmesh route search.
694///
695/// `visited_nodes` is algorithm-defined (typically expanded cells in corridor BFS;
696/// Polyanya reports triangle/polygon expansions from the external path).
697#[derive(Debug, Clone, Copy, Default, PartialEq)]
698pub struct NavmeshSearchStats {
699    /// Algorithm-defined expansion / visit count for this route search.
700    pub visited_nodes: usize,
701}
702
703/// Failure to validate or prepare a navmesh route search request.
704///
705/// Endpoint errors mean the point is off-mesh. Adapter errors mean a backend could
706/// not build its intermediate representation (for example Polyanya triangulation).
707/// [`Self::BudgetExhausted`] means a caller budget hard-stopped the search without
708/// proving unreachability—distinct from [`SearchOutcome::NoPath`].
709#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
710#[non_exhaustive]
711pub enum NavmeshSearchError {
712    /// Start does not locate in any walkable cell.
713    #[error("invalid navmesh start: {point:?}")]
714    InvalidStart {
715        /// Off-mesh start that was rejected.
716        point: Point2,
717    },
718    /// Goal does not locate in any walkable cell (start was valid).
719    #[error("invalid navmesh goal: {point:?}")]
720    InvalidGoal {
721        /// Off-mesh goal that was rejected.
722        point: Point2,
723    },
724    /// Caller [`condor_core::SearchBudget`] exhausted before found/no-path completed.
725    #[error(transparent)]
726    BudgetExhausted(#[from] condor_core::BudgetExhausted),
727    /// Feature-gated Polyanya mesh adaptation or bake failed.
728    #[cfg(feature = "polyanya")]
729    #[error("failed to adapt navmesh for Polyanya: {source}")]
730    PolyanyaMeshAdapter {
731        /// Underlying adapter / triangulation failure.
732        #[from]
733        #[source]
734        source: adapter::PolyanyaMeshAdapterError,
735    },
736}
737
738/// Navmesh route return type: validation / budget `Err`, or found/no-path with stats.
739///
740/// `Ok(Found)` carries a walkable polyline witness; `Ok(NoPath)` is exhaustive
741/// unreachability (or a solver-local dead end after precheck). `Err` is not a
742/// no-path proof.
743pub type NavmeshSearchResult =
744    Result<SearchOutcome<NavmeshPath, NavmeshSearchStats>, NavmeshSearchError>;
745
746/// Builds `Ok(Found)` with a walkable polyline witness and expansion stats.
747pub(crate) const fn search_found(path: NavmeshPath, visited_nodes: usize) -> NavmeshSearchResult {
748    Ok(SearchOutcome::found(
749        path,
750        NavmeshSearchStats { visited_nodes },
751    ))
752}
753
754/// Builds `Ok(NoPath)` after connectivity precheck or exhaustive local search.
755pub(crate) const fn search_not_found(visited_nodes: usize) -> NavmeshSearchResult {
756    Ok(SearchOutcome::no_path(NavmeshSearchStats { visited_nodes }))
757}
758
759impl SearchVisitStats for NavmeshSearchStats {
760    fn visited_nodes(&self) -> usize {
761        self.visited_nodes
762    }
763}
764
765/// Canonical undirected portal identity for dynamic enable/disable updates.
766///
767/// Cell ids are sorted so `(A,B)` and `(B,A)` map to the same key regardless of
768/// the order written in an update or fixture.
769#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
770pub struct DynamicNavmeshPortalKey {
771    left_cell_id: String,
772    right_cell_id: String,
773}
774
775impl DynamicNavmeshPortalKey {
776    /// Builds a order-normalized key from the two cell ids a portal connects.
777    #[must_use]
778    pub fn new(left_cell_id: impl Into<String>, right_cell_id: impl Into<String>) -> Self {
779        let left_cell_id = left_cell_id.into();
780        let right_cell_id = right_cell_id.into();
781        if left_cell_id <= right_cell_id {
782            Self {
783                left_cell_id,
784                right_cell_id,
785            }
786        } else {
787            Self {
788                left_cell_id: right_cell_id,
789                right_cell_id: left_cell_id,
790            }
791        }
792    }
793
794    /// Canonical left cell id for this undirected portal key.
795    #[must_use]
796    pub fn left_cell_id(&self) -> &str {
797        &self.left_cell_id
798    }
799
800    /// Canonical right cell id for this undirected portal key.
801    #[must_use]
802    pub fn right_cell_id(&self) -> &str {
803        &self.right_cell_id
804    }
805}
806
807/// Single availability mutation applied to a [`DynamicNavmeshState`].
808///
809/// Cell and portal identities use stable string ids from the base mesh—not cell
810/// indices—so updates remain valid across materializations that reindex survivors.
811#[derive(Debug, Clone, PartialEq, Eq)]
812pub enum DynamicNavmeshUpdate {
813    /// Enable or disable a whole cell by `cell_id`.
814    SetCellEnabled {
815        /// Stable base-mesh cell id (not a materialization index).
816        cell_id: String,
817        /// `true` enables the cell; `false` subtracts it from materializations.
818        enabled: bool,
819    },
820    /// Enable or disable the portal between two cell ids (order-insensitive).
821    SetPortalEnabled {
822        /// One cell id of the undirected portal.
823        left_cell_id: String,
824        /// Other cell id of the undirected portal.
825        right_cell_id: String,
826        /// `true` enables the portal; `false` removes it from materializations.
827        enabled: bool,
828    },
829}
830
831impl DynamicNavmeshUpdate {
832    /// Disables (`enabled = false`) or re-enables a cell in the availability overlay.
833    #[must_use]
834    pub fn set_cell_enabled(cell_id: impl Into<String>, enabled: bool) -> Self {
835        Self::SetCellEnabled {
836            cell_id: cell_id.into(),
837            enabled,
838        }
839    }
840
841    /// Disables or re-enables the undirected portal between two cell ids.
842    #[must_use]
843    pub fn set_portal_enabled(
844        left_cell_id: impl Into<String>,
845        right_cell_id: impl Into<String>,
846        enabled: bool,
847    ) -> Self {
848        Self::SetPortalEnabled {
849            left_cell_id: left_cell_id.into(),
850            right_cell_id: right_cell_id.into(),
851            enabled,
852        }
853    }
854}
855
856/// Mutable availability overlay on a validated base [`Navmesh`].
857///
858/// The base geometry is immutable. Disabled cells and portals form a subtractive
859/// overlay: [`Self::materialize`] emits a static [`Navmesh`] containing only enabled
860/// cells and portals (with remapped indices). This is **not** mesh carving or
861/// in-place prepared repair.
862///
863/// # Prepared staleness
864///
865/// Every successful [`Self::apply_update`] sets [`Self::prepared_stale`]. Callers that
866/// hold a [`PreparedNavmesh`] built from a prior snapshot must discard it and rebuild
867/// from a fresh materialization. Staleness clears only when a rebuild-backed helper
868/// (for example [`DynamicPreparedNavmeshQuery::run`]) marks the state rebuilt—not
869/// merely from calling [`Self::materialize`].
870#[derive(Debug, Clone, PartialEq)]
871pub struct DynamicNavmeshState {
872    base: Navmesh,
873    disabled_cells: BTreeSet<String>,
874    disabled_portals: BTreeSet<DynamicNavmeshPortalKey>,
875    prepared_stale: bool,
876}
877
878impl DynamicNavmeshState {
879    /// Validates `base` (non-empty, passes [`Navmesh::validate`]) with all cells/portals enabled.
880    ///
881    /// # Errors
882    ///
883    /// Returns [`DynamicNavmeshError`] when the base is empty or invalid.
884    pub fn new(base: Navmesh) -> Result<Self, DynamicNavmeshError> {
885        if base.cells().is_empty() {
886            return Err(DynamicNavmeshError::EmptyBase);
887        }
888        base.validate()?;
889        Ok(Self {
890            base,
891            disabled_cells: BTreeSet::new(),
892            disabled_portals: BTreeSet::new(),
893            prepared_stale: false,
894        })
895    }
896
897    /// Like [`Self::new`], then seeds the disabled cell and portal sets without marking stale.
898    ///
899    /// Use for fixture restore and cold start. Subsequent [`Self::apply_update`] calls still
900    /// mark prepared data stale as usual.
901    ///
902    /// # Errors
903    ///
904    /// Returns [`DynamicNavmeshError`] when the base is invalid or a disabled id is unknown.
905    pub fn with_disabled_availability(
906        base: Navmesh,
907        disabled_cells: impl IntoIterator<Item = String>,
908        disabled_portals: impl IntoIterator<Item = DynamicNavmeshPortalKey>,
909    ) -> Result<Self, DynamicNavmeshError> {
910        let mut state = Self::new(base)?;
911        for cell_id in disabled_cells {
912            state.ensure_cell_exists(&cell_id)?;
913            state.disabled_cells.insert(cell_id);
914        }
915        for portal in disabled_portals {
916            state.ensure_portal_exists(&portal)?;
917            state.disabled_portals.insert(portal);
918        }
919        Ok(state)
920    }
921
922    /// Immutable base geometry (full cell/portal set, ignoring availability).
923    #[must_use]
924    pub fn base(&self) -> &Navmesh {
925        &self.base
926    }
927
928    /// Stable cell ids currently excluded from materialization.
929    #[must_use]
930    pub fn disabled_cells(&self) -> &BTreeSet<String> {
931        &self.disabled_cells
932    }
933
934    /// Portal keys currently excluded from materialization.
935    #[must_use]
936    pub fn disabled_portals(&self) -> &BTreeSet<DynamicNavmeshPortalKey> {
937        &self.disabled_portals
938    }
939
940    /// Whether any availability change has not yet been followed by a prepared rebuild.
941    ///
942    /// When `true`, any prepared snapshot derived before the last update must not be trusted.
943    #[must_use]
944    pub fn prepared_stale(&self) -> bool {
945        self.prepared_stale
946    }
947
948    fn mark_prepared_rebuilt(&mut self) {
949        self.prepared_stale = false;
950    }
951
952    /// Applies one cell or portal availability change and marks prepared data stale.
953    ///
954    /// Disabling a cell drops it from the next materialization. Disabling a portal drops
955    /// connectivity between the two cell ids while leaving both cells present when enabled.
956    ///
957    /// # Errors
958    ///
959    /// Returns [`DynamicNavmeshError`] when an update references an unknown cell
960    /// or portal.
961    pub fn apply_update(
962        &mut self,
963        update: &DynamicNavmeshUpdate,
964    ) -> Result<(), DynamicNavmeshError> {
965        match update {
966            DynamicNavmeshUpdate::SetCellEnabled { cell_id, enabled } => {
967                self.ensure_cell_exists(cell_id)?;
968                if *enabled {
969                    self.disabled_cells.remove(cell_id);
970                } else {
971                    self.disabled_cells.insert(cell_id.clone());
972                }
973            }
974            DynamicNavmeshUpdate::SetPortalEnabled {
975                left_cell_id,
976                right_cell_id,
977                enabled,
978            } => {
979                let portal = DynamicNavmeshPortalKey::new(left_cell_id, right_cell_id);
980                self.ensure_portal_exists(&portal)?;
981                if *enabled {
982                    self.disabled_portals.remove(&portal);
983                } else {
984                    self.disabled_portals.insert(portal);
985                }
986            }
987        }
988
989        self.prepared_stale = true;
990        Ok(())
991    }
992
993    /// Builds a static [`Navmesh`] snapshot of currently enabled cells and portals.
994    ///
995    /// Survivor cells keep their geometry and stable `cell_id`s but receive new dense
996    /// indices. Portals between two surviving enabled cells are remapped; portals that
997    /// touch a disabled cell or sit in the disabled-portal set are dropped. Does **not**
998    /// clear [`Self::prepared_stale`]—callers must rebuild prepared structures separately.
999    /// If every cell is disabled, the snapshot is empty and queries return
1000    /// [`NavmeshQueryResult::InvalidStart`].
1001    ///
1002    /// # Errors
1003    ///
1004    /// Returns [`DynamicNavmeshError`] if the enabled subset does not form a
1005    /// valid navmesh.
1006    pub fn materialize(&self) -> Result<Navmesh, DynamicNavmeshError> {
1007        let mut source_to_materialized = BTreeMap::new();
1008        let mut cells = Vec::new();
1009        for (source_index, cell) in self.base.cells().iter().enumerate() {
1010            if self.disabled_cells.contains(cell.cell_id()) {
1011                continue;
1012            }
1013
1014            let materialized_index = cells.len();
1015            source_to_materialized.insert(source_index, materialized_index);
1016            cells.push(cell.clone());
1017        }
1018
1019        let portals = self
1020            .base
1021            .portals()
1022            .iter()
1023            .filter_map(|portal| {
1024                let left = &self.base.cells()[portal.left_cell];
1025                let right = &self.base.cells()[portal.right_cell];
1026                let portal_key = DynamicNavmeshPortalKey::new(left.cell_id(), right.cell_id());
1027                let left_cell = *source_to_materialized.get(&portal.left_cell)?;
1028                let right_cell = *source_to_materialized.get(&portal.right_cell)?;
1029                (!self.disabled_portals.contains(&portal_key)).then_some(NavmeshPortal {
1030                    left_cell,
1031                    right_cell,
1032                    start: portal.start,
1033                    end: portal.end,
1034                })
1035            })
1036            .collect::<Vec<_>>();
1037
1038        let navmesh = Navmesh::new(cells, portals);
1039        navmesh.validate()?;
1040        Ok(navmesh)
1041    }
1042
1043    fn ensure_cell_exists(&self, cell_id: &str) -> Result<(), DynamicNavmeshError> {
1044        if self
1045            .base
1046            .cells()
1047            .iter()
1048            .any(|cell| cell.cell_id() == cell_id)
1049        {
1050            Ok(())
1051        } else {
1052            Err(DynamicNavmeshError::MissingCell {
1053                cell_id: cell_id.to_owned(),
1054            })
1055        }
1056    }
1057
1058    fn ensure_portal_exists(
1059        &self,
1060        portal: &DynamicNavmeshPortalKey,
1061    ) -> Result<(), DynamicNavmeshError> {
1062        if self.base.portals().iter().any(|candidate| {
1063            let left = &self.base.cells()[candidate.left_cell];
1064            let right = &self.base.cells()[candidate.right_cell];
1065            DynamicNavmeshPortalKey::new(left.cell_id(), right.cell_id()) == *portal
1066        }) {
1067            Ok(())
1068        } else {
1069            Err(DynamicNavmeshError::MissingPortal {
1070                left_cell_id: portal.left_cell_id().to_owned(),
1071                right_cell_id: portal.right_cell_id().to_owned(),
1072            })
1073        }
1074    }
1075}
1076
1077/// How a dynamic prepared query restored a fresh prepared snapshot.
1078///
1079/// V0 always discards prior prepared data and rebuilds from the materialized
1080/// snapshot—there is no incremental patch path.
1081#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1082pub enum DynamicPreparedNavmeshRebuildStatus {
1083    /// Prepared map was rebuilt from [`DynamicNavmeshState::materialize`] output.
1084    RebuiltFromMaterializedSnapshot,
1085}
1086
1087impl DynamicPreparedNavmeshRebuildStatus {
1088    /// Stable string token for reports and fixtures.
1089    #[must_use]
1090    pub const fn as_str(self) -> &'static str {
1091        match self {
1092            Self::RebuiltFromMaterializedSnapshot => "rebuilt-from-materialized-snapshot",
1093        }
1094    }
1095}
1096
1097/// Staleness and rebuild bookkeeping from one dynamic prepared query run.
1098///
1099/// Captures whether prepared data was already stale, how many updates ran, and
1100/// that a full rebuild from the materialized snapshot completed (V0 has no
1101/// incremental patch path).
1102#[derive(Debug, Clone, PartialEq, Eq)]
1103pub struct DynamicPreparedNavmeshQueryMetadata {
1104    /// [`PreparedNavmeshBuilder::name`] used for the rebuild.
1105    pub builder_name: &'static str,
1106    /// Number of [`DynamicNavmeshUpdate`]s applied in this run.
1107    pub applied_update_count: usize,
1108    /// [`DynamicNavmeshState::prepared_stale`] before any update in this run.
1109    pub prepared_stale_before_updates: bool,
1110    /// Stale flag after updates (true when any update applied or already stale).
1111    pub prepared_stale_after_updates: bool,
1112    /// Stale flag after rebuild; expected `false` on success.
1113    pub prepared_stale_after_rebuild: bool,
1114    /// Cell count in the materialization used for rebuild.
1115    pub materialized_cell_count: usize,
1116    /// Portal count in the materialization used for rebuild.
1117    pub materialized_portal_count: usize,
1118    /// How prepared data was restored (V0: always full rebuild).
1119    pub rebuild_status: DynamicPreparedNavmeshRebuildStatus,
1120}
1121
1122/// Materialized navmesh, rebuilt prepared map, and raw vs rebuilt query outcomes.
1123///
1124/// `raw_result` is [`Navmesh::query`] on the materialization; `rebuilt_prepared_result`
1125/// is the same query on the freshly prepared map (parity check for rebuild correctness).
1126#[derive(Debug, Clone, PartialEq)]
1127pub struct DynamicPreparedNavmeshQueryResult<M> {
1128    /// Static mesh snapshot after applying availability updates.
1129    pub materialized_navmesh: Navmesh,
1130    /// Prepared map rebuilt from [`Self::materialized_navmesh`].
1131    pub prepared_navmesh: M,
1132    /// Connectivity result from the raw materialization (no prepared index).
1133    pub raw_result: NavmeshQueryResult,
1134    /// Connectivity result from the freshly prepared map (parity with raw).
1135    pub rebuilt_prepared_result: NavmeshQueryResult,
1136    /// Staleness / rebuild bookkeeping for this run.
1137    pub metadata: DynamicPreparedNavmeshQueryMetadata,
1138}
1139
1140/// Applies dynamic updates, materializes, rebuilds prepared data, and runs connectivity.
1141///
1142/// Canonical lifecycle helper for the dynamic availability lane: ordered updates →
1143/// materialize → [`PreparedNavmeshBuilder::preprocess`] → query both raw and prepared →
1144/// clear [`DynamicNavmeshState::prepared_stale`].
1145#[derive(Debug, Clone, Copy, Default)]
1146pub struct DynamicPreparedNavmeshQuery;
1147
1148impl DynamicPreparedNavmeshQuery {
1149    /// Applies `updates` in order, materializes, rebuilds via `builder`, and queries both.
1150    ///
1151    /// On success, marks `state` as no longer prepared-stale. Does not mutate the base
1152    /// mesh geometry—only the availability overlay and staleness flag.
1153    ///
1154    /// # Errors
1155    ///
1156    /// Returns [`DynamicNavmeshError`] when an update is invalid, materialization fails,
1157    /// or prepared preprocess fails.
1158    pub fn run<B, U>(
1159        state: &mut DynamicNavmeshState,
1160        updates: impl IntoIterator<Item = U>,
1161        query: NavmeshQuery,
1162        builder: &B,
1163    ) -> Result<DynamicPreparedNavmeshQueryResult<B::Map>, DynamicNavmeshError>
1164    where
1165        B: PreparedNavmeshBuilder,
1166        U: Borrow<DynamicNavmeshUpdate>,
1167    {
1168        let prepared_stale_before_updates = state.prepared_stale();
1169        let mut applied_update_count = 0;
1170        for update in updates {
1171            state.apply_update(update.borrow())?;
1172            applied_update_count += 1;
1173        }
1174        let prepared_stale_after_updates = state.prepared_stale();
1175
1176        let materialized_navmesh = state.materialize()?;
1177        let raw_result = materialized_navmesh.query(query);
1178        let prepared_navmesh = builder
1179            .preprocess(&materialized_navmesh)
1180            .map_err(|source| DynamicNavmeshError::PreparedRebuild { source })?;
1181        let rebuilt_prepared_result = prepared_navmesh.query(query);
1182        state.mark_prepared_rebuilt();
1183
1184        let metadata = DynamicPreparedNavmeshQueryMetadata {
1185            builder_name: builder.name(),
1186            applied_update_count,
1187            prepared_stale_before_updates,
1188            prepared_stale_after_updates,
1189            prepared_stale_after_rebuild: state.prepared_stale(),
1190            materialized_cell_count: materialized_navmesh.cells().len(),
1191            materialized_portal_count: materialized_navmesh.portals().len(),
1192            rebuild_status: DynamicPreparedNavmeshRebuildStatus::RebuiltFromMaterializedSnapshot,
1193        };
1194
1195        Ok(DynamicPreparedNavmeshQueryResult {
1196            materialized_navmesh,
1197            prepared_navmesh,
1198            raw_result,
1199            rebuilt_prepared_result,
1200            metadata,
1201        })
1202    }
1203}
1204
1205/// Online route search over a validated **static** [`Navmesh`] snapshot.
1206///
1207/// Implementations (channel search, TA*, optional Polyanya) own the algorithm but
1208/// share this module's mesh, walkability, and query contracts. They do not apply
1209/// dynamic availability—callers [`DynamicNavmeshState::materialize`] first when
1210/// needed. Prepared multi-query TRA* maps implement
1211/// [`PreparedNavmesh`] + a local `search` instead of this trait.
1212pub trait NavmeshPathfinder {
1213    /// Stable algorithm name for reports and benchmark ids.
1214    fn name(&self) -> &'static str;
1215
1216    /// Searches for a continuous polyline between the query endpoints.
1217    ///
1218    /// Successful computation returns [`Ok`] with found or no-path
1219    /// [`SearchOutcome`]; invalid endpoints, exhausted [`condor_core::SearchBudget`],
1220    /// and backend prep failures return [`NavmeshSearchError`]. Disconnected graphs
1221    /// are no-path, not `Err`.
1222    ///
1223    /// # Errors
1224    ///
1225    /// Returns [`NavmeshSearchError`] when an endpoint is invalid, the caller budget
1226    /// is exhausted, or an implementation cannot prepare the navmesh for its search
1227    /// backend.
1228    fn search(&self, navmesh: &Navmesh, query: NavmeshQuery) -> NavmeshSearchResult;
1229}
1230
1231fn polygon_edges(vertices: &[Point2]) -> impl Iterator<Item = (Point2, Point2)> + '_ {
1232    vertices
1233        .iter()
1234        .copied()
1235        .zip(vertices.iter().copied().cycle().skip(1))
1236        .take(vertices.len())
1237}
1238
1239fn sort_and_dedup_parameters(parameters: &mut Vec<f64>) {
1240    parameters.sort_by(f64::total_cmp);
1241    parameters.dedup_by(|left, right| (*left - *right).abs() <= EPSILON);
1242}
1243
1244fn interpolate_segment(start: Point2, end: Point2, parameter: f64) -> Point2 {
1245    Point2::new(
1246        start.x + ((end.x - start.x) * parameter),
1247        start.y + ((end.y - start.y) * parameter),
1248    )
1249}
1250
1251fn segment_intersection_parameters(
1252    a_start: Point2,
1253    a_end: Point2,
1254    b_start: Point2,
1255    b_end: Point2,
1256) -> Vec<f64> {
1257    let mut parameters = Vec::with_capacity(2);
1258    for point in [a_start, a_end, b_start, b_end] {
1259        if point_on_segment(point, a_start, a_end) && point_on_segment(point, b_start, b_end) {
1260            parameters.push(segment_parameter(point, a_start, a_end));
1261        }
1262    }
1263
1264    if !parameters.is_empty() {
1265        sort_and_dedup_parameters(&mut parameters);
1266        return parameters;
1267    }
1268
1269    if let Some(parameter) = proper_intersection_parameter(a_start, a_end, b_start, b_end) {
1270        parameters.push(parameter);
1271    }
1272
1273    parameters
1274}
1275
1276fn segment_parameter(point: Point2, start: Point2, end: Point2) -> f64 {
1277    let dx = end.x - start.x;
1278    let dy = end.y - start.y;
1279    if dx.abs() >= dy.abs() && dx.abs() > EPSILON {
1280        ((point.x - start.x) / dx).clamp(0.0, 1.0)
1281    } else if dy.abs() > EPSILON {
1282        ((point.y - start.y) / dy).clamp(0.0, 1.0)
1283    } else {
1284        0.0
1285    }
1286}
1287
1288fn proper_intersection_parameter(
1289    a_start: Point2,
1290    a_end: Point2,
1291    b_start: Point2,
1292    b_end: Point2,
1293) -> Option<f64> {
1294    let o1 = orientation(a_start, a_end, b_start);
1295    let o2 = orientation(a_start, a_end, b_end);
1296    let o3 = orientation(b_start, b_end, a_start);
1297    let o4 = orientation(b_start, b_end, a_end);
1298
1299    let properly_crosses = (o1 > EPSILON && o2 < -EPSILON || o1 < -EPSILON && o2 > EPSILON)
1300        && (o3 > EPSILON && o4 < -EPSILON || o3 < -EPSILON && o4 > EPSILON);
1301    if !properly_crosses {
1302        return None;
1303    }
1304
1305    let a_dx = a_end.x - a_start.x;
1306    let a_dy = a_end.y - a_start.y;
1307    let b_dx = b_end.x - b_start.x;
1308    let b_dy = b_end.y - b_start.y;
1309    let denominator = cross(a_dx, a_dy, b_dx, b_dy);
1310    if denominator.abs() <= EPSILON {
1311        return None;
1312    }
1313
1314    let offset_x = b_start.x - a_start.x;
1315    let offset_y = b_start.y - a_start.y;
1316    Some((cross(offset_x, offset_y, b_dx, b_dy) / denominator).clamp(0.0, 1.0))
1317}
1318
1319fn point_on_segment(point: Point2, start: Point2, end: Point2) -> bool {
1320    orientation(start, end, point).abs() <= EPSILON
1321        && point.x >= start.x.min(end.x) - EPSILON
1322        && point.x <= start.x.max(end.x) + EPSILON
1323        && point.y >= start.y.min(end.y) - EPSILON
1324        && point.y <= start.y.max(end.y) + EPSILON
1325}
1326
1327fn orientation(start: Point2, end: Point2, point: Point2) -> f64 {
1328    ((end.x - start.x) * (point.y - start.y)) - ((end.y - start.y) * (point.x - start.x))
1329}
1330
1331fn cross(left_x: f64, left_y: f64, right_x: f64, right_y: f64) -> f64 {
1332    (left_x * right_y) - (left_y * right_x)
1333}
1334
1335fn is_degenerate_polygon(vertices: &[Point2]) -> bool {
1336    signed_area(vertices).abs() <= EPSILON
1337}
1338
1339fn signed_area(vertices: &[Point2]) -> f64 {
1340    polygon_edges(vertices)
1341        .map(|(left, right)| (left.x * right.y) - (right.x * left.y))
1342        .sum::<f64>()
1343        / 2.0
1344}
1345
1346fn is_convex_polygon(vertices: &[Point2]) -> bool {
1347    let mut reference_sign = 0.0_f64;
1348    for index in 0..vertices.len() {
1349        let a = vertices[index];
1350        let b = vertices[(index + 1) % vertices.len()];
1351        let c = vertices[(index + 2) % vertices.len()];
1352        let turn = orientation(a, b, c);
1353        if turn.abs() <= EPSILON {
1354            continue;
1355        }
1356
1357        if reference_sign.abs() <= EPSILON {
1358            reference_sign = turn;
1359            continue;
1360        }
1361
1362        if turn.signum() != reference_sign.signum() {
1363            return false;
1364        }
1365    }
1366
1367    true
1368}
1369
1370/// Epsilon equality for navmesh portal/endpoint identity (not bit-identity).
1371pub(crate) fn points_equal(left: Point2, right: Point2) -> bool {
1372    (left.x - right.x).abs() <= EPSILON && (left.y - right.y).abs() <= EPSILON
1373}
1374
1375fn shares_any_cell(left: &[usize], right: &[usize]) -> bool {
1376    left.iter().any(|cell| right.contains(cell))
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::{Navmesh, NavmeshCell, NavmeshPortal, NavmeshQuery, NavmeshQueryResult};
1382    use condor_core::Point2;
1383
1384    #[test]
1385    fn validates_and_queries_a_two_cell_mesh() {
1386        let navmesh = Navmesh::new(
1387            vec![
1388                NavmeshCell::new(
1389                    "left",
1390                    vec![
1391                        Point2::new(0.0, 0.0),
1392                        Point2::new(2.0, 0.0),
1393                        Point2::new(2.0, 2.0),
1394                        Point2::new(0.0, 2.0),
1395                    ],
1396                ),
1397                NavmeshCell::new(
1398                    "right",
1399                    vec![
1400                        Point2::new(2.0, 0.0),
1401                        Point2::new(4.0, 0.0),
1402                        Point2::new(4.0, 2.0),
1403                        Point2::new(2.0, 2.0),
1404                    ],
1405                ),
1406            ],
1407            vec![NavmeshPortal {
1408                left_cell: 0,
1409                right_cell: 1,
1410                start: Point2::new(2.0, 0.0),
1411                end: Point2::new(2.0, 2.0),
1412            }],
1413        );
1414        navmesh.validate().expect("valid mesh");
1415        assert!(matches!(
1416            navmesh.query(NavmeshQuery::new(
1417                Point2::new(1.0, 1.0),
1418                Point2::new(3.0, 1.0)
1419            )),
1420            NavmeshQueryResult::Connected { .. }
1421        ));
1422    }
1423}