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}