Skip to main content

trailgen_core/
model.rs

1use crate::geo::{Coord, LineString};
2use crate::hiking::{EdgeTraversal, HikingModel, TraversalEstimate};
3use crate::{Result, TrailgenError};
4use rstar::{AABB, RTree, RTreeObject};
5use serde::{Deserialize, Serialize};
6use std::cmp::Ordering;
7use std::collections::{BTreeMap, BinaryHeap};
8use std::ops::AddAssign;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
11#[serde(transparent)]
12pub struct VertexId(pub usize);
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct EdgeId(pub usize);
17
18/// The source-authored structural kind of a routable segment. This is
19/// orthogonal to terrain and surface: a forest footway is still a footway.
20#[derive(
21    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
22)]
23#[serde(rename_all = "kebab-case")]
24pub enum WayKind {
25    #[default]
26    Unknown,
27    Path,
28    Footway,
29    Sidewalk,
30    Crossing,
31    Track,
32    #[serde(alias = "service")]
33    ServiceRoad,
34    PedestrianStreet,
35    Steps,
36    Bridleway,
37    /// A deliberate route across ground where no path is asserted to exist.
38    /// Physical ground cover remains represented by [`Terrain`].
39    Bushwhack,
40    #[serde(alias = "road")]
41    Roadway,
42    /// Bicycle-priority infrastructure whose pedestrian authority is carried
43    /// independently by [`Access`].
44    Cycleway,
45}
46
47/// The routing projection in which a way participates. Manual routing admits
48/// every value; Finder admits only recreation and its bounded connectors.
49#[derive(
50    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
51)]
52#[serde(rename_all = "kebab-case")]
53pub enum WayRealm {
54    #[default]
55    Recreational,
56    Connector,
57    Urban,
58}
59
60impl WayRealm {
61    #[must_use]
62    pub fn from_tag(tag: &str) -> Self {
63        match tag.trim().to_ascii_lowercase().as_str() {
64            "connector" | "recreational-connector" => Self::Connector,
65            "urban" | "pedestrian" | "circulation" => Self::Urban,
66            _ => Self::Recreational,
67        }
68    }
69
70    #[must_use]
71    pub const fn admitted_by_finder(self) -> bool {
72        matches!(self, Self::Recreational | Self::Connector)
73    }
74}
75
76#[derive(
77    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
78)]
79#[serde(rename_all = "kebab-case")]
80pub enum GeometryClaim {
81    #[default]
82    Surveyed,
83    CenterlineProxy,
84}
85
86impl GeometryClaim {
87    #[must_use]
88    pub fn from_tag(tag: &str) -> Self {
89        match tag.trim().to_ascii_lowercase().as_str() {
90            "centerline-proxy" | "road-centerline-proxy" | "proxy" => Self::CenterlineProxy,
91            _ => Self::Surveyed,
92        }
93    }
94}
95
96#[derive(
97    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
98)]
99#[serde(rename_all = "kebab-case")]
100pub enum CrossingControl {
101    #[default]
102    None,
103    Uncontrolled,
104    Marked,
105    Signals,
106    GradeSeparated,
107}
108
109impl CrossingControl {
110    #[must_use]
111    pub fn from_tag(tag: &str) -> Self {
112        match tag.trim().to_ascii_lowercase().as_str() {
113            "uncontrolled" | "unmarked" | "no" => Self::Uncontrolled,
114            "marked" | "zebra" | "uncontrolled-marked" => Self::Marked,
115            "signals" | "signal" | "traffic-signals" => Self::Signals,
116            "grade-separated" | "bridge" | "tunnel" => Self::GradeSeparated,
117            _ => Self::None,
118        }
119    }
120}
121
122/// How much institutional and physical reality a trail line presently claims.
123/// Access is independent: an established trail may be closed, while an
124/// informal path may still cross public land.
125#[derive(
126    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
127)]
128#[serde(rename_all = "kebab-case")]
129pub enum TrailStanding {
130    #[default]
131    Unknown,
132    Established,
133    Unmaintained,
134    Informal,
135    Historical,
136}
137
138/// Whether the path supplies deliberate wayfinding marks. This is independent
139/// of physical condition, institutional standing, and permission to travel.
140#[derive(
141    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
142)]
143#[serde(rename_all = "kebab-case")]
144pub enum TrailMarking {
145    #[default]
146    Unknown,
147    Marked,
148    Unmarked,
149}
150
151impl TrailMarking {
152    #[must_use]
153    pub fn from_tag(tag: &str) -> Self {
154        match tag.trim().to_ascii_lowercase().as_str() {
155            "marked" | "marked trail" | "blazed" | "yes" | "symbols" | "poles" | "cairns" => {
156                Self::Marked
157            }
158            "unmarked" | "unmarked trail" | "unblazed" | "no" | "none" => Self::Unmarked,
159            _ => Self::Unknown,
160        }
161    }
162}
163
164impl TrailStanding {
165    #[must_use]
166    pub fn from_tag(tag: &str) -> Self {
167        match tag.trim().to_ascii_lowercase().as_str() {
168            "established" | "maintained" | "official" | "current" => Self::Established,
169            "unmaintained" | "disused" | "overgrown" => Self::Unmaintained,
170            "informal" | "social" | "social-trail" | "desire-path" => Self::Informal,
171            "historical" | "abandoned" | "removed" => Self::Historical,
172            _ => Self::Unknown,
173        }
174    }
175}
176
177impl WayKind {
178    #[must_use]
179    pub fn from_tag(tag: &str) -> Self {
180        match tag.trim().to_ascii_lowercase().as_str() {
181            "path" | "trail" | "singletrack" => Self::Path,
182            "footway" => Self::Footway,
183            "sidewalk" => Self::Sidewalk,
184            "crossing" => Self::Crossing,
185            "track" => Self::Track,
186            "service" | "service-road" => Self::ServiceRoad,
187            "pedestrian" | "pedestrian-way" => Self::PedestrianStreet,
188            "steps" | "stairs" => Self::Steps,
189            "bridleway" => Self::Bridleway,
190            "bushwhack" | "bushwhacking" | "off-trail" | "offtrail" | "cross-country" => {
191                Self::Bushwhack
192            }
193            "road" | "living_street" | "residential" | "unclassified" | "tertiary"
194            | "secondary" | "primary" => Self::Roadway,
195            "cycleway" | "cycle-way" => Self::Cycleway,
196            _ => Self::Unknown,
197        }
198    }
199
200    #[must_use]
201    pub const fn road_like(self) -> bool {
202        matches!(self, Self::Track | Self::ServiceRoad | Self::Roadway)
203    }
204
205    #[must_use]
206    pub const fn pathless(self) -> bool {
207        matches!(self, Self::Bushwhack)
208    }
209}
210
211#[derive(
212    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
213)]
214#[serde(rename_all = "kebab-case")]
215pub enum Terrain {
216    #[default]
217    Unknown,
218    Trail,
219    Forest,
220    Alpine,
221    Talus,
222    Scramble,
223    Pavement,
224    Road,
225    Water,
226}
227
228impl Terrain {
229    #[must_use]
230    pub fn from_tag(tag: &str) -> Self {
231        match tag.trim().to_ascii_lowercase().as_str() {
232            "trail" | "path" | "singletrack" => Self::Trail,
233            "forest" | "woods" => Self::Forest,
234            "alpine" | "tundra" => Self::Alpine,
235            "talus" | "scree" | "boulder" => Self::Talus,
236            "scramble" | "technical" => Self::Scramble,
237            "pavement" | "paved" | "asphalt" => Self::Pavement,
238            "road" | "service-road" | "gravel-road" => Self::Road,
239            "water" | "ford" => Self::Water,
240            _ => Self::Unknown,
241        }
242    }
243
244    #[must_use]
245    pub fn from_landcover_tag(tag: &str) -> Self {
246        let terrain = Self::from_tag(tag);
247        if terrain != Self::Unknown {
248            return terrain;
249        }
250        if let Ok(code) = tag.trim().parse::<u16>() {
251            return Self::from_nlcd_code(code);
252        }
253        match canonical_tag(tag).as_str() {
254            "openwater" | "emergentherbaceouswetlands" | "wetlands" => Self::Water,
255            "perennialicesnow"
256            | "shrubscrub"
257            | "grasslandherbaceous"
258            | "sedgeherbaceous"
259            | "lichens"
260            | "moss"
261            | "pasturehay"
262            | "cultivatedcrops" => Self::Alpine,
263            "developedopenspace"
264            | "developedlowintensity"
265            | "developedmediumintensity"
266            | "developedhighintensity"
267            | "developed"
268            | "urban" => Self::Pavement,
269            "barrenland" | "rocksandclay" | "barren" | "bedrock" | "rock" => Self::Talus,
270            "deciduousforest" | "evergreenforest" | "mixedforest" | "forestland"
271            | "woodywetlands" => Self::Forest,
272            _ => Self::Unknown,
273        }
274    }
275
276    const fn from_nlcd_code(code: u16) -> Self {
277        match code {
278            11 | 95 => Self::Water,
279            12 | 52 | 71 | 72 | 73 | 74 | 81 | 82 => Self::Alpine,
280            21..=24 => Self::Pavement,
281            31 => Self::Talus,
282            41..=43 | 90 => Self::Forest,
283            _ => Self::Unknown,
284        }
285    }
286}
287
288fn canonical_tag(tag: &str) -> String {
289    tag.chars()
290        .filter(char::is_ascii_alphanumeric)
291        .flat_map(char::to_lowercase)
292        .collect()
293}
294
295#[derive(
296    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
297)]
298#[serde(rename_all = "kebab-case")]
299pub enum Access {
300    #[default]
301    Unknown,
302    Open,
303    Restricted,
304    Closed,
305    Private,
306}
307
308impl Access {
309    #[must_use]
310    pub fn from_tag(tag: &str) -> Self {
311        match tag.trim().to_ascii_lowercase().as_str() {
312            "open" | "yes" | "permissive" => Self::Open,
313            "restricted" | "permit" => Self::Restricted,
314            "closed" | "no" => Self::Closed,
315            "private" => Self::Private,
316            _ => Self::Unknown,
317        }
318    }
319}
320
321#[derive(
322    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
323)]
324#[serde(rename_all = "kebab-case")]
325pub enum EdgeTravel {
326    #[default]
327    Both,
328    Forward,
329    Backward,
330}
331
332impl EdgeTravel {
333    #[must_use]
334    pub fn from_tag(tag: &str) -> Self {
335        match tag.trim().to_ascii_lowercase().as_str() {
336            "both" | "bidirectional" | "two-way" | "twoway" | "2" | "no" | "false" | "0" => {
337                Self::Both
338            }
339            "forward" | "with" | "yes" | "true" | "1" => Self::Forward,
340            "backward" | "reverse" | "against" | "-1" => Self::Backward,
341            _ => Self::Both,
342        }
343    }
344
345    #[must_use]
346    pub const fn can_depart(self, from: VertexId, a: VertexId, b: VertexId) -> bool {
347        match self {
348            Self::Both => from.0 == a.0 || from.0 == b.0,
349            Self::Forward => from.0 == a.0,
350            Self::Backward => from.0 == b.0,
351        }
352    }
353}
354
355#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
356pub struct Provenance {
357    pub source: String,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub layer: Option<String>,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub source_id: Option<String>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub license: Option<String>,
364}
365
366impl Provenance {
367    #[must_use]
368    pub fn fixture(source_id: impl Into<String>) -> Self {
369        Self {
370            source: "fixture".to_owned(),
371            layer: Some("mini-network".to_owned()),
372            source_id: Some(source_id.into()),
373            license: Some("CC0-fixture".to_owned()),
374        }
375    }
376}
377
378#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
379pub struct Vertex {
380    pub id: VertexId,
381    pub coord: crate::geo::Coord,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub junction: Option<crate::JunctionKey>,
384}
385
386#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
387pub struct TerrainEvidence {
388    pub terrain: Terrain,
389    pub confidence: f64,
390    pub rationale: String,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub provenance: Option<Provenance>,
393}
394
395#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
396#[serde(rename_all = "kebab-case")]
397pub enum CrossingKind {
398    Road,
399    Water,
400}
401
402impl CrossingKind {
403    #[must_use]
404    pub fn from_tag(tag: &str) -> Option<Self> {
405        match tag.trim().to_ascii_lowercase().as_str() {
406            "road" | "roads" | "pavement" | "highway" | "street" => Some(Self::Road),
407            "water" | "hydrology" | "stream" | "creek" | "river" | "ford" => Some(Self::Water),
408            _ => None,
409        }
410    }
411}
412
413#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
414pub struct CrossingEvidence {
415    pub kind: CrossingKind,
416    pub count: u32,
417    pub provenance: Provenance,
418}
419
420#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
421pub struct TurnBan {
422    pub via: VertexId,
423    pub from: EdgeId,
424    pub to: EdgeId,
425    pub provenance: Provenance,
426}
427
428#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
429pub struct GradeDistribution {
430    pub flat_m: f64,
431    pub rolling_m: f64,
432    pub steep_m: f64,
433    pub savage_m: f64,
434}
435
436impl GradeDistribution {
437    #[must_use]
438    pub fn add_segment(mut self, length_m: f64, abs_grade: f64) -> Self {
439        if abs_grade < 0.05 {
440            self.flat_m += length_m;
441        } else if abs_grade < 0.15 {
442            self.rolling_m += length_m;
443        } else if abs_grade < 0.30 {
444            self.steep_m += length_m;
445        } else {
446            self.savage_m += length_m;
447        }
448        self
449    }
450
451    #[must_use]
452    pub fn total_m(self) -> f64 {
453        self.flat_m + self.rolling_m + self.steep_m + self.savage_m
454    }
455}
456
457impl AddAssign for GradeDistribution {
458    fn add_assign(&mut self, rhs: Self) {
459        self.flat_m += rhs.flat_m;
460        self.rolling_m += rhs.rolling_m;
461        self.steep_m += rhs.steep_m;
462        self.savage_m += rhs.savage_m;
463    }
464}
465
466#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
467pub struct EdgeAttr {
468    pub length_m: f64,
469    pub ascent_m: f64,
470    pub descent_m: f64,
471    pub grade_abs_mean: f64,
472    #[serde(default)]
473    pub grade_abs_max: f64,
474    #[serde(default)]
475    pub sustained_steep_m: f64,
476    #[serde(default)]
477    pub grade_distribution: GradeDistribution,
478    /// Mean magnitude of the surrounding terrain slope in degrees. This is
479    /// distinct from grade along the trail and may be absent without a DEM.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub hill_slope_deg: Option<f64>,
482    #[serde(default)]
483    pub way_kind: WayKind,
484    #[serde(default)]
485    pub realm: WayRealm,
486    #[serde(default)]
487    pub geometry_claim: GeometryClaim,
488    #[serde(default)]
489    pub crossing_control: CrossingControl,
490    #[serde(default)]
491    pub standing: TrailStanding,
492    #[serde(default)]
493    pub marking: TrailMarking,
494    pub terrain: Terrain,
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub surface: Option<String>,
497    #[serde(default)]
498    pub terrain_confidence: f64,
499    #[serde(default)]
500    pub terrain_evidence: Vec<TerrainEvidence>,
501    pub access: Access,
502    #[serde(default)]
503    pub travel: EdgeTravel,
504    #[serde(default)]
505    pub access_confidence: f64,
506    #[serde(default)]
507    pub access_provenance: Vec<Provenance>,
508    #[serde(default)]
509    pub crossings: Vec<CrossingEvidence>,
510    pub road_exposure: f64,
511    pub confidence: f64,
512    #[serde(default)]
513    pub traversal: EdgeTraversal,
514    #[serde(default)]
515    pub seed_count: u32,
516    #[serde(default)]
517    pub popularity: f64,
518    #[serde(default)]
519    pub seed_provenance: Vec<Provenance>,
520    #[serde(default)]
521    pub elevation_provenance: Vec<Provenance>,
522    pub provenance: Vec<Provenance>,
523}
524
525#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
526pub struct Edge {
527    pub id: EdgeId,
528    pub a: VertexId,
529    pub b: VertexId,
530    pub geometry: LineString,
531    pub attr: EdgeAttr,
532}
533
534#[derive(Clone, Copy, Debug, PartialEq)]
535pub struct EdgeProjection {
536    pub edge: EdgeId,
537    pub coord: Coord,
538    pub progress_m: f64,
539    pub distance_m: f64,
540}
541
542impl Edge {
543    #[must_use]
544    pub fn other(&self, v: VertexId) -> Option<VertexId> {
545        if v == self.a {
546            Some(self.b)
547        } else if v == self.b {
548            Some(self.a)
549        } else {
550            None
551        }
552    }
553
554    #[must_use]
555    pub fn traverse(&self, from: VertexId) -> Option<VertexId> {
556        if !self.attr.travel.can_depart(from, self.a, self.b) {
557            return None;
558        }
559        self.other(from)
560    }
561
562    #[must_use]
563    pub fn oriented_geometry(&self, from: VertexId) -> LineString {
564        if from == self.a {
565            self.geometry.clone()
566        } else {
567            self.geometry.reversed()
568        }
569    }
570
571    #[must_use]
572    pub const fn traversal_from(&self, from: VertexId) -> TraversalEstimate {
573        self.attr.traversal.departing(self, from)
574    }
575}
576
577#[derive(Clone, Debug, PartialEq, Serialize)]
578pub struct WalkGraph {
579    pub vertices: Vec<Vertex>,
580    pub edges: Vec<Edge>,
581    #[serde(default)]
582    pub turn_bans: Vec<TurnBan>,
583    #[serde(skip_serializing)]
584    pub adjacency: Vec<Vec<EdgeId>>,
585}
586
587impl<'de> Deserialize<'de> for WalkGraph {
588    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
589    where
590        D: serde::Deserializer<'de>,
591    {
592        #[derive(Deserialize)]
593        struct StoredGraph {
594            vertices: Vec<Vertex>,
595            edges: Vec<Edge>,
596            #[serde(default)]
597            turn_bans: Vec<TurnBan>,
598        }
599
600        let stored = StoredGraph::deserialize(deserializer)?;
601        let mut graph = Self {
602            vertices: stored.vertices,
603            edges: stored.edges,
604            turn_bans: stored.turn_bans,
605            adjacency: Vec::new(),
606        };
607        for edge in &mut graph.edges {
608            HikingModel.apply(edge);
609        }
610        graph.validate().map_err(serde::de::Error::custom)?;
611        graph.rebuild_adjacency();
612        Ok(graph)
613    }
614}
615
616#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
617pub struct RouteSnapStats {
618    pub segment_count: usize,
619    pub snapped_segment_count: usize,
620    pub rejected_segment_count: usize,
621    #[serde(default)]
622    pub disconnected_transition_count: usize,
623    pub max_snap_m: f64,
624    pub mean_snap_m: f64,
625}
626
627#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
628#[serde(rename_all = "kebab-case")]
629pub enum CoverageGapKind {
630    BeyondNetwork,
631    DisconnectedNetwork,
632}
633
634#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
635pub struct CoverageGap {
636    pub kind: CoverageGapKind,
637    pub first_route_segment: usize,
638    pub last_route_segment: usize,
639    pub geometry: LineString,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub nearest_edge: Option<EdgeId>,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub nearest_distance_m: Option<f64>,
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub before_edge: Option<EdgeId>,
646    #[serde(default, skip_serializing_if = "Option::is_none")]
647    pub after_edge: Option<EdgeId>,
648}
649
650#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
651pub struct RouteCoverage {
652    pub edges: Vec<EdgeId>,
653    pub stats: RouteSnapStats,
654    pub gaps: Vec<CoverageGap>,
655}
656
657#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
658struct WalkState {
659    at: VertexId,
660    previous: Option<EdgeId>,
661}
662
663#[derive(Clone, Copy, Debug, PartialEq)]
664struct WalkFrontier {
665    cost_m: f64,
666    state: WalkState,
667}
668
669#[derive(Clone, Copy, Debug)]
670struct SnapAnchor {
671    edge: EdgeId,
672    entry: VertexId,
673    budget_m: f64,
674    route_segment: usize,
675}
676
677#[derive(Clone, Copy, Debug)]
678struct DisconnectedSpan {
679    from_segment: usize,
680    to_segment: usize,
681    before: EdgeId,
682    after: EdgeId,
683}
684
685#[derive(Clone, Copy)]
686struct EdgeEnvelope {
687    edge: EdgeId,
688    bounds: AABB<[f64; 2]>,
689}
690
691#[derive(Clone)]
692pub struct EdgeIndex {
693    tree: RTree<EdgeEnvelope>,
694}
695
696impl EdgeIndex {
697    #[must_use]
698    pub fn forge(graph: &WalkGraph) -> Self {
699        Self {
700            tree: edge_spatial_index(graph.edges.iter()),
701        }
702    }
703
704    #[must_use]
705    pub fn forge_allowed(graph: &WalkGraph, allowed: &[bool]) -> Self {
706        assert_eq!(allowed.len(), graph.edges.len());
707        Self {
708            tree: edge_spatial_index(
709                graph
710                    .edges
711                    .iter()
712                    .zip(allowed)
713                    .filter_map(|(edge, allowed)| allowed.then_some(edge)),
714            ),
715        }
716    }
717
718    #[must_use]
719    pub fn project(&self, graph: &WalkGraph, coord: Coord) -> Option<EdgeProjection> {
720        let (edge, distance_m) = indexed_nearest_edge(&graph.edges, &self.tree, coord)?;
721        let (_, progress_m, coord) = line_projection(&graph.edges[edge.0].geometry, coord)?;
722        Some(EdgeProjection {
723            edge,
724            coord,
725            progress_m,
726            distance_m,
727        })
728    }
729
730    /// Returns the nearest legal-looking geometric anchors in deterministic
731    /// order. Selection policy belongs to the caller; the spatial index only
732    /// supplies a bounded candidate set.
733    #[must_use]
734    pub fn candidates(
735        &self,
736        graph: &WalkGraph,
737        coord: Coord,
738        max_distance_m: f64,
739        limit: usize,
740    ) -> Vec<EdgeProjection> {
741        if !max_distance_m.is_finite() || max_distance_m < 0.0 || limit == 0 {
742            return Vec::new();
743        }
744        let latitude_radius = max_distance_m / 110_540.0;
745        let longitude_radius =
746            max_distance_m / (111_320.0 * coord.lat.to_radians().cos().abs().max(0.01));
747        let neighborhood = AABB::from_corners(
748            [coord.lon - longitude_radius, coord.lat - latitude_radius],
749            [coord.lon + longitude_radius, coord.lat + latitude_radius],
750        );
751        let mut candidates = self
752            .tree
753            .locate_in_envelope_intersecting(neighborhood)
754            .filter_map(|candidate| {
755                let (distance_m, progress_m, anchor) =
756                    line_projection(&graph.edges[candidate.edge.0].geometry, coord)?;
757                (distance_m <= max_distance_m).then_some(EdgeProjection {
758                    edge: candidate.edge,
759                    coord: anchor,
760                    progress_m,
761                    distance_m,
762                })
763            })
764            .collect::<Vec<_>>();
765        candidates.sort_by(|left, right| {
766            left.distance_m
767                .total_cmp(&right.distance_m)
768                .then_with(|| {
769                    edge_anchor_rank(&graph.edges[left.edge.0])
770                        .cmp(&edge_anchor_rank(&graph.edges[right.edge.0]))
771                })
772                .then_with(|| left.edge.cmp(&right.edge))
773                .then_with(|| left.progress_m.total_cmp(&right.progress_m))
774        });
775        candidates.truncate(limit);
776        candidates
777    }
778}
779
780impl RTreeObject for EdgeEnvelope {
781    type Envelope = AABB<[f64; 2]>;
782
783    fn envelope(&self) -> Self::Envelope {
784        self.bounds
785    }
786}
787
788impl Eq for WalkFrontier {}
789
790impl Ord for WalkFrontier {
791    fn cmp(&self, other: &Self) -> Ordering {
792        other
793            .cost_m
794            .total_cmp(&self.cost_m)
795            .then_with(|| other.state.cmp(&self.state))
796    }
797}
798
799impl PartialOrd for WalkFrontier {
800    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
801        Some(self.cmp(other))
802    }
803}
804
805impl WalkGraph {
806    #[must_use]
807    pub fn new(vertices: Vec<Vertex>, edges: Vec<Edge>) -> Self {
808        let mut graph = Self {
809            adjacency: vec![Vec::new(); vertices.len()],
810            vertices,
811            edges,
812            turn_bans: Vec::new(),
813        };
814        graph.rebuild_adjacency();
815        graph
816    }
817
818    pub fn rebuild_adjacency(&mut self) {
819        self.adjacency.clear();
820        self.adjacency.resize_with(self.vertices.len(), Vec::new);
821        for edge in &self.edges {
822            if edge.attr.travel.can_depart(edge.a, edge.a, edge.b) {
823                self.adjacency[edge.a.0].push(edge.id);
824            }
825            if edge.attr.travel.can_depart(edge.b, edge.a, edge.b) {
826                self.adjacency[edge.b.0].push(edge.id);
827            }
828        }
829    }
830
831    pub fn validate(&self) -> Result<()> {
832        for (index, vertex) in self.vertices.iter().enumerate() {
833            if vertex.id.0 != index {
834                return Err(TrailgenError::InvalidData(format!(
835                    "vertex slot {index} contains id {}",
836                    vertex.id.0
837                )));
838            }
839            if !valid_coord(vertex.coord) {
840                return Err(TrailgenError::InvalidData(format!(
841                    "vertex {index} has invalid coordinate"
842                )));
843            }
844        }
845        for (index, edge) in self.edges.iter().enumerate() {
846            if edge.id.0 != index {
847                return Err(TrailgenError::InvalidData(format!(
848                    "edge slot {index} contains id {}",
849                    edge.id.0
850                )));
851            }
852            if edge.a == edge.b
853                || edge.a.0 >= self.vertices.len()
854                || edge.b.0 >= self.vertices.len()
855            {
856                return Err(TrailgenError::InvalidData(format!(
857                    "edge {index} has invalid endpoints {} and {}",
858                    edge.a.0, edge.b.0
859                )));
860            }
861            if edge.geometry.points.len() < 2
862                || edge
863                    .geometry
864                    .points
865                    .iter()
866                    .copied()
867                    .any(|coord| !valid_coord(coord))
868                || !edge.attr.length_m.is_finite()
869                || edge.attr.length_m <= 0.0
870                || !edge.attr.traversal.valid()
871            {
872                return Err(TrailgenError::InvalidData(format!(
873                    "edge {index} has invalid geometry or length"
874                )));
875            }
876        }
877        for ban in &self.turn_bans {
878            if ban.via.0 >= self.vertices.len()
879                || ban.from.0 >= self.edges.len()
880                || ban.to.0 >= self.edges.len()
881                || self.edges[ban.from.0]
882                    .other(ban.via)
883                    .is_none_or(|_| self.edges[ban.to.0].other(ban.via).is_none())
884            {
885                return Err(TrailgenError::InvalidData(format!(
886                    "turn ban e{}→v{}→e{} does not reference incident graph members",
887                    ban.from.0, ban.via.0, ban.to.0
888                )));
889            }
890        }
891        Ok(())
892    }
893
894    #[must_use]
895    pub fn nearest_vertex(&self, coord: crate::geo::Coord) -> Option<VertexId> {
896        self.nearest_vertex_with_distance(coord)
897            .map(|(vertex, _)| vertex)
898    }
899
900    #[must_use]
901    pub fn nearest_vertex_with_distance(
902        &self,
903        coord: crate::geo::Coord,
904    ) -> Option<(VertexId, f64)> {
905        self.vertices
906            .iter()
907            .min_by(|a, b| {
908                a.coord
909                    .planar_distance2(coord)
910                    .total_cmp(&b.coord.planar_distance2(coord))
911            })
912            .map(|v| (v.id, v.coord.haversine_m(coord)))
913    }
914
915    #[must_use]
916    pub fn snap_line_edges(&self, line: &crate::geo::LineString) -> Vec<EdgeId> {
917        self.trace_coverage(line, f64::INFINITY).edges
918    }
919
920    #[must_use]
921    pub fn trace_coverage(&self, line: &crate::geo::LineString, max_snap_m: f64) -> RouteCoverage {
922        let edge_index = edge_spatial_index(self.edges.iter());
923        let mut anchors = Vec::new();
924        let mut gaps = Vec::new();
925        let mut segment_count = 0usize;
926        let mut snapped_segment_count = 0usize;
927        let mut rejected_segment_count = 0usize;
928        let mut max_observed_m = 0.0f64;
929        let mut sum_observed_m = 0.0f64;
930        let mut observed_count = 0.0f64;
931        for (route_segment, w) in line.points.windows(2).enumerate() {
932            segment_count += 1;
933            let mid = w[0].lerp(w[1], 0.5);
934            let Some((edge_id, snap_m)) = indexed_nearest_edge(&self.edges, &edge_index, mid)
935            else {
936                rejected_segment_count += 1;
937                gaps.push(CoverageGap {
938                    kind: CoverageGapKind::BeyondNetwork,
939                    first_route_segment: route_segment,
940                    last_route_segment: route_segment,
941                    geometry: LineString::unchecked(w.to_vec()),
942                    nearest_edge: None,
943                    nearest_distance_m: None,
944                    before_edge: None,
945                    after_edge: None,
946                });
947                continue;
948            };
949            max_observed_m = max_observed_m.max(snap_m);
950            sum_observed_m += snap_m;
951            observed_count += 1.0;
952            if snap_m > max_snap_m {
953                rejected_segment_count += 1;
954                gaps.push(CoverageGap {
955                    kind: CoverageGapKind::BeyondNetwork,
956                    first_route_segment: route_segment,
957                    last_route_segment: route_segment,
958                    geometry: LineString::unchecked(w.to_vec()),
959                    nearest_edge: Some(edge_id),
960                    nearest_distance_m: Some(snap_m),
961                    before_edge: None,
962                    after_edge: None,
963                });
964                continue;
965            }
966            snapped_segment_count += 1;
967            if anchors
968                .last()
969                .is_none_or(|last: &SnapAnchor| last.edge != edge_id)
970            {
971                let observation_m = w[0].haversine_m(w[1]);
972                let snap_allowance_m = if max_snap_m.is_finite() {
973                    max_snap_m * 2.0
974                } else {
975                    500.0
976                };
977                let edge = &self.edges[edge_id.0];
978                let start_progress_m = line_progress_m(&edge.geometry, w[0]);
979                let end_progress_m = line_progress_m(&edge.geometry, w[1]);
980                anchors.push(SnapAnchor {
981                    edge: edge_id,
982                    entry: if end_progress_m >= start_progress_m {
983                        edge.a
984                    } else {
985                        edge.b
986                    },
987                    budget_m: observation_m.mul_add(3.0, snap_allowance_m).max(50.0),
988                    route_segment,
989                });
990            }
991        }
992        self.collapse_shadow_anchors(&mut anchors, line, max_snap_m);
993        let (edges, disconnected) = self.connect_snap_anchors(&anchors);
994        gaps.extend(disconnected.iter().map(|span| CoverageGap {
995            kind: CoverageGapKind::DisconnectedNetwork,
996            first_route_segment: span.from_segment.min(span.to_segment),
997            last_route_segment: span.from_segment.max(span.to_segment),
998            geometry: coverage_geometry(
999                line,
1000                span.from_segment.min(span.to_segment),
1001                span.from_segment.max(span.to_segment),
1002            ),
1003            nearest_edge: None,
1004            nearest_distance_m: None,
1005            before_edge: Some(span.before),
1006            after_edge: Some(span.after),
1007        }));
1008        RouteCoverage {
1009            edges,
1010            stats: coverage_stats(
1011                segment_count,
1012                snapped_segment_count,
1013                rejected_segment_count,
1014                disconnected.len(),
1015                max_observed_m,
1016                sum_observed_m,
1017                observed_count,
1018            ),
1019            gaps: coalesce_coverage_gaps(gaps, line),
1020        }
1021    }
1022
1023    fn collapse_shadow_anchors(
1024        &self,
1025        anchors: &mut Vec<SnapAnchor>,
1026        line: &crate::geo::LineString,
1027        max_snap_m: f64,
1028    ) {
1029        if !max_snap_m.is_finite() {
1030            return;
1031        }
1032        let mut first = 0;
1033        while first + 2 < anchors.len() {
1034            let shadowed = ((first + 2)..anchors.len()).rev().find(|&last| {
1035                anchors[first].edge == anchors[last].edge
1036                    && anchors[first].entry == anchors[last].entry
1037                    && route_span_fits_edge(
1038                        &self.edges[anchors[first].edge.0],
1039                        line,
1040                        anchors[first].route_segment,
1041                        anchors[last].route_segment,
1042                        max_snap_m,
1043                    )
1044            });
1045            if let Some(last) = shadowed {
1046                anchors.drain(first + 1..=last);
1047            } else {
1048                first += 1;
1049            }
1050        }
1051    }
1052
1053    fn connect_snap_anchors(&self, anchors: &[SnapAnchor]) -> (Vec<EdgeId>, Vec<DisconnectedSpan>) {
1054        let Some(first_anchor) = anchors.first().copied() else {
1055            return (Vec::new(), Vec::new());
1056        };
1057        let first_id = first_anchor.edge;
1058        let Some(first) = self.edges.get(first_id.0) else {
1059            return (
1060                Vec::new(),
1061                vec![DisconnectedSpan {
1062                    from_segment: first_anchor.route_segment,
1063                    to_segment: first_anchor.route_segment,
1064                    before: first_id,
1065                    after: first_id,
1066                }],
1067            );
1068        };
1069        let start = first_anchor.entry;
1070        if first.traverse(start).is_none() {
1071            return (
1072                Vec::new(),
1073                vec![DisconnectedSpan {
1074                    from_segment: first_anchor.route_segment,
1075                    to_segment: first_anchor.route_segment,
1076                    before: first_id,
1077                    after: first_id,
1078                }],
1079            );
1080        }
1081        let mut edges = vec![first_id];
1082        let mut at = first
1083            .traverse(start)
1084            .expect("start was filtered as traversable");
1085        let mut previous = first_id;
1086        let mut previous_anchor = first_anchor;
1087        let mut disconnected = Vec::new();
1088        for anchor in &anchors[1..] {
1089            let target = anchor.edge;
1090            if at == anchor.entry
1091                && self.turn_allowed(Some(previous), at, target)
1092                && let Some(next) = self.edges[target.0].traverse(at)
1093            {
1094                edges.push(target);
1095                previous = target;
1096                at = next;
1097                previous_anchor = *anchor;
1098                continue;
1099            }
1100            let Some(connector) =
1101                self.shortest_connector(at, previous, target, anchor.entry, anchor.budget_m)
1102            else {
1103                self.record_disconnection(&mut disconnected, previous_anchor, *anchor);
1104                if let Some(next) = self.edges[target.0].traverse(anchor.entry) {
1105                    edges.clear();
1106                    edges.push(target);
1107                    at = next;
1108                    previous = target;
1109                    previous_anchor = *anchor;
1110                    continue;
1111                }
1112                previous_anchor = *anchor;
1113                continue;
1114            };
1115            for edge_id in connector {
1116                at = self.edges[edge_id.0]
1117                    .traverse(at)
1118                    .expect("connector reconstruction preserves direction");
1119                previous = edge_id;
1120                edges.push(edge_id);
1121            }
1122            if at == anchor.entry
1123                && self.turn_allowed(Some(previous), at, target)
1124                && let Some(next) = self.edges[target.0].traverse(at)
1125            {
1126                edges.push(target);
1127                previous = target;
1128                at = next;
1129            } else {
1130                self.record_disconnection(&mut disconnected, previous_anchor, *anchor);
1131            }
1132            previous_anchor = *anchor;
1133        }
1134        (edges, disconnected)
1135    }
1136
1137    fn record_disconnection(
1138        &self,
1139        disconnected: &mut Vec<DisconnectedSpan>,
1140        before: SnapAnchor,
1141        after: SnapAnchor,
1142    ) {
1143        if !self.edges_connect_locally(before.edge, after.edge, before.budget_m.max(after.budget_m))
1144        {
1145            disconnected.push(DisconnectedSpan {
1146                from_segment: before.route_segment,
1147                to_segment: after.route_segment,
1148                before: before.edge,
1149                after: after.edge,
1150            });
1151        }
1152    }
1153
1154    fn edges_connect_locally(&self, before: EdgeId, after: EdgeId, budget_m: f64) -> bool {
1155        let before_edge = &self.edges[before.0];
1156        let after_edge = &self.edges[after.0];
1157        [before_edge.a, before_edge.b]
1158            .into_iter()
1159            .filter_map(|entry| before_edge.traverse(entry))
1160            .any(|at| {
1161                [after_edge.a, after_edge.b]
1162                    .into_iter()
1163                    .filter(|entry| after_edge.traverse(*entry).is_some())
1164                    .any(|entry| {
1165                        (at == entry && self.turn_allowed(Some(before), at, after))
1166                            || self
1167                                .shortest_connector(at, before, after, entry, budget_m)
1168                                .is_some()
1169                    })
1170            })
1171    }
1172
1173    fn shortest_connector(
1174        &self,
1175        from: VertexId,
1176        previous: EdgeId,
1177        target: EdgeId,
1178        target_entry: VertexId,
1179        max_distance_m: f64,
1180    ) -> Option<Vec<EdgeId>> {
1181        let origin = WalkState {
1182            at: from,
1183            previous: Some(previous),
1184        };
1185        let mut frontier = BinaryHeap::from([WalkFrontier {
1186            cost_m: 0.0,
1187            state: origin,
1188        }]);
1189        let mut distance = BTreeMap::from([(origin, 0.0)]);
1190        let mut predecessor = BTreeMap::<WalkState, (WalkState, EdgeId)>::new();
1191        while let Some(WalkFrontier { cost_m, state }) = frontier.pop() {
1192            if cost_m > max_distance_m
1193                || distance
1194                    .get(&state)
1195                    .is_some_and(|best| cost_m > *best + f64::EPSILON)
1196            {
1197                continue;
1198            }
1199            if state.at == target_entry
1200                && self.turn_allowed(state.previous, state.at, target)
1201                && self.edges[target.0].traverse(state.at).is_some()
1202            {
1203                let mut path = Vec::new();
1204                let mut cursor = state;
1205                while cursor != origin {
1206                    let (prior, edge) = predecessor.get(&cursor).copied()?;
1207                    path.push(edge);
1208                    cursor = prior;
1209                }
1210                path.reverse();
1211                return Some(path);
1212            }
1213            for edge_id in self.adjacency.get(state.at.0)?.iter().copied() {
1214                if edge_id == target || !self.turn_allowed(state.previous, state.at, edge_id) {
1215                    continue;
1216                }
1217                let edge = self.edges.get(edge_id.0)?;
1218                let Some(to) = edge.traverse(state.at) else {
1219                    continue;
1220                };
1221                let next_cost = cost_m + edge.attr.length_m;
1222                if next_cost > max_distance_m {
1223                    continue;
1224                }
1225                let next = WalkState {
1226                    at: to,
1227                    previous: Some(edge_id),
1228                };
1229                if distance.get(&next).is_none_or(|best| next_cost < *best) {
1230                    distance.insert(next, next_cost);
1231                    predecessor.insert(next, (state, edge_id));
1232                    frontier.push(WalkFrontier {
1233                        cost_m: next_cost,
1234                        state: next,
1235                    });
1236                }
1237            }
1238        }
1239        None
1240    }
1241
1242    #[must_use]
1243    pub fn nearest_edge(&self, coord: crate::geo::Coord) -> Option<EdgeId> {
1244        self.nearest_edge_with_distance(coord).map(|(edge, _)| edge)
1245    }
1246
1247    #[must_use]
1248    pub fn nearest_edge_with_distance(&self, coord: crate::geo::Coord) -> Option<(EdgeId, f64)> {
1249        self.project_onto_edge(coord)
1250            .map(|projection| (projection.edge, projection.distance_m))
1251    }
1252
1253    #[must_use]
1254    pub fn project_onto_edge(&self, coord: Coord) -> Option<EdgeProjection> {
1255        self.edges
1256            .iter()
1257            .filter_map(|edge| {
1258                line_projection(&edge.geometry, coord).map(|(distance_m, progress_m, coord)| {
1259                    EdgeProjection {
1260                        edge: edge.id,
1261                        coord,
1262                        progress_m,
1263                        distance_m,
1264                    }
1265                })
1266            })
1267            .min_by(|left, right| left.distance_m.total_cmp(&right.distance_m))
1268    }
1269
1270    #[must_use]
1271    pub fn snapped_line_start(
1272        &self,
1273        line: &crate::geo::LineString,
1274        edges: &[EdgeId],
1275    ) -> Option<VertexId> {
1276        let first = self.edges.get(edges.first()?.0)?;
1277        let line_start = line.start();
1278        [first.a, first.b]
1279            .into_iter()
1280            .filter(|start| self.walk_edges(*start, edges).is_some())
1281            .min_by(|a, b| {
1282                self.vertices[a.0]
1283                    .coord
1284                    .planar_distance2(line_start)
1285                    .total_cmp(&self.vertices[b.0].coord.planar_distance2(line_start))
1286            })
1287    }
1288
1289    #[must_use]
1290    pub fn walk_edges(&self, start: VertexId, edges: &[EdgeId]) -> Option<VertexId> {
1291        let mut at = start;
1292        let mut previous = None;
1293        for edge_id in edges {
1294            if !self.turn_allowed(previous, at, *edge_id) {
1295                return None;
1296            }
1297            at = self.edges.get(edge_id.0)?.traverse(at)?;
1298            previous = Some(*edge_id);
1299        }
1300        Some(at)
1301    }
1302
1303    #[must_use]
1304    pub fn turn_allowed(&self, from: Option<EdgeId>, via: VertexId, to: EdgeId) -> bool {
1305        from.is_none_or(|from| {
1306            !self
1307                .turn_bans
1308                .iter()
1309                .any(|ban| ban.via == via && ban.from == from && ban.to == to)
1310        })
1311    }
1312
1313    pub fn apply_seed_hints(&mut self, seed: &crate::seed::SeedRoute) {
1314        for edge_id in &seed.snapped_edges {
1315            let Some(edge) = self.edges.get_mut(edge_id.0) else {
1316                continue;
1317            };
1318            if !edge.attr.seed_provenance.contains(&seed.provenance) {
1319                edge.attr.seed_count = edge.attr.seed_count.saturating_add(1);
1320                edge.attr.seed_provenance.push(seed.provenance.clone());
1321            }
1322            edge.attr.popularity = f64::from(edge.attr.seed_count).ln_1p();
1323            edge.attr.confidence = edge.attr.confidence.max(0.82);
1324        }
1325    }
1326}
1327
1328fn coverage_geometry(line: &LineString, first: usize, last: usize) -> LineString {
1329    let start = first.min(line.points.len().saturating_sub(2));
1330    let end = last
1331        .saturating_add(1)
1332        .min(line.points.len().saturating_sub(1));
1333    LineString::unchecked(line.points[start..=end.max(start + 1)].to_vec())
1334}
1335
1336fn coalesce_coverage_gaps(mut gaps: Vec<CoverageGap>, line: &LineString) -> Vec<CoverageGap> {
1337    gaps.sort_by_key(|gap| (gap.first_route_segment, gap.last_route_segment));
1338    let mut merged = Vec::<CoverageGap>::new();
1339    for gap in gaps {
1340        let Some(previous) = merged.last_mut() else {
1341            merged.push(gap);
1342            continue;
1343        };
1344        if previous.kind != gap.kind
1345            || gap.first_route_segment > previous.last_route_segment.saturating_add(1)
1346        {
1347            merged.push(gap);
1348            continue;
1349        }
1350        previous.last_route_segment = previous.last_route_segment.max(gap.last_route_segment);
1351        previous.geometry = coverage_geometry(
1352            line,
1353            previous.first_route_segment,
1354            previous.last_route_segment,
1355        );
1356        if previous.nearest_edge != gap.nearest_edge {
1357            previous.nearest_edge = None;
1358        }
1359        if previous.kind == CoverageGapKind::DisconnectedNetwork {
1360            previous.after_edge = gap.after_edge;
1361        } else {
1362            if previous.before_edge != gap.before_edge {
1363                previous.before_edge = None;
1364            }
1365            if previous.after_edge != gap.after_edge {
1366                previous.after_edge = None;
1367            }
1368        }
1369        previous.nearest_distance_m = match (previous.nearest_distance_m, gap.nearest_distance_m) {
1370            (Some(left), Some(right)) => Some(left.max(right)),
1371            _ => None,
1372        };
1373    }
1374    merged
1375}
1376
1377fn valid_coord(coord: Coord) -> bool {
1378    coord.lon.is_finite()
1379        && coord.lat.is_finite()
1380        && (-180.0..=180.0).contains(&coord.lon)
1381        && (-90.0..=90.0).contains(&coord.lat)
1382        && coord.ele.is_none_or(f64::is_finite)
1383}
1384
1385fn edge_distance_m(edge: &Edge, coord: Coord) -> f64 {
1386    line_projection(&edge.geometry, coord).map_or(f64::INFINITY, |projection| projection.0)
1387}
1388
1389fn edge_spatial_index<'a>(edges: impl IntoIterator<Item = &'a Edge>) -> RTree<EdgeEnvelope> {
1390    RTree::bulk_load(
1391        edges
1392            .into_iter()
1393            .map(|edge| {
1394                let (west, south, east, north) = edge.geometry.points.iter().fold(
1395                    (
1396                        f64::INFINITY,
1397                        f64::INFINITY,
1398                        f64::NEG_INFINITY,
1399                        f64::NEG_INFINITY,
1400                    ),
1401                    |(west, south, east, north), point| {
1402                        (
1403                            west.min(point.lon),
1404                            south.min(point.lat),
1405                            east.max(point.lon),
1406                            north.max(point.lat),
1407                        )
1408                    },
1409                );
1410                EdgeEnvelope {
1411                    edge: edge.id,
1412                    bounds: AABB::from_corners([west, south], [east, north]),
1413                }
1414            })
1415            .collect(),
1416    )
1417}
1418
1419fn coverage_stats(
1420    segment_count: usize,
1421    snapped_segment_count: usize,
1422    rejected_segment_count: usize,
1423    disconnected_transition_count: usize,
1424    max_snap_m: f64,
1425    sum_snap_m: f64,
1426    observed_count: f64,
1427) -> RouteSnapStats {
1428    RouteSnapStats {
1429        segment_count,
1430        snapped_segment_count,
1431        rejected_segment_count,
1432        disconnected_transition_count,
1433        max_snap_m,
1434        mean_snap_m: if observed_count <= f64::EPSILON {
1435            0.0
1436        } else {
1437            sum_snap_m / observed_count
1438        },
1439    }
1440}
1441
1442fn indexed_nearest_edge(
1443    edges: &[Edge],
1444    index: &RTree<EdgeEnvelope>,
1445    coord: Coord,
1446) -> Option<(EdgeId, f64)> {
1447    for radius_m in std::iter::successors(Some(32.0), |radius| Some(radius * 4.0)).take(11) {
1448        let latitude_radius = radius_m / 110_540.0;
1449        let longitude_radius =
1450            radius_m / (111_320.0 * coord.lat.to_radians().cos().abs().max(0.01));
1451        let neighborhood = AABB::from_corners(
1452            [coord.lon - longitude_radius, coord.lat - latitude_radius],
1453            [coord.lon + longitude_radius, coord.lat + latitude_radius],
1454        );
1455        let nearest = index
1456            .locate_in_envelope_intersecting(neighborhood)
1457            .map(|candidate| {
1458                let distance_m = edge_distance_m(&edges[candidate.edge.0], coord);
1459                (candidate.edge, distance_m)
1460            })
1461            .min_by(|left, right| {
1462                left.1
1463                    .total_cmp(&right.1)
1464                    .then_with(|| {
1465                        edge_anchor_rank(&edges[left.0.0]).cmp(&edge_anchor_rank(&edges[right.0.0]))
1466                    })
1467                    .then_with(|| left.0.cmp(&right.0))
1468            });
1469        if nearest.is_some_and(|(_, distance_m)| distance_m <= radius_m) {
1470            return nearest;
1471        }
1472    }
1473    edges
1474        .iter()
1475        .map(|edge| (edge.id, edge_distance_m(edge, coord)))
1476        .min_by(|left, right| {
1477            left.1
1478                .total_cmp(&right.1)
1479                .then_with(|| {
1480                    edge_anchor_rank(&edges[left.0.0]).cmp(&edge_anchor_rank(&edges[right.0.0]))
1481                })
1482                .then_with(|| left.0.cmp(&right.0))
1483        })
1484}
1485
1486const fn edge_anchor_rank(edge: &Edge) -> (u8, u8, u8) {
1487    let access = match edge.attr.access {
1488        Access::Open => 0,
1489        Access::Unknown | Access::Restricted => 1,
1490        Access::Closed | Access::Private => 2,
1491    };
1492    let geometry = match edge.attr.geometry_claim {
1493        GeometryClaim::Surveyed => 0,
1494        GeometryClaim::CenterlineProxy => 1,
1495    };
1496    let function = match edge.attr.way_kind {
1497        WayKind::Path
1498        | WayKind::Footway
1499        | WayKind::Sidewalk
1500        | WayKind::Crossing
1501        | WayKind::PedestrianStreet
1502        | WayKind::Steps
1503        | WayKind::Bridleway
1504        | WayKind::Bushwhack
1505        | WayKind::Cycleway => 0,
1506        WayKind::Track => 1,
1507        WayKind::ServiceRoad => 2,
1508        WayKind::Roadway | WayKind::Unknown => 3,
1509    };
1510    (access, geometry, function)
1511}
1512
1513fn route_span_fits_edge(
1514    edge: &Edge,
1515    line: &LineString,
1516    first_segment: usize,
1517    last_segment: usize,
1518    max_snap_m: f64,
1519) -> bool {
1520    line.points
1521        .windows(2)
1522        .enumerate()
1523        .skip(first_segment)
1524        .take(last_segment.saturating_sub(first_segment) + 1)
1525        .all(|(_, segment)| edge_distance_m(edge, segment[0].lerp(segment[1], 0.5)) <= max_snap_m)
1526}
1527
1528fn line_progress_m(line: &LineString, point: Coord) -> f64 {
1529    line_projection(line, point).map_or(0.0, |projection| projection.1)
1530}
1531
1532pub(crate) fn line_projection(line: &LineString, point: Coord) -> Option<(f64, f64, Coord)> {
1533    let mut traversed_m = 0.0;
1534    let mut nearest = None::<(f64, f64, Coord)>;
1535    for segment in line.points.windows(2) {
1536        let length_m = segment[0].haversine_m(segment[1]);
1537        let (distance_m, interpolation) = segment_projection(segment[0], segment[1], point);
1538        if nearest.is_none_or(|nearest| distance_m < nearest.0) {
1539            nearest = Some((
1540                distance_m,
1541                length_m.mul_add(interpolation, traversed_m),
1542                segment[0].lerp(segment[1], interpolation),
1543            ));
1544        }
1545        traversed_m += length_m;
1546    }
1547    nearest
1548}
1549
1550fn segment_projection(head: Coord, tail: Coord, point: Coord) -> (f64, f64) {
1551    let latitude_scale = point.lat.to_radians().cos();
1552    let meters_per_lon = 111_320.0 * latitude_scale;
1553    let meters_per_lat = 110_540.0;
1554    let head_x = (head.lon - point.lon) * meters_per_lon;
1555    let head_y = (head.lat - point.lat) * meters_per_lat;
1556    let tail_x = (tail.lon - point.lon) * meters_per_lon;
1557    let tail_y = (tail.lat - point.lat) * meters_per_lat;
1558    let span_x = tail_x - head_x;
1559    let span_y = tail_y - head_y;
1560    let span_len2 = span_x.mul_add(span_x, span_y * span_y);
1561    if span_len2 <= f64::EPSILON {
1562        return (head_x.hypot(head_y), 0.0);
1563    }
1564    let interpolation = (-(head_x * span_x + head_y * span_y) / span_len2).clamp(0.0, 1.0);
1565    (
1566        (head_x + span_x * interpolation).hypot(head_y + span_y * interpolation),
1567        interpolation,
1568    )
1569}