Skip to main content

axiolid_route/
lib.rs

1#![forbid(unsafe_code)]
2//! Exact planar shortest path over a visibility graph.
3//!
4//! # Why exact, and what that means here
5//!
6//! `axiolid-field` already answers route questions, but by sampling a grid:
7//! its answer is only as good as the resolution. On a polygon set the shortest
8//! path is exactly computable, because the optimal path is a polyline whose
9//! interior vertices are region or barrier vertices. No discretisation, no
10//! resolution parameter.
11//!
12//! "Exact" is a claim about the COMBINATORICS, and it is earned by deciding
13//! every segment-crossing and sidedness question with certified `orient2d`
14//! rather than a tolerance comparison. Which edges exist in the visibility
15//! graph is therefore exact. The path LENGTH is still a sum of square roots
16//! evaluated in binary64, so it carries ordinary floating-point rounding.
17//! Overstating that as "exact length" would be a false claim, so it is not
18//! made.
19//!
20//! # Not a verdict
21//!
22//! The kernel owns the region, the graph, the path and the typed unreachable
23//! reason. It does not own why a route was requested, what clearance is
24//! required, or whether a length is acceptable. Same line `navigate.rs` draws.
25//!
26//! # Input size is bounded explicitly
27//!
28//! Visibility graph construction is quadratic in vertices and cubic to verify,
29//! so a large input silently becomes a hang. [`MAX_VERTICES`] caps it and
30//! oversized input is REFUSED, never truncated: truncating would answer a
31//! different question than the one asked, and the caller would not be told.
32//!
33//! The cap is a default, not a law. [`shortest_path_within`] takes the
34//! budget as a parameter, because what is affordable depends on the
35//! caller's deadline rather than on the kernel. And the refusal carries a
36//! PROVEN lower bound — the straight-line distance between the endpoints,
37//! which no route can beat — so an over-budget query still yields a usable
38//! fact instead of only an error.
39
40use axiolid_contracts::Sign;
41use axiolid_core::Point2;
42use axiolid_guarantees::Certified;
43use axiolid_overlay::{Polygon, Ring};
44use axiolid_predicates::orient2d;
45
46mod forced;
47mod graph;
48mod map;
49mod skeleton;
50mod weighted;
51
52use graph::Graph;
53
54pub use forced::{
55    forced_walk, forced_walk_within, weighted_forced_walk, weighted_forced_walk_within, ForcedWalk,
56    WeightedForcedWalk,
57};
58pub use map::{
59    distance_map, distance_map_weighted, distance_map_within, distance_map_within_weighted,
60    farthest_point, farthest_point_within, DistanceMap, Farthest, FarthestError, LengthInterval,
61    MapError, Reach, MAX_CELLS,
62};
63pub use skeleton::{skeleton, NodeKind, Skeleton, SkeletonError, SkeletonNode, Wall};
64pub use weighted::{
65    weighted_distance_map, weighted_distance_map_seeded, weighted_distance_map_seeded_within,
66    weighted_distance_map_within, weighted_farthest_point, weighted_farthest_point_within,
67    CostRegion, WeightedMap, WeightedReach, MAX_WEIGHTED_NODES,
68};
69
70/// Maximum vertices, counting region, barrier and endpoint vertices.
71pub const MAX_VERTICES: usize = 512;
72
73/// Why no path was produced.
74///
75/// A geometric fact about the input, distinguished from a numerical failure:
76/// a caller must be able to tell "there is no route" from "this could not be
77/// decided".
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum Unreachable {
81    /// The start point lies outside the free-space region.
82    StartOutside,
83    /// The goal point lies outside the free-space region.
84    GoalOutside,
85    /// Both endpoints are inside, but no route connects them.
86    DisconnectedComponents,
87}
88
89/// A malformed query, as opposed to an honest "no route".
90///
91/// `PartialEq` but not `Eq`: [`RouteError::TooManyVertices`] carries a
92/// float bound, and float equality is not reflexive.
93#[derive(Debug, Clone, Copy, PartialEq)]
94#[non_exhaustive]
95pub enum RouteError {
96    /// A coordinate was NaN or infinite.
97    NonFinitePoint,
98    /// A ring had fewer than three vertices.
99    RingTooShort,
100    /// A barrier had fewer than two vertices, so it bounds no segment.
101    BarrierTooShort,
102    /// The input exceeds the vertex budget. Refused, not truncated.
103    ///
104    /// Carries a PROVEN lower bound rather than only a complaint. A
105    /// refusal and a bound are different facts: a caller that cannot
106    /// afford the exact route can still report a minimum, defer, or
107    /// escalate, where a bare error forces it to drop the query or
108    /// reimplement routing (kernel#92).
109    ///
110    /// The added fields make this a breaking change: `Eq` is gone because
111    /// the bound is a float, and an exhaustive struct variant gained
112    /// fields. Both are recorded against the 0.3.0 minor bump rather than
113    /// worked around — marking the variant `#[non_exhaustive]` now would
114    /// itself be breaking, so it buys nothing here.
115    TooManyVertices {
116        /// Vertices the caller supplied.
117        supplied: usize,
118        /// The budget that was applied, so the caller can raise it.
119        budget: usize,
120        /// Straight-line distance between the endpoints.
121        ///
122        /// A lower bound on EVERY route between them, not an estimate:
123        /// a polyline is at least as long as the straight line joining
124        /// its ends, and obstacles only lengthen it. Both endpoints are
125        /// already proven inside the free-space region when this is
126        /// reported, so the bound applies to a route that could exist.
127        ///
128        /// Being a bound, it is safe to act on: no admissible route is
129        /// shorter. It says nothing about whether a route EXISTS.
130        lower_bound: f64,
131    },
132    /// The exact predicate could not decide a sidedness question.
133    ///
134    /// Distinct from every [`Unreachable`] variant: this is the kernel
135    /// declining to guess, not a statement about the geometry.
136    Undecidable,
137}
138
139/// A shortest path and its length.
140#[derive(Debug, Clone, PartialEq)]
141pub struct Route {
142    /// The polyline realising the path, start first, goal last.
143    pub polyline: Vec<Point2>,
144    /// Summed Euclidean length of the polyline.
145    ///
146    /// A sum of square roots in binary64: the graph is exact, this number
147    /// carries ordinary rounding.
148    pub length: f64,
149    /// Vertices in the visibility graph that produced this path.
150    pub graph_vertices: usize,
151}
152
153/// Shortest path from `start` to `goal` inside `region`, avoiding `barriers`.
154///
155/// `barriers` are zero-width: they block visibility without bounding area, so
156/// a wall that is a line rather than a thin polygon still stops a route. A
157/// barrier is a polyline, not a ring, and is not closed implicitly.
158///
159/// `Ok(Ok(route))` is a path; `Ok(Err(reason))` is a geometric fact that no
160/// path exists; `Err` is a malformed query.
161pub fn shortest_path(
162    region: &[Polygon],
163    barriers: &[Vec<Point2>],
164    start: Point2,
165    goal: Point2,
166) -> Result<Result<Route, Unreachable>, RouteError> {
167    shortest_path_within(region, barriers, start, goal, MAX_VERTICES)
168}
169
170/// [`shortest_path`] with a caller-chosen vertex budget.
171///
172/// The right cap depends on the caller's time budget, not on the kernel:
173/// construction is quadratic in vertices and cubic to verify, so what is
174/// affordable is a property of the deadline, not of the geometry
175/// (kernel#92). [`MAX_VERTICES`] remains the default for
176/// [`shortest_path`].
177///
178/// Raising the budget does not change any answer, only which inputs are
179/// affordable. Over-budget input is still REFUSED rather than truncated,
180/// but the refusal carries a proven lower bound.
181pub fn shortest_path_within(
182    region: &[Polygon],
183    barriers: &[Vec<Point2>],
184    start: Point2,
185    goal: Point2,
186    budget: usize,
187) -> Result<Result<Route, Unreachable>, RouteError> {
188    validate(region, barriers, start, goal)?;
189
190    // Endpoint containment is decided before any graph work: it is the
191    // cheapest question and gives the most specific answer.
192    if !contains(region, start)? {
193        return Ok(Err(Unreachable::StartOutside));
194    }
195    if !contains(region, goal)? {
196        return Ok(Err(Unreachable::GoalOutside));
197    }
198
199    let mut nodes = vec![start, goal];
200    for polygon in region {
201        nodes.extend(polygon.outer.points.iter().copied());
202        for hole in &polygon.holes {
203            nodes.extend(hole.points.iter().copied());
204        }
205    }
206    for barrier in barriers {
207        nodes.extend(barrier.iter().copied());
208    }
209
210    // Duplicate vertices would create zero-length graph edges and duplicate
211    // work without changing the answer.
212    dedup_points(&mut nodes);
213    if nodes.len() > budget {
214        // Both endpoints are proven inside the region by this point, so
215        // the straight line between them bounds any route that could
216        // exist. Reported as a fact the caller can act on, not as a
217        // consolation: no admissible route is shorter than this.
218        return Err(RouteError::TooManyVertices {
219            supplied: nodes.len(),
220            budget,
221            lower_bound: (goal - start).length(),
222        });
223    }
224
225    let obstacles = obstacle_segments(region, barriers);
226    let graph = Graph::build(&nodes, region, barriers, &obstacles)?;
227    // The goal is the first vertex equal to it (the start, if they are
228    // the same point).
229    let target = nodes.iter().position(|n| *n == goal).unwrap_or(1);
230    let sources: Vec<usize> = graph.states(0).collect();
231    let (distance, previous, reached) = graph::dijkstra(&graph.adjacency, &sources, |state| {
232        graph.node(state) == target
233    });
234    let Some(end) = reached else {
235        return Ok(Err(Unreachable::DisconnectedComponents));
236    };
237    let mut path = vec![end];
238    let mut state = end;
239    while previous[state] != usize::MAX {
240        state = previous[state];
241        path.push(state);
242    }
243    path.reverse();
244    let mut polyline: Vec<Point2> = path.into_iter().map(|s| nodes[graph.node(s)]).collect();
245    if polyline.len() == 1 {
246        polyline.push(goal);
247    }
248    Ok(Ok(Route {
249        polyline,
250        length: distance[end],
251        graph_vertices: nodes.len(),
252    }))
253}
254
255fn validate(
256    region: &[Polygon],
257    barriers: &[Vec<Point2>],
258    start: Point2,
259    goal: Point2,
260) -> Result<(), RouteError> {
261    if !start.is_finite() || !goal.is_finite() {
262        return Err(RouteError::NonFinitePoint);
263    }
264    validate_region(region, barriers)
265}
266
267/// The region's rings and the barriers are long enough and finite.
268fn validate_region(region: &[Polygon], barriers: &[Vec<Point2>]) -> Result<(), RouteError> {
269    for polygon in region {
270        for ring in core::iter::once(&polygon.outer).chain(polygon.holes.iter()) {
271            if ring.points.len() < 3 {
272                return Err(RouteError::RingTooShort);
273            }
274            if !ring.points.iter().all(|p| p.is_finite()) {
275                return Err(RouteError::NonFinitePoint);
276            }
277        }
278    }
279    for barrier in barriers {
280        if barrier.len() < 2 {
281            return Err(RouteError::BarrierTooShort);
282        }
283        if !barrier.iter().all(|p| p.is_finite()) {
284            return Err(RouteError::NonFinitePoint);
285        }
286    }
287    Ok(())
288}
289
290/// Exact sidedness, or `Undecidable` rather than a guess.
291fn side(a: Point2, b: Point2, c: Point2) -> Result<Sign, RouteError> {
292    match orient2d(a, b, c) {
293        Certified::Certain { sign, .. } => Ok(sign),
294        _ => Err(RouteError::Undecidable),
295    }
296}
297
298/// True when segments `pq` and `rs` cross at an interior point of both.
299///
300/// Shared endpoints and collinear touching do NOT count: two visibility edges
301/// meeting at a shared polygon vertex is the normal case, and treating that as
302/// a blocking crossing would disconnect every graph.
303fn crosses(p: Point2, q: Point2, r: Point2, s: Point2) -> Result<bool, RouteError> {
304    // Disjoint bounding boxes cannot cross; skips the predicates.
305    if p.x.max(q.x) < r.x.min(s.x)
306        || r.x.max(s.x) < p.x.min(q.x)
307        || p.y.max(q.y) < r.y.min(s.y)
308        || r.y.max(s.y) < p.y.min(q.y)
309    {
310        return Ok(false);
311    }
312    let d1 = side(p, q, r)?;
313    let d2 = side(p, q, s)?;
314    let d3 = side(r, s, p)?;
315    let d4 = side(r, s, q)?;
316    // Strict straddle on both segments. Any Zero means a touch, not a cross.
317    Ok(d1 != Sign::Zero
318        && d2 != Sign::Zero
319        && d3 != Sign::Zero
320        && d4 != Sign::Zero
321        && d1 != d2
322        && d3 != d4)
323}
324
325/// Every blocking segment: region boundary edges and barrier edges.
326fn obstacle_segments(region: &[Polygon], barriers: &[Vec<Point2>]) -> Vec<(Point2, Point2)> {
327    let mut segments = Vec::new();
328    for polygon in region {
329        for ring in core::iter::once(&polygon.outer).chain(polygon.holes.iter()) {
330            segments.extend(ring_edges(ring));
331        }
332    }
333    for barrier in barriers {
334        // Open polyline: no closing edge, so a zero-width wall stays a wall
335        // rather than becoming an implicit loop.
336        for pair in barrier.windows(2) {
337            segments.push((pair[0], pair[1]));
338        }
339    }
340    segments
341}
342
343fn ring_edges(ring: &Ring) -> Vec<(Point2, Point2)> {
344    let count = ring.points.len();
345    (0..count)
346        .map(|index| (ring.points[index], ring.points[(index + 1) % count]))
347        .collect()
348}
349
350/// Can the open segment `a`-`b` be travelled without leaving the region?
351///
352/// It must not properly cross any obstacle edge. And every stretch of it
353/// must lie in the closed region: a segment can clear every edge yet pass
354/// through a hole corner to corner, or run along one wall, through a
355/// vertex, and on across a gap outside the region (#187). So the segment
356/// is cut at every obstacle vertex lying on it, decided exactly. Between
357/// two cuts a stretch either runs along an obstacle edge -- on the
358/// boundary, which a route may follow -- or meets no boundary at all, and
359/// then its midpoint decides for the whole of it.
360///
361/// Passing through a vertex where obstacles meet is decided per side of
362/// travel, with the vertex's sectors, in [`graph::sides`] (#189).
363fn visible(
364    a: Point2,
365    b: Point2,
366    region: &[Polygon],
367    obstacles: &[(Point2, Point2)],
368) -> Result<bool, RouteError> {
369    for (p, q) in obstacles {
370        if crosses(a, b, *p, *q)? {
371            return Ok(false);
372        }
373    }
374    // Obstacle vertices strictly inside the segment, in order along it.
375    let d = b - a;
376    let mut cuts: Vec<Point2> = Vec::new();
377    for (p, q) in obstacles {
378        for v in [*p, *q] {
379            if v != a && v != b && side(a, b, v)? == Sign::Zero && within(a, b, v) {
380                cuts.push(v);
381            }
382        }
383    }
384    cuts.sort_by(|u, v| (*u - a).dot(d).total_cmp(&(*v - a).dot(d)));
385    cuts.dedup();
386    let mut stops = Vec::with_capacity(cuts.len() + 2);
387    stops.push(a);
388    stops.extend(cuts);
389    stops.push(b);
390    for pair in stops.windows(2) {
391        let (from, to) = (pair[0], pair[1]);
392        // Along an obstacle edge: both ends on it, exactly.
393        let along = obstacles.iter().any(|&(p, q)| {
394            matches!(side(p, q, from), Ok(Sign::Zero))
395                && matches!(side(p, q, to), Ok(Sign::Zero))
396                && within(p, q, from)
397                && within(p, q, to)
398        });
399        if along {
400            continue;
401        }
402        let midpoint = Point2::new(from.x * 0.5 + to.x * 0.5, from.y * 0.5 + to.y * 0.5);
403        if !contains(region, midpoint)? {
404            return Ok(false);
405        }
406    }
407    Ok(true)
408}
409
410/// Whether `v`, collinear with `a`-`b`, lies within the segment's span.
411fn within(a: Point2, b: Point2, v: Point2) -> bool {
412    v.x >= a.x.min(b.x) && v.x <= a.x.max(b.x) && v.y >= a.y.min(b.y) && v.y <= a.y.max(b.y)
413}
414
415/// Is `point` inside the region (outer boundary, minus holes)?
416///
417/// Boundary points count as inside: a route legitimately runs along a wall,
418/// and excluding the boundary would make every vertex-to-vertex edge invalid.
419fn contains(region: &[Polygon], point: Point2) -> Result<bool, RouteError> {
420    for polygon in region {
421        // Ray casting alone is boundary-EXCLUSIVE, but region vertices lie
422        // exactly on the outer boundary and every visibility edge ends at one.
423        // Excluding them would reject every graph edge.
424        if !point_in_ring(&polygon.outer, point) && !on_boundary(&polygon.outer, point)? {
425            continue;
426        }
427        let mut in_hole = false;
428        for hole in &polygon.holes {
429            // Strictly inside a hole is outside the region; ON the hole
430            // boundary is still travellable.
431            if point_in_ring(hole, point) && !on_boundary(hole, point)? {
432                in_hole = true;
433                break;
434            }
435        }
436        if !in_hole {
437            return Ok(true);
438        }
439    }
440    Ok(false)
441}
442
443/// Exact on-boundary test: the point is collinear with, and between, the
444/// endpoints of some ring edge.
445fn on_boundary(ring: &Ring, point: Point2) -> Result<bool, RouteError> {
446    for (a, b) in ring_edges(ring) {
447        if side(a, b, point)? != Sign::Zero {
448            continue;
449        }
450        // Collinear; now check it lies within the edge span rather than on
451        // its infinite extension.
452        let within_x = point.x >= a.x.min(b.x) && point.x <= a.x.max(b.x);
453        let within_y = point.y >= a.y.min(b.y) && point.y <= a.y.max(b.y);
454        if within_x && within_y {
455            return Ok(true);
456        }
457    }
458    Ok(false)
459}
460
461/// Ray-casting containment, boundary inclusive.
462fn point_in_ring(ring: &Ring, point: Point2) -> bool {
463    let mut inside = false;
464    let count = ring.points.len();
465    for index in 0..count {
466        let a = ring.points[index];
467        let b = ring.points[(index + 1) % count];
468        if (a.y > point.y) != (b.y > point.y) {
469            let crossing = (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x;
470            if point.x < crossing {
471                inside = !inside;
472            }
473        }
474    }
475    inside
476}
477
478/// Remove exact duplicate points, preserving first-seen order.
479fn dedup_points(points: &mut Vec<Point2>) {
480    let mut seen: Vec<Point2> = Vec::new();
481    points.retain(|point| {
482        if seen.iter().any(|other| other == point) {
483            false
484        } else {
485            seen.push(*point);
486            true
487        }
488    });
489}