Skip to main content

trailgen_core/
trail.rs

1use crate::{
2    Access, Coord, Edge, EdgeAttr, EdgeId, EdgeIndex, EdgeProjection, GradeDistribution,
3    HikingModel, LineString, LoopConstraints, Route, RouteShape, Terrain, TrailgenError, TurnBan,
4    Vertex, VertexId, WalkGraph,
5};
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    sync::Arc,
10};
11
12pub const DEFAULT_ROAD_AVERSION: f64 = 2.0;
13
14#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct SupportPoint(Coord);
17
18impl SupportPoint {
19    pub fn forge(coord: Coord) -> Option<Self> {
20        (coord.lon.is_finite()
21            && coord.lat.is_finite()
22            && (-180.0..=180.0).contains(&coord.lon)
23            && (-85.0..=85.0).contains(&coord.lat)
24            && coord.ele.is_none_or(f64::is_finite))
25        .then_some(Self(coord))
26    }
27
28    #[must_use]
29    pub const fn coord(self) -> Coord {
30        self.0
31    }
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct RoutingLaw {
37    #[serde(default = "default_road_aversion", alias = "road_penalty")]
38    pub road_aversion: f64,
39}
40
41impl Default for RoutingLaw {
42    fn default() -> Self {
43        Self {
44            road_aversion: DEFAULT_ROAD_AVERSION,
45        }
46    }
47}
48
49impl RoutingLaw {
50    pub fn validate(self) -> crate::Result<()> {
51        if !self.road_aversion.is_finite() || self.road_aversion < 0.0 {
52            return Err(TrailgenError::InvalidData(
53                "road aversion must be finite and nonnegative".to_owned(),
54            ));
55        }
56        Ok(())
57    }
58
59    #[must_use]
60    pub fn edge_cost(self, graph: &WalkGraph, edge: EdgeId) -> Option<f64> {
61        let attr = &graph.edges[edge.0].attr;
62        if matches!(attr.access, Access::Closed | Access::Private) {
63            return None;
64        }
65        let road =
66            attr.road_exposure
67                .clamp(0.0, 1.0)
68                .max(if matches!(attr.terrain, Terrain::Road) {
69                    1.0
70                } else {
71                    0.0
72                });
73        Some(attr.length_m * self.road_aversion.mul_add(road, 1.0))
74    }
75}
76
77const fn default_road_aversion() -> f64 {
78    DEFAULT_ROAD_AVERSION
79}
80
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct Trail {
84    pub shape: RouteShape,
85    pub support_points: Vec<SupportPoint>,
86    #[serde(default)]
87    pub routing: RoutingLaw,
88}
89
90impl Trail {
91    pub fn forge(
92        shape: RouteShape,
93        support_points: Vec<SupportPoint>,
94        routing: RoutingLaw,
95    ) -> crate::Result<Self> {
96        let trail = Self {
97            shape,
98            support_points,
99            routing,
100        };
101        trail.validate()?;
102        Ok(trail)
103    }
104
105    pub fn validate(&self) -> crate::Result<()> {
106        self.routing.validate()?;
107        if self.shape == RouteShape::FigureEight {
108            return Err(TrailgenError::InvalidData(
109                "figure-eight support topology is not defined".to_owned(),
110            ));
111        }
112        if self.support_points.len() < 2 {
113            return Err(TrailgenError::InvalidData(
114                "a trail needs a trailhead and at least one further support point".to_owned(),
115            ));
116        }
117        if self
118            .support_points
119            .iter()
120            .any(|point| SupportPoint::forge(point.coord()).is_none())
121        {
122            return Err(TrailgenError::InvalidData(
123                "trail contains an invalid support point".to_owned(),
124            ));
125        }
126        Ok(())
127    }
128
129    /// Recovers a compact, lossless support design. Globally shortest spans
130    /// remain single legs; an irreducible non-shortest edge receives an
131    /// interior support so the design compels that physical segment.
132    #[must_use]
133    pub fn infer(graph: &WalkGraph, route: &Route, routing: RoutingLaw) -> Option<Self> {
134        routing.validate().ok()?;
135        let vertices = route_vertices(graph, route)?;
136        let n = route.edges.len();
137        if n == 0 {
138            return None;
139        }
140        let points = match route.metrics.shape {
141            RouteShape::OutAndBack => {
142                let split = n / 2;
143                if !n.is_multiple_of(2)
144                    || route.edges[..split]
145                        != route.edges[split..]
146                            .iter()
147                            .rev()
148                            .copied()
149                            .collect::<Vec<_>>()
150                {
151                    return None;
152                }
153                let mut points = Vec::new();
154                compress_arc(graph, route, &vertices, routing, 0, split, &mut points)?;
155                points.push(vertex_support(graph, vertices[split])?);
156                points
157            }
158            RouteShape::Loop => {
159                if vertices[n] != route.start || n < 2 {
160                    return None;
161                }
162                let mut points = Vec::new();
163                let split = n / 2;
164                compress_arc(graph, route, &vertices, routing, 0, split, &mut points)?;
165                compress_arc(graph, route, &vertices, routing, split, n, &mut points)?;
166                points
167            }
168            RouteShape::Open => {
169                let mut points = Vec::new();
170                compress_arc(graph, route, &vertices, routing, 0, n, &mut points)?;
171                points.push(vertex_support(graph, vertices[n])?);
172                points
173            }
174            RouteShape::FigureEight => return None,
175        };
176        Self::forge(route.metrics.shape, points, routing).ok()
177    }
178
179    pub fn realize(
180        &self,
181        name: impl Into<String>,
182        graph: &WalkGraph,
183        constraints: &LoopConstraints,
184        max_snap_m: f64,
185    ) -> crate::Result<TrailRealization> {
186        let index = EdgeIndex::forge(graph);
187        let router = crate::WalkRouter::forge(graph);
188        self.realize_indexed(name, graph, &index, &router, constraints, max_snap_m)
189    }
190
191    pub fn realize_indexed(
192        &self,
193        name: impl Into<String>,
194        graph: &WalkGraph,
195        index: &EdgeIndex,
196        router: &crate::WalkRouter,
197        constraints: &LoopConstraints,
198        max_snap_m: f64,
199    ) -> crate::Result<TrailRealization> {
200        self.validate()?;
201        if !max_snap_m.is_finite() || max_snap_m <= 0.0 {
202            return Err(TrailgenError::InvalidData(
203                "support-point snap distance must be positive".to_owned(),
204            ));
205        }
206        let anchors = bind_projections(graph, index, &self.support_points, max_snap_m)?;
207        let RoutedDesign {
208            spans,
209            support_span_offsets,
210        } = self.strike_spans(graph, router, &anchors)?;
211        let realized = materialize_walk(graph, &anchors, &spans, &support_span_offsets)?;
212        let start = realized.bindings[0].vertex;
213        let end = realized
214            .graph
215            .walk_edges(start, &realized.edges)
216            .ok_or_else(|| {
217                TrailgenError::InvalidData(
218                    "support points induce an illegal directed trail".to_owned(),
219                )
220            })?;
221        let route = Route::from_edges(name, &realized.graph, start, realized.edges, constraints);
222        if !walk_fulfills_design(self.shape, route.metrics.shape, start == end) {
223            return Err(TrailgenError::ShapeMismatch {
224                actual: route.metrics.shape,
225                expected: self.shape,
226            });
227        }
228        Ok(TrailRealization {
229            trail: Self {
230                shape: self.shape,
231                support_points: realized
232                    .bindings
233                    .iter()
234                    .map(|binding| binding.anchor)
235                    .collect(),
236                routing: self.routing,
237            },
238            bindings: realized.bindings,
239            route,
240            walk: Arc::new(realized.graph),
241            support_offsets: realized.support_offsets,
242            source_spans: spans,
243            support_span_offsets,
244        })
245    }
246
247    fn strike_spans(
248        &self,
249        graph: &WalkGraph,
250        router: &crate::WalkRouter,
251        anchors: &[BoundProjection],
252    ) -> crate::Result<RoutedDesign> {
253        let mut workspace = router.workspace(graph);
254        let mut forge = PathForge {
255            router,
256            workspace: &mut workspace,
257            graph,
258            law: self.routing,
259        };
260        let mut spans = Vec::new();
261        let mut support_span_offsets = vec![0];
262        let mut previous = None;
263        for targets in anchors.windows(2) {
264            let leg = forge
265                .route(
266                    targets[0].projection,
267                    targets[1].projection,
268                    previous,
269                    PathVeto::default(),
270                )
271                .ok_or_else(|| {
272                    TrailgenError::InvalidData(
273                        "no lawful trail connects consecutive support points".to_owned(),
274                    )
275                })?;
276            previous = leg.last().map(|span| span.edge).or(previous);
277            spans.extend(leg);
278            support_span_offsets.push(spans.len());
279        }
280        match self.shape {
281            RouteShape::Open => {}
282            RouteShape::OutAndBack => {
283                let mut reverse = spans
284                    .iter()
285                    .rev()
286                    .map(|span| span.reversed())
287                    .collect::<Vec<_>>();
288                spans.append(&mut reverse);
289            }
290            RouteShape::Loop => {
291                let forbidden_edges = spans.iter().map(|span| span.edge).collect::<BTreeSet<_>>();
292                let forbidden_vertices = walked_span_vertices(graph, &spans);
293                let return_path = forge
294                    .route(
295                        anchors.last().expect("validated anchors").projection,
296                        anchors[0].projection,
297                        previous,
298                        PathVeto {
299                            edges: Some(&forbidden_edges),
300                            vertices: Some(&forbidden_vertices),
301                            ..PathVeto::default()
302                        },
303                    )
304                    // Prefer a simple closure, then admit the shortest lawful
305                    // closed walk when the network topology compels a retrace.
306                    .or_else(|| {
307                        forge.route(
308                            anchors.last().expect("validated anchors").projection,
309                            anchors[0].projection,
310                            previous,
311                            PathVeto::default(),
312                        )
313                    })
314                    .ok_or_else(|| {
315                        TrailgenError::InvalidData(
316                            "no lawful return connects the final support point".to_owned(),
317                        )
318                    })?;
319                spans.extend(return_path);
320            }
321            RouteShape::FigureEight => unreachable!("figure-eight rejected by validation"),
322        }
323        Ok(RoutedDesign {
324            spans,
325            support_span_offsets,
326        })
327    }
328}
329
330struct RoutedDesign {
331    spans: Vec<PartialSpan>,
332    support_span_offsets: Vec<usize>,
333}
334
335/// A trail shape is design intent; route shape is observed walk morphology.
336/// In particular, a manually designed loop may revisit a junction or retrace
337/// an edge while still fulfilling its sole topological promise: returning to
338/// its trailhead.
339fn walk_fulfills_design(design: RouteShape, morphology: RouteShape, closed: bool) -> bool {
340    match design {
341        RouteShape::Loop => closed,
342        RouteShape::Open | RouteShape::OutAndBack => morphology == design,
343        RouteShape::FigureEight => false,
344    }
345}
346
347fn route_vertices(graph: &WalkGraph, route: &Route) -> Option<Vec<VertexId>> {
348    let mut vertices = Vec::with_capacity(route.edges.len() + 1);
349    let mut at = route.start;
350    let mut previous = None;
351    vertices.push(at);
352    for edge in &route.edges {
353        let segment = graph.edges.get(edge.0)?;
354        if !graph.turn_allowed(previous, at, *edge) {
355            return None;
356        }
357        at = segment.traverse(at)?;
358        previous = Some(*edge);
359        vertices.push(at);
360    }
361    Some(vertices)
362}
363
364fn compress_arc(
365    graph: &WalkGraph,
366    route: &Route,
367    vertices: &[VertexId],
368    routing: RoutingLaw,
369    lo: usize,
370    hi: usize,
371    supports: &mut Vec<SupportPoint>,
372) -> Option<()> {
373    let previous = lo.checked_sub(1).map(|index| route.edges[index]);
374    if shortest_path(graph, vertices[lo], vertices[hi], previous, routing, None)
375        .is_some_and(|shortest| shortest == route.edges[lo..hi])
376    {
377        supports.push(vertex_support(graph, vertices[lo])?);
378        return Some(());
379    }
380    if hi - lo <= 1 {
381        supports.push(vertex_support(graph, vertices[lo])?);
382        let edge = &graph.edges[route.edges[lo].0];
383        supports.push(SupportPoint::forge(line_coord_at(
384            &edge.geometry,
385            edge.geometry.length_m() * 0.5,
386        ))?);
387        return Some(());
388    }
389    let split = lo + (hi - lo) / 2;
390    compress_arc(graph, route, vertices, routing, lo, split, supports)?;
391    compress_arc(graph, route, vertices, routing, split, hi, supports)
392}
393
394fn compress_span_arc(
395    forge: &mut PathForge<'_>,
396    spans: &[PartialSpan],
397    lo: usize,
398    hi: usize,
399    supports: &mut Vec<SupportPoint>,
400) -> Option<()> {
401    let start = span_projection(forge.graph, spans[lo], true);
402    let target = span_projection(forge.graph, spans[hi - 1], false);
403    let previous = lo.checked_sub(1).map(|slot| spans[slot].edge);
404    if forge
405        .route(start, target, previous, PathVeto::default())
406        .is_some_and(|shortest| same_spans(&shortest, &spans[lo..hi]))
407    {
408        supports.push(SupportPoint::forge(start.coord)?);
409        return Some(());
410    }
411    if hi - lo <= 1 {
412        supports.push(SupportPoint::forge(start.coord)?);
413        let span = spans[lo];
414        supports.push(SupportPoint::forge(line_coord_at(
415            &forge.graph.edges[span.edge.0].geometry,
416            (span.from_m + span.to_m) * 0.5,
417        ))?);
418        return Some(());
419    }
420    let split = lo + (hi - lo) / 2;
421    compress_span_arc(forge, spans, lo, split, supports)?;
422    compress_span_arc(forge, spans, split, hi, supports)
423}
424
425fn span_projection(graph: &WalkGraph, span: PartialSpan, start: bool) -> EdgeProjection {
426    let progress_m = if start { span.from_m } else { span.to_m };
427    EdgeProjection {
428        edge: span.edge,
429        coord: line_coord_at(&graph.edges[span.edge.0].geometry, progress_m),
430        progress_m,
431        distance_m: 0.0,
432    }
433}
434
435fn same_spans(left: &[PartialSpan], right: &[PartialSpan]) -> bool {
436    left.len() == right.len()
437        && left.iter().zip(right).all(|(left, right)| {
438            left.edge == right.edge
439                && (left.from_m - right.from_m).abs() <= SUPPORT_EPSILON_M
440                && (left.to_m - right.to_m).abs() <= SUPPORT_EPSILON_M
441        })
442}
443
444fn vertex_support(graph: &WalkGraph, vertex: VertexId) -> Option<SupportPoint> {
445    SupportPoint::forge(graph.vertices[vertex.0].coord)
446}
447
448#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
449#[serde(deny_unknown_fields)]
450pub struct SupportBinding {
451    pub requested: SupportPoint,
452    pub anchor: SupportPoint,
453    pub vertex: VertexId,
454    pub snap_m: f64,
455}
456
457#[derive(Clone, Debug, PartialEq)]
458pub struct TrailRealization {
459    pub trail: Trail,
460    pub bindings: Vec<SupportBinding>,
461    pub route: Route,
462    walk: Arc<WalkGraph>,
463    support_offsets: Vec<usize>,
464    source_spans: Vec<PartialSpan>,
465    support_span_offsets: Vec<usize>,
466}
467
468#[derive(Clone, Debug, PartialEq)]
469pub struct TrailReversal {
470    pub trail: Trail,
471    pub added_supports: usize,
472}
473
474#[derive(Clone, Copy, Debug, PartialEq)]
475pub struct SupportInsertion {
476    pub slot: usize,
477    pub distance_m: f64,
478}
479
480impl TrailRealization {
481    #[must_use]
482    pub fn graph(&self) -> &WalkGraph {
483        &self.walk
484    }
485
486    /// Direction-sensitive identity for this realization's ordered physical
487    /// walk. The value is an ephemeral comparison key, not durable project
488    /// identity or a cryptographic digest.
489    #[must_use]
490    pub fn walk_fingerprint(&self) -> u64 {
491        self.source_spans.iter().fold(
492            (self.source_spans.len() as u64) ^ 0xa076_1d64_78bd_642f,
493            |state, span| {
494                [
495                    span.edge.0 as u64,
496                    span.from_m.to_bits(),
497                    span.to_m.to_bits(),
498                ]
499                .into_iter()
500                .fold(state, |state, word| {
501                    (state ^ word.wrapping_mul(0xe703_7ed1_a0b4_28db))
502                        .rotate_left(23)
503                        .wrapping_mul(0x8ebc_6af0_9c88_c6e3)
504                })
505            },
506        )
507    }
508
509    /// Inverts a loop's exact physical walk while retaining every existing
510    /// support. The support tail is reversed; compact repairs are inserted
511    /// only where that program would otherwise select another path.
512    pub fn reverse_loop(&self, source: &WalkGraph) -> crate::Result<TrailReversal> {
513        if self.trail.shape != RouteShape::Loop {
514            return Err(TrailgenError::ShapeMismatch {
515                actual: self.trail.shape,
516                expected: RouteShape::Loop,
517            });
518        }
519        if self.source_spans.iter().any(|span| {
520            source
521                .edges
522                .get(span.edge.0)
523                .is_none_or(|edge| edge.attr.travel != crate::EdgeTravel::Both)
524        }) {
525            return Err(TrailgenError::OneWayReversal);
526        }
527
528        let reversed = self
529            .source_spans
530            .iter()
531            .rev()
532            .map(|span| span.reversed())
533            .collect::<Vec<_>>();
534        let span_count = reversed.len();
535        debug_assert_eq!(
536            self.support_span_offsets.len(),
537            self.trail.support_points.len()
538        );
539
540        let fixed = std::iter::once((0, self.trail.support_points[0]))
541            .chain((1..self.trail.support_points.len()).rev().map(|slot| {
542                (
543                    span_count - self.support_span_offsets[slot],
544                    self.trail.support_points[slot],
545                )
546            }))
547            .collect::<Vec<_>>();
548        let mut support_points = Vec::with_capacity(fixed.len());
549        support_points.push(fixed[0].1);
550        let mut added_supports = 0;
551        let router = crate::WalkRouter::forge(source);
552        let mut workspace = router.workspace(source);
553        let mut forge = PathForge {
554            router: &router,
555            workspace: &mut workspace,
556            graph: source,
557            law: self.trail.routing,
558        };
559        for slot in 0..fixed.len() {
560            let lo = fixed[slot].0;
561            let hi = fixed.get(slot + 1).map_or(span_count, |next| next.0);
562            if lo < hi {
563                let mut repaired = Vec::new();
564                compress_span_arc(&mut forge, &reversed, lo, hi, &mut repaired)
565                    .ok_or(TrailgenError::UnrepresentableReversal)?;
566                debug_assert_eq!(repaired.first(), Some(&fixed[slot].1));
567                added_supports += repaired.len() - 1;
568                support_points.extend(repaired.into_iter().skip(1));
569            }
570            if let Some(next) = fixed.get(slot + 1) {
571                support_points.push(next.1);
572            }
573        }
574
575        Ok(TrailReversal {
576            trail: Trail::forge(RouteShape::Loop, support_points, self.trail.routing)?,
577            added_supports,
578        })
579    }
580
581    /// Locates a new support in the design order of the realized walk. Loops
582    /// admit their closing arc after the final explicit support; out-and-backs
583    /// index only their outward spine because the return is its exact reverse.
584    #[must_use]
585    pub fn support_insertion(&self, requested: Coord) -> Option<SupportInsertion> {
586        let graph = self.graph();
587        let routed = if self.trail.shape == RouteShape::OutAndBack {
588            *self.support_offsets.last()?
589        } else {
590            self.route.edges.len()
591        };
592        let (edge_slot, distance_m) = self
593            .route
594            .edges
595            .iter()
596            .take(routed)
597            .enumerate()
598            .filter_map(|(slot, edge)| {
599                crate::model::line_projection(&graph.edges[edge.0].geometry, requested)
600                    .map(|(distance_m, _, _)| (slot, distance_m))
601            })
602            .min_by(|left, right| left.1.total_cmp(&right.1))?;
603        let slot = self.support_offsets[1..].partition_point(|offset| *offset <= edge_slot) + 1;
604        Some(SupportInsertion { slot, distance_m })
605    }
606}
607
608const SUPPORT_EPSILON_M: f64 = 0.05;
609
610#[derive(Clone, Copy)]
611struct BoundProjection {
612    requested: SupportPoint,
613    projection: EdgeProjection,
614}
615
616#[derive(Clone, Copy, Debug, PartialEq)]
617struct PartialSpan {
618    edge: EdgeId,
619    from_m: f64,
620    to_m: f64,
621}
622
623impl PartialSpan {
624    const fn reversed(self) -> Self {
625        Self {
626            edge: self.edge,
627            from_m: self.to_m,
628            to_m: self.from_m,
629        }
630    }
631}
632
633fn bind_projections(
634    graph: &WalkGraph,
635    index: &EdgeIndex,
636    supports: &[SupportPoint],
637    max_snap_m: f64,
638) -> crate::Result<Vec<BoundProjection>> {
639    supports
640        .iter()
641        .copied()
642        .map(|requested| {
643            let projection = index.project(graph, requested.coord()).ok_or_else(|| {
644                TrailgenError::InvalidData(
645                    "cannot bind a support point to an empty network".to_owned(),
646                )
647            })?;
648            if projection.distance_m > max_snap_m {
649                return Err(TrailgenError::InvalidData(format!(
650                    "support point lies {:.0} m from the walking network",
651                    projection.distance_m
652                )));
653            }
654            Ok(BoundProjection {
655                requested,
656                projection,
657            })
658        })
659        .collect()
660}
661
662#[derive(Clone, Copy)]
663struct EndpointRoute {
664    vertex: VertexId,
665    span: Option<PartialSpan>,
666    cost: f64,
667    previous: Option<EdgeId>,
668}
669
670struct PathForge<'a> {
671    router: &'a crate::WalkRouter,
672    workspace: &'a mut crate::RoutingWorkspace,
673    graph: &'a WalkGraph,
674    law: RoutingLaw,
675}
676
677impl PathForge<'_> {
678    fn route(
679        &mut self,
680        from: EdgeProjection,
681        target: EdgeProjection,
682        previous: Option<EdgeId>,
683        veto: PathVeto<'_>,
684    ) -> Option<Vec<PartialSpan>> {
685        let mut alternatives = Vec::<(f64, Vec<PartialSpan>)>::new();
686        if from.edge == target.edge
687            && span_allowed(self.graph, from.edge, from.progress_m, target.progress_m)
688            && veto.edges.is_none_or(|edges| !edges.contains(&from.edge))
689        {
690            let edge = &self.graph.edges[from.edge.0];
691            let at_endpoint = endpoint_at(edge, from.progress_m);
692            if at_endpoint.is_none_or(|via| self.graph.turn_allowed(previous, via, from.edge)) {
693                let span = PartialSpan {
694                    edge: from.edge,
695                    from_m: from.progress_m,
696                    to_m: target.progress_m,
697                };
698                if let Some(cost) = partial_cost(self.graph, self.law, span)
699                    && veto.cost_ceiling.is_none_or(|ceiling| cost <= ceiling)
700                {
701                    alternatives.push((cost, vec![span]));
702                }
703            }
704        }
705
706        for departure in departure_routes(self.graph, from, previous, self.law) {
707            if departure
708                .span
709                .is_some_and(|span| veto.edges.is_some_and(|edges| edges.contains(&span.edge)))
710            {
711                continue;
712            }
713            for arrival in arrival_routes(self.graph, target, self.law) {
714                if arrival
715                    .span
716                    .is_some_and(|span| veto.edges.is_some_and(|edges| edges.contains(&span.edge)))
717                {
718                    continue;
719                }
720                let fixed_cost = departure.cost + arrival.cost;
721                let ceiling = veto.cost_ceiling.map(|ceiling| ceiling - fixed_cost);
722                if ceiling.is_some_and(|ceiling| ceiling < 0.0) {
723                    continue;
724                }
725                let Some(path) = self.router.shortest_path(
726                    self.graph,
727                    self.workspace,
728                    crate::RouteRequest {
729                        from: departure.vertex,
730                        target: arrival.vertex,
731                        previous: departure.previous,
732                        law: self.law,
733                        cost_ceiling: ceiling,
734                        forbidden_edges: veto.edges,
735                        forbidden_vertices: veto.vertices,
736                    },
737                ) else {
738                    continue;
739                };
740                if let Some(span) = arrival.span
741                    && !self.graph.turn_allowed(
742                        path.last().copied().or(departure.previous),
743                        arrival.vertex,
744                        span.edge,
745                    )
746                {
747                    continue;
748                }
749                let mut spans = Vec::with_capacity(path.len() + 2);
750                if let Some(prefix) = departure.span {
751                    spans.push(prefix);
752                }
753                append_full_spans(self.graph, departure.vertex, &path, &mut spans)?;
754                if let Some(suffix) = arrival.span {
755                    spans.push(suffix);
756                }
757                let cost = fixed_cost
758                    + path
759                        .iter()
760                        .map(|edge| self.law.edge_cost(self.graph, *edge))
761                        .sum::<Option<f64>>()?;
762                alternatives.push((cost, spans));
763            }
764        }
765        alternatives
766            .into_iter()
767            .min_by(|left, right| {
768                left.0
769                    .total_cmp(&right.0)
770                    .then_with(|| span_key(&left.1).cmp(&span_key(&right.1)))
771            })
772            .map(|(_, spans)| spans)
773    }
774}
775
776fn departure_routes(
777    graph: &WalkGraph,
778    projection: EdgeProjection,
779    previous: Option<EdgeId>,
780    law: RoutingLaw,
781) -> Vec<EndpointRoute> {
782    let edge = &graph.edges[projection.edge.0];
783    if let Some(vertex) = endpoint_at(edge, projection.progress_m) {
784        return vec![EndpointRoute {
785            vertex,
786            span: None,
787            cost: 0.0,
788            previous,
789        }];
790    }
791    [(edge.a, 0.0), (edge.b, edge.geometry.length_m())]
792        .into_iter()
793        .filter_map(|(vertex, to_m)| {
794            let span = PartialSpan {
795                edge: projection.edge,
796                from_m: projection.progress_m,
797                to_m,
798            };
799            if !span_allowed(graph, span.edge, span.from_m, span.to_m) {
800                return None;
801            }
802            Some(EndpointRoute {
803                vertex,
804                span: Some(span),
805                cost: partial_cost(graph, law, span)?,
806                previous: Some(span.edge),
807            })
808        })
809        .collect()
810}
811
812fn arrival_routes(
813    graph: &WalkGraph,
814    projection: EdgeProjection,
815    law: RoutingLaw,
816) -> Vec<EndpointRoute> {
817    let edge = &graph.edges[projection.edge.0];
818    if let Some(vertex) = endpoint_at(edge, projection.progress_m) {
819        return vec![EndpointRoute {
820            vertex,
821            span: None,
822            cost: 0.0,
823            previous: None,
824        }];
825    }
826    [(edge.a, 0.0), (edge.b, edge.geometry.length_m())]
827        .into_iter()
828        .filter_map(|(vertex, from_m)| {
829            let span = PartialSpan {
830                edge: projection.edge,
831                from_m,
832                to_m: projection.progress_m,
833            };
834            if !span_allowed(graph, span.edge, span.from_m, span.to_m) {
835                return None;
836            }
837            Some(EndpointRoute {
838                vertex,
839                span: Some(span),
840                cost: partial_cost(graph, law, span)?,
841                previous: None,
842            })
843        })
844        .collect()
845}
846
847fn span_allowed(graph: &WalkGraph, edge: EdgeId, from_m: f64, to_m: f64) -> bool {
848    use crate::EdgeTravel;
849    match graph.edges[edge.0].attr.travel {
850        EdgeTravel::Both => true,
851        EdgeTravel::Forward => to_m >= from_m,
852        EdgeTravel::Backward => to_m <= from_m,
853    }
854}
855
856fn partial_cost(graph: &WalkGraph, law: RoutingLaw, span: PartialSpan) -> Option<f64> {
857    let full_m = graph.edges[span.edge.0].geometry.length_m();
858    let ratio = (span.to_m - span.from_m).abs() / full_m.max(f64::EPSILON);
859    Some(law.edge_cost(graph, span.edge)? * ratio)
860}
861
862fn endpoint_at(edge: &Edge, progress_m: f64) -> Option<VertexId> {
863    if progress_m <= SUPPORT_EPSILON_M {
864        Some(edge.a)
865    } else if edge.geometry.length_m() - progress_m <= SUPPORT_EPSILON_M {
866        Some(edge.b)
867    } else {
868        None
869    }
870}
871
872fn append_full_spans(
873    graph: &WalkGraph,
874    mut at: VertexId,
875    path: &[EdgeId],
876    spans: &mut Vec<PartialSpan>,
877) -> Option<()> {
878    for edge_id in path {
879        let edge = &graph.edges[edge_id.0];
880        let to = edge.traverse(at)?;
881        spans.push(PartialSpan {
882            edge: *edge_id,
883            from_m: if at == edge.a {
884                0.0
885            } else {
886                edge.geometry.length_m()
887            },
888            to_m: if to == edge.b {
889                edge.geometry.length_m()
890            } else {
891                0.0
892            },
893        });
894        at = to;
895    }
896    Some(())
897}
898
899fn span_key(spans: &[PartialSpan]) -> Vec<(EdgeId, u64, u64)> {
900    spans
901        .iter()
902        .map(|span| (span.edge, span.from_m.to_bits(), span.to_m.to_bits()))
903        .collect()
904}
905
906fn walked_span_vertices(graph: &WalkGraph, spans: &[PartialSpan]) -> BTreeSet<VertexId> {
907    spans
908        .iter()
909        .flat_map(|span| {
910            let edge = &graph.edges[span.edge.0];
911            [endpoint_at(edge, span.from_m), endpoint_at(edge, span.to_m)]
912        })
913        .flatten()
914        .collect()
915}
916
917struct MaterializedWalk {
918    graph: WalkGraph,
919    bindings: Vec<SupportBinding>,
920    edges: Vec<EdgeId>,
921    support_offsets: Vec<usize>,
922}
923
924#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
925enum LocalVertex {
926    Base(VertexId),
927    Interior(EdgeId, u64),
928}
929
930fn materialize_walk(
931    source: &WalkGraph,
932    anchors: &[BoundProjection],
933    spans: &[PartialSpan],
934    support_span_offsets: &[usize],
935) -> crate::Result<MaterializedWalk> {
936    let mut cuts = BTreeMap::<EdgeId, Vec<f64>>::new();
937    for span in spans {
938        cuts.entry(span.edge)
939            .or_default()
940            .extend([span.from_m, span.to_m]);
941    }
942    for anchor in anchors {
943        cuts.entry(anchor.projection.edge)
944            .or_default()
945            .push(anchor.projection.progress_m);
946    }
947    for (edge, marks) in &mut cuts {
948        let length_m = source.edges[edge.0].geometry.length_m();
949        marks.extend([0.0, length_m]);
950        marks.sort_by(f64::total_cmp);
951        marks.dedup_by(|left, right| (*left - *right).abs() <= SUPPORT_EPSILON_M);
952    }
953
954    let mut vertices = Vec::new();
955    let mut vertex_ids = BTreeMap::<LocalVertex, VertexId>::new();
956    let mut edges = Vec::new();
957    let mut edge_ids = BTreeMap::<(EdgeId, u64, u64), EdgeId>::new();
958    let mut lineage = Vec::new();
959    let mut route = Vec::new();
960    let mut span_offsets = Vec::with_capacity(spans.len() + 1);
961    span_offsets.push(0);
962    for span in spans {
963        let marks = &cuts[&span.edge];
964        let from = canonical_mark(marks, span.from_m);
965        let to = canonical_mark(marks, span.to_m);
966        let lo = from.min(to);
967        let hi = from.max(to);
968        let mut intervals = marks
969            .windows(2)
970            .filter(|pair| pair[0] >= lo - SUPPORT_EPSILON_M && pair[1] <= hi + SUPPORT_EPSILON_M)
971            .map(|pair| (pair[0], pair[1]))
972            .collect::<Vec<_>>();
973        if to < from {
974            intervals.reverse();
975        }
976        for (lo, hi) in intervals {
977            let edge = materialized_edge(
978                source,
979                span.edge,
980                lo,
981                hi,
982                &mut vertices,
983                &mut vertex_ids,
984                &mut edges,
985                &mut edge_ids,
986                &mut lineage,
987            );
988            route.push(edge);
989        }
990        span_offsets.push(route.len());
991    }
992
993    let bindings = anchors
994        .iter()
995        .map(|bound| {
996            let marks = &cuts[&bound.projection.edge];
997            let progress_m = canonical_mark(marks, bound.projection.progress_m);
998            let vertex = materialized_vertex(
999                source,
1000                bound.projection.edge,
1001                progress_m,
1002                &mut vertices,
1003                &mut vertex_ids,
1004            );
1005            let anchor = SupportPoint(line_coord_at(
1006                &source.edges[bound.projection.edge.0].geometry,
1007                progress_m,
1008            ));
1009            SupportBinding {
1010                requested: bound.requested,
1011                anchor,
1012                vertex,
1013                snap_m: bound.requested.coord().haversine_m(anchor.coord()),
1014            }
1015        })
1016        .collect::<Vec<_>>();
1017    let support_offsets = support_span_offsets
1018        .iter()
1019        .map(|offset| span_offsets[*offset])
1020        .collect();
1021    let mut graph = WalkGraph::new(vertices, edges);
1022    graph.turn_bans = inherited_turn_bans(source, &graph, &lineage);
1023    graph.validate()?;
1024    Ok(MaterializedWalk {
1025        graph,
1026        bindings,
1027        edges: route,
1028        support_offsets,
1029    })
1030}
1031
1032#[allow(clippy::too_many_arguments)]
1033fn materialized_edge(
1034    source: &WalkGraph,
1035    source_edge: EdgeId,
1036    lo: f64,
1037    hi: f64,
1038    vertices: &mut Vec<Vertex>,
1039    vertex_ids: &mut BTreeMap<LocalVertex, VertexId>,
1040    edges: &mut Vec<Edge>,
1041    edge_ids: &mut BTreeMap<(EdgeId, u64, u64), EdgeId>,
1042    lineage: &mut Vec<EdgeId>,
1043) -> EdgeId {
1044    let key = (source_edge, lo.to_bits(), hi.to_bits());
1045    if let Some(edge) = edge_ids.get(&key) {
1046        return *edge;
1047    }
1048    let a = materialized_vertex(source, source_edge, lo, vertices, vertex_ids);
1049    let b = materialized_vertex(source, source_edge, hi, vertices, vertex_ids);
1050    let original = &source.edges[source_edge.0];
1051    let span_m = original.geometry.length_m();
1052    let geometry = line_slice(&original.geometry, lo, hi);
1053    let carries_crossings = lo <= span_m * 0.5 && span_m * 0.5 < hi;
1054    let id = EdgeId(edges.len());
1055    edges.push(Edge {
1056        id,
1057        a,
1058        b,
1059        attr: cleaved_attr(original, &geometry, span_m, carries_crossings),
1060        geometry,
1061    });
1062    lineage.push(source_edge);
1063    edge_ids.insert(key, id);
1064    id
1065}
1066
1067fn materialized_vertex(
1068    source: &WalkGraph,
1069    source_edge: EdgeId,
1070    progress_m: f64,
1071    vertices: &mut Vec<Vertex>,
1072    vertex_ids: &mut BTreeMap<LocalVertex, VertexId>,
1073) -> VertexId {
1074    let edge = &source.edges[source_edge.0];
1075    let key = if progress_m <= SUPPORT_EPSILON_M {
1076        LocalVertex::Base(edge.a)
1077    } else if edge.geometry.length_m() - progress_m <= SUPPORT_EPSILON_M {
1078        LocalVertex::Base(edge.b)
1079    } else {
1080        LocalVertex::Interior(source_edge, progress_m.to_bits())
1081    };
1082    if let Some(vertex) = vertex_ids.get(&key) {
1083        return *vertex;
1084    }
1085    let id = VertexId(vertices.len());
1086    let (coord, junction) = match key {
1087        LocalVertex::Base(base) => {
1088            let base = &source.vertices[base.0];
1089            (base.coord, base.junction.clone())
1090        }
1091        LocalVertex::Interior(_, _) => (line_coord_at(&edge.geometry, progress_m), None),
1092    };
1093    vertices.push(Vertex {
1094        id,
1095        coord,
1096        junction,
1097    });
1098    vertex_ids.insert(key, id);
1099    id
1100}
1101
1102fn canonical_mark(marks: &[f64], progress_m: f64) -> f64 {
1103    marks
1104        .iter()
1105        .copied()
1106        .find(|mark| (*mark - progress_m).abs() <= SUPPORT_EPSILON_M)
1107        .unwrap_or(progress_m)
1108}
1109
1110fn inherited_turn_bans(
1111    source: &WalkGraph,
1112    realized: &WalkGraph,
1113    lineage: &[EdgeId],
1114) -> Vec<TurnBan> {
1115    source
1116        .turn_bans
1117        .iter()
1118        .filter_map(|ban| {
1119            let via = realized
1120                .vertices
1121                .iter()
1122                .find(|vertex| {
1123                    vertex.coord == source.vertices[ban.via.0].coord
1124                        && vertex.junction == source.vertices[ban.via.0].junction
1125                })?
1126                .id;
1127            let incident = |source_edge: EdgeId| {
1128                lineage.iter().enumerate().find_map(|(slot, lineage)| {
1129                    (*lineage == source_edge
1130                        && [realized.edges[slot].a, realized.edges[slot].b].contains(&via))
1131                    .then_some(EdgeId(slot))
1132                })
1133            };
1134            Some(TurnBan {
1135                via,
1136                from: incident(ban.from)?,
1137                to: incident(ban.to)?,
1138                provenance: ban.provenance.clone(),
1139            })
1140        })
1141        .collect()
1142}
1143
1144fn line_coord_at(line: &LineString, progress_m: f64) -> Coord {
1145    let mut traversed_m = 0.0;
1146    for segment in line.points.windows(2) {
1147        let length_m = segment[0].haversine_m(segment[1]);
1148        if progress_m <= traversed_m + length_m {
1149            let t = if length_m <= f64::EPSILON {
1150                0.0
1151            } else {
1152                (progress_m - traversed_m) / length_m
1153            };
1154            return segment[0].lerp(segment[1], t.clamp(0.0, 1.0));
1155        }
1156        traversed_m += length_m;
1157    }
1158    line.end()
1159}
1160
1161fn line_slice(line: &LineString, start_m: f64, end_m: f64) -> LineString {
1162    let mut points = vec![line_coord_at(line, start_m)];
1163    let mut traversed_m = 0.0;
1164    for segment in line.points.windows(2) {
1165        traversed_m += segment[0].haversine_m(segment[1]);
1166        if traversed_m > start_m && traversed_m < end_m {
1167            points.push(segment[1]);
1168        }
1169    }
1170    let end = line_coord_at(line, end_m);
1171    if points.last() != Some(&end) {
1172        points.push(end);
1173    }
1174    if points.len() == 1 {
1175        points.push(end);
1176    }
1177    LineString::unchecked(points)
1178}
1179
1180fn cleaved_attr(
1181    source: &Edge,
1182    geometry: &LineString,
1183    span_m: f64,
1184    carries_crossings: bool,
1185) -> EdgeAttr {
1186    let ratio = geometry.length_m() / span_m;
1187    let (measured_ascent, measured_descent) = geometry.ascent_descent_m();
1188    let measured = geometry.points.iter().all(|point| point.ele.is_some());
1189    let ascent_m = if measured {
1190        measured_ascent
1191    } else {
1192        source.attr.ascent_m * ratio
1193    };
1194    let descent_m = if measured {
1195        measured_descent
1196    } else {
1197        source.attr.descent_m * ratio
1198    };
1199    let mut attr = source.attr.clone();
1200    attr.length_m *= ratio;
1201    attr.ascent_m = ascent_m;
1202    attr.descent_m = descent_m;
1203    attr.sustained_steep_m *= ratio;
1204    attr.grade_distribution = scale_grades(attr.grade_distribution, ratio);
1205    attr.traversal = HikingModel.estimate(geometry, &attr);
1206    if !carries_crossings {
1207        attr.crossings.clear();
1208    }
1209    attr
1210}
1211
1212const fn scale_grades(grades: GradeDistribution, ratio: f64) -> GradeDistribution {
1213    GradeDistribution {
1214        flat_m: grades.flat_m * ratio,
1215        rolling_m: grades.rolling_m * ratio,
1216        steep_m: grades.steep_m * ratio,
1217        savage_m: grades.savage_m * ratio,
1218    }
1219}
1220
1221#[derive(Clone, Copy, Default)]
1222struct PathVeto<'a> {
1223    cost_ceiling: Option<f64>,
1224    edges: Option<&'a BTreeSet<EdgeId>>,
1225    vertices: Option<&'a BTreeSet<VertexId>>,
1226}
1227
1228pub(crate) fn shortest_path(
1229    graph: &WalkGraph,
1230    from: VertexId,
1231    target: VertexId,
1232    previous: Option<EdgeId>,
1233    law: RoutingLaw,
1234    max_cost: Option<f64>,
1235) -> Option<Vec<EdgeId>> {
1236    shortest_path_excluding(
1237        graph,
1238        from,
1239        target,
1240        previous,
1241        law,
1242        PathVeto {
1243            cost_ceiling: max_cost,
1244            ..PathVeto::default()
1245        },
1246    )
1247}
1248
1249fn shortest_path_excluding(
1250    graph: &WalkGraph,
1251    from: VertexId,
1252    target: VertexId,
1253    previous: Option<EdgeId>,
1254    law: RoutingLaw,
1255    veto: PathVeto<'_>,
1256) -> Option<Vec<EdgeId>> {
1257    let router = crate::WalkRouter::forge(graph);
1258    let mut workspace = router.workspace(graph);
1259    route_path_excluding(
1260        &router,
1261        &mut workspace,
1262        graph,
1263        from,
1264        target,
1265        previous,
1266        law,
1267        veto.edges,
1268        veto.vertices,
1269        veto.cost_ceiling,
1270    )
1271}
1272
1273#[allow(clippy::too_many_arguments)]
1274fn route_path_excluding(
1275    router: &crate::WalkRouter,
1276    workspace: &mut crate::RoutingWorkspace,
1277    graph: &WalkGraph,
1278    from: VertexId,
1279    target: VertexId,
1280    previous: Option<EdgeId>,
1281    law: RoutingLaw,
1282    forbidden_edges: Option<&BTreeSet<EdgeId>>,
1283    forbidden_vertices: Option<&BTreeSet<VertexId>>,
1284    cost_ceiling: Option<f64>,
1285) -> Option<Vec<EdgeId>> {
1286    router.shortest_path(
1287        graph,
1288        workspace,
1289        crate::RouteRequest {
1290            from,
1291            target,
1292            previous,
1293            law,
1294            cost_ceiling,
1295            forbidden_edges,
1296            forbidden_vertices,
1297        },
1298    )
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304    use crate::{
1305        CrossingControl, EdgeTravel, GeometryClaim, GraphBuilder, JunctionPolicy, LineString,
1306        Provenance, SegmentDraft, TrailStanding, WayKind, WayRealm, io::geojson,
1307    };
1308
1309    fn graph() -> WalkGraph {
1310        GraphBuilder::default()
1311            .build(
1312                &geojson::network_from_str(include_str!("../tests/fixtures/mini_network.geojson"))
1313                    .expect("parse fixture"),
1314            )
1315            .expect("build fixture")
1316    }
1317
1318    fn draft(name: &str, from: Coord, to: Coord) -> SegmentDraft {
1319        SegmentDraft {
1320            geometry: LineString::new(vec![from, to]).expect("valid line"),
1321            junctions: JunctionPolicy::Planar,
1322            turn_ref: None,
1323            junction_keys: None,
1324            turn_restrictions: Vec::new(),
1325            way_kind: WayKind::Path,
1326            realm: WayRealm::default(),
1327            geometry_claim: GeometryClaim::default(),
1328            crossing_control: CrossingControl::default(),
1329            standing: TrailStanding::Established,
1330            marking: crate::TrailMarking::default(),
1331            terrain: Terrain::Trail,
1332            terrain_confidence: Some(1.0),
1333            surface: Some("dirt".to_owned()),
1334            access: Access::Open,
1335            travel: EdgeTravel::Both,
1336            road_exposure: 0.0,
1337            confidence: 1.0,
1338            provenance: vec![Provenance::fixture(name)],
1339        }
1340    }
1341
1342    fn vertex_at(graph: &WalkGraph, coord: Coord) -> VertexId {
1343        graph
1344            .vertices
1345            .iter()
1346            .find(|vertex| vertex.coord.planar_distance2(coord) < 1.0e-16)
1347            .expect("coordinate vertex")
1348            .id
1349    }
1350
1351    fn edge_between(graph: &WalkGraph, from: VertexId, to: VertexId) -> EdgeId {
1352        graph.adjacency[from.0]
1353            .iter()
1354            .copied()
1355            .find(|edge| graph.edges[edge.0].other(from) == Some(to))
1356            .expect("adjacent vertices")
1357    }
1358
1359    fn loop_constraints() -> LoopConstraints {
1360        LoopConstraints {
1361            min_distance_m: 0.0,
1362            max_distance_m: f64::MAX,
1363            max_repeated_edge_fraction: 0.0,
1364            allowed_shapes: vec![RouteShape::Loop],
1365            ..LoopConstraints::default()
1366        }
1367    }
1368
1369    #[test]
1370    fn out_and_back_is_a_shortest_spine_reversed_by_construction() {
1371        let graph = graph();
1372        let start = SupportPoint::forge(graph.vertices[0].coord).expect("valid start");
1373        let end = SupportPoint::forge(graph.vertices[2].coord).expect("valid end");
1374        let trail = Trail::forge(
1375            RouteShape::OutAndBack,
1376            vec![start, end],
1377            RoutingLaw::default(),
1378        )
1379        .expect("valid trail");
1380        let constraints = LoopConstraints {
1381            min_distance_m: 0.0,
1382            max_distance_m: f64::MAX,
1383            max_repeated_edge_fraction: 1.0,
1384            allowed_shapes: vec![RouteShape::OutAndBack],
1385            ..LoopConstraints::default()
1386        };
1387        let realized = trail
1388            .realize("manual", &graph, &constraints, 1.0)
1389            .expect("realize support points");
1390        let split = realized.route.edges.len() / 2;
1391        assert_eq!(
1392            realized.route.edges[..split],
1393            realized.route.edges[split..]
1394                .iter()
1395                .rev()
1396                .copied()
1397                .collect::<Vec<_>>()
1398        );
1399        let inferred = Trail::infer(&graph, &realized.route, RoutingLaw::default())
1400            .expect("canonical out-and-back is inferable");
1401        assert_eq!(inferred.support_points.len(), 2);
1402    }
1403
1404    #[test]
1405    fn out_and_back_may_turn_inside_an_edge() {
1406        let start_coord = Coord::with_ele(0.0, 0.0, 0.0);
1407        let turn_coord = Coord::new(0.001, 0.000_004);
1408        let graph = GraphBuilder::default()
1409            .build(&[SegmentDraft {
1410                geometry: LineString::new(vec![start_coord, Coord::with_ele(0.004, 0.0, 40.0)])
1411                    .expect("valid line"),
1412                junctions: JunctionPolicy::Planar,
1413                turn_ref: None,
1414                junction_keys: None,
1415                turn_restrictions: Vec::new(),
1416                way_kind: WayKind::Path,
1417                realm: WayRealm::default(),
1418                geometry_claim: GeometryClaim::default(),
1419                crossing_control: CrossingControl::default(),
1420                standing: TrailStanding::Established,
1421                marking: crate::TrailMarking::default(),
1422                terrain: Terrain::Trail,
1423                terrain_confidence: Some(1.0),
1424                surface: Some("dirt".to_owned()),
1425                access: Access::Open,
1426                travel: EdgeTravel::Both,
1427                road_exposure: 0.0,
1428                confidence: 1.0,
1429                provenance: vec![Provenance::fixture("long-edge")],
1430            }])
1431            .expect("build line");
1432        let source_length_m = graph.edges[0].attr.length_m;
1433        let trail = Trail::forge(
1434            RouteShape::OutAndBack,
1435            vec![
1436                SupportPoint::forge(start_coord).expect("valid start"),
1437                SupportPoint::forge(turn_coord).expect("valid turn"),
1438            ],
1439            RoutingLaw::default(),
1440        )
1441        .expect("valid trail");
1442        let constraints = LoopConstraints {
1443            min_distance_m: 0.0,
1444            max_distance_m: f64::MAX,
1445            max_repeated_edge_fraction: 1.0,
1446            allowed_shapes: vec![RouteShape::OutAndBack],
1447            ..LoopConstraints::default()
1448        };
1449
1450        let realized = trail
1451            .realize("partial", &graph, &constraints, 2.0)
1452            .expect("realize partial edge");
1453        let local = realized.graph();
1454
1455        assert_eq!(graph.vertices.len(), 2, "the corpus remains immutable");
1456        assert_eq!(graph.edges.len(), 1, "the corpus remains immutable");
1457        assert_eq!(local.vertices.len(), 2, "the realized graph is route-local");
1458        assert_eq!(
1459            local.edges.len(),
1460            1,
1461            "only the walked half-edge is materialized"
1462        );
1463        assert_eq!(realized.route.edges.len(), 2);
1464        assert_eq!(realized.route.edges[0], realized.route.edges[1]);
1465        assert!((realized.route.metrics.distance_m - source_length_m / 2.0).abs() < 0.5);
1466        assert!((realized.route.metrics.ascent_m - 10.0).abs() < 0.1);
1467        assert!((realized.route.metrics.descent_m - 10.0).abs() < 0.1);
1468        let anchor = realized.trail.support_points[1].coord();
1469        assert!(anchor.lat.abs() < 1.0e-10);
1470        assert!((anchor.lon - 0.001).abs() < 1.0e-8);
1471        assert_eq!(local.vertices[realized.bindings[1].vertex.0].coord, anchor);
1472    }
1473
1474    #[test]
1475    fn loop_candidates_recover_exact_support_designs() {
1476        let graph = graph();
1477        let constraints = loop_constraints();
1478        let route = crate::ExactLoopSolver::default()
1479            .enumerate(&graph, VertexId(0), &constraints, 1)
1480            .into_iter()
1481            .next()
1482            .expect("fixture has a loop");
1483        let trail = Trail::infer(&graph, &route, RoutingLaw::default())
1484            .expect("loop has an exact support design");
1485        let realized = trail
1486            .realize("recovered", &graph, &constraints, 1.0)
1487            .expect("realize recovered loop");
1488        assert_eq!(realized.route.edges, route.edges);
1489        assert!(trail.support_points.len() >= 2);
1490    }
1491
1492    #[test]
1493    fn loop_closure_chooses_the_shortest_nonrepeating_return() {
1494        let a = Coord::new(0.0, 0.0);
1495        let b = Coord::new(0.002, 0.0);
1496        let c = Coord::new(0.001, 0.001);
1497        let graph = GraphBuilder::default()
1498            .build(&[
1499                draft("direct", a, b),
1500                draft("east", b, c),
1501                draft("west", c, a),
1502            ])
1503            .expect("build triangular network");
1504        let trail = Trail::forge(
1505            RouteShape::Loop,
1506            [a, b]
1507                .map(|coord| SupportPoint::forge(coord).expect("valid support"))
1508                .to_vec(),
1509            RoutingLaw::default(),
1510        )
1511        .expect("valid loop design");
1512
1513        let realized = trail
1514            .realize("triangle", &graph, &loop_constraints(), 1.0)
1515            .expect("alternative return closes a proper loop");
1516
1517        assert_eq!(realized.route.metrics.shape, RouteShape::Loop);
1518        assert_eq!(realized.route.edges.len(), 3);
1519        assert_eq!(
1520            realized
1521                .route
1522                .edges
1523                .iter()
1524                .copied()
1525                .collect::<BTreeSet<_>>()
1526                .len(),
1527            3,
1528            "the return must not double back over the outbound edge"
1529        );
1530    }
1531
1532    #[test]
1533    fn loop_design_admits_and_reverses_a_retraced_bridge() {
1534        let trailhead = Coord::new(0.0, 0.0);
1535        let junction = Coord::new(0.001, 0.0);
1536        let east = Coord::new(0.002, 0.0);
1537        let north = Coord::new(0.001, 0.001);
1538        let graph = GraphBuilder::default()
1539            .build(&[
1540                draft("bridge", trailhead, junction),
1541                draft("lower", junction, east),
1542                draft("upper", east, north),
1543                draft("west", north, junction),
1544            ])
1545            .expect("build lollipop network");
1546        let trail = Trail::forge(
1547            RouteShape::Loop,
1548            [trailhead, east, north]
1549                .map(|coord| SupportPoint::forge(coord).expect("valid support"))
1550                .to_vec(),
1551            RoutingLaw::default(),
1552        )
1553        .expect("valid loop design");
1554        let constraints = LoopConstraints {
1555            min_distance_m: 0.0,
1556            max_distance_m: f64::MAX,
1557            max_repeated_edge_fraction: 1.0,
1558            allowed_shapes: vec![
1559                RouteShape::Loop,
1560                RouteShape::FigureEight,
1561                RouteShape::OutAndBack,
1562            ],
1563            ..LoopConstraints::default()
1564        };
1565
1566        let realized = trail
1567            .realize("lollipop", &graph, &constraints, 1.0)
1568            .expect("a retraced bridge is a lawful manual loop");
1569
1570        assert_eq!(realized.trail.shape, RouteShape::Loop);
1571        assert_eq!(realized.route.metrics.shape, RouteShape::OutAndBack);
1572        assert!(realized.route.metrics.repeated_edge_fraction > 0.0);
1573        assert!(realized.route.verdict.satisfied);
1574        assert_eq!(
1575            graph.walk_edges(realized.route.start, &realized.route.edges),
1576            Some(realized.route.start)
1577        );
1578
1579        let reversal = realized
1580            .reverse_loop(&graph)
1581            .expect("closed design remains reversible despite its morphology");
1582        let reversed = reversal
1583            .trail
1584            .realize("lollipop", &graph, &constraints, 1.0)
1585            .expect("reversed supports reproduce the reversed closed walk");
1586        assert_eq!(reversed.trail.shape, RouteShape::Loop);
1587        assert_eq!(reversed.route.metrics.shape, RouteShape::OutAndBack);
1588        assert_ne!(realized.walk_fingerprint(), reversed.walk_fingerprint());
1589        let expected = realized
1590            .source_spans
1591            .iter()
1592            .rev()
1593            .map(|span| span.reversed())
1594            .collect::<Vec<_>>();
1595        assert!(same_spans(&reversed.source_spans, &expected));
1596    }
1597
1598    #[test]
1599    fn reversal_retains_pins_and_repairs_only_ambiguous_spans() {
1600        let trailhead = Coord::new(0.0, 0.0);
1601        let east = Coord::new(0.001, 0.0);
1602        let far_east = Coord::new(0.002, 0.0);
1603        let turn = Coord::new(0.002, 0.001);
1604        let detour = Coord::new(0.001, 0.001);
1605        let mut graph = GraphBuilder::default()
1606            .build(&[
1607                draft("head-east", trailhead, east),
1608                draft("east-far", east, far_east),
1609                draft("far-turn", far_east, turn),
1610                draft("turn-head", turn, trailhead),
1611                draft("turn-detour", turn, detour),
1612                draft("detour-head", detour, trailhead),
1613            ])
1614            .expect("build pentagonal network");
1615        let head_vertex = vertex_at(&graph, trailhead);
1616        let far_vertex = vertex_at(&graph, far_east);
1617        let turn_vertex = vertex_at(&graph, turn);
1618        graph.turn_bans.push(TurnBan {
1619            via: turn_vertex,
1620            from: edge_between(&graph, far_vertex, turn_vertex),
1621            to: edge_between(&graph, turn_vertex, head_vertex),
1622            provenance: Provenance::fixture("force-detour"),
1623        });
1624        graph.validate().expect("valid turn ban");
1625
1626        let supports = [trailhead, far_east, turn]
1627            .map(|coord| SupportPoint::forge(coord).expect("valid support"))
1628            .to_vec();
1629        let trail = Trail::forge(RouteShape::Loop, supports.clone(), RoutingLaw::default())
1630            .expect("valid loop design");
1631        let constraints = loop_constraints();
1632        let realized = trail
1633            .realize("detour", &graph, &constraints, 1.0)
1634            .expect("turn ban creates a lawful loop");
1635        let reversal = realized
1636            .reverse_loop(&graph)
1637            .expect("bidirectional loop is reversible");
1638
1639        assert_eq!(reversal.added_supports, 1);
1640        assert!(
1641            supports
1642                .iter()
1643                .all(|support| reversal.trail.support_points.contains(support))
1644        );
1645        let reversed = reversal
1646            .trail
1647            .realize("detour", &graph, &constraints, 1.0)
1648            .expect("repaired controls realize");
1649        let expected = realized
1650            .source_spans
1651            .iter()
1652            .rev()
1653            .map(|span| span.reversed())
1654            .collect::<Vec<_>>();
1655        assert!(same_spans(&reversed.source_spans, &expected));
1656    }
1657
1658    #[test]
1659    fn reversal_rejects_a_loop_containing_a_one_way_segment() {
1660        let a = Coord::new(0.0, 0.0);
1661        let b = Coord::new(0.001, 0.0);
1662        let c = Coord::new(0.0005, 0.001);
1663        let mut graph = GraphBuilder::default()
1664            .build(&[draft("ab", a, b), draft("bc", b, c), draft("ca", c, a)])
1665            .expect("build triangle");
1666        let va = vertex_at(&graph, a);
1667        let vb = vertex_at(&graph, b);
1668        let ab = edge_between(&graph, va, vb);
1669        graph.edges[ab.0].attr.travel = if graph.edges[ab.0].a == va {
1670            EdgeTravel::Forward
1671        } else {
1672            EdgeTravel::Backward
1673        };
1674        graph.rebuild_adjacency();
1675
1676        let supports = [a, b, c]
1677            .map(|coord| SupportPoint::forge(coord).expect("valid support"))
1678            .to_vec();
1679        let trail = Trail::forge(RouteShape::Loop, supports, RoutingLaw::default())
1680            .expect("valid loop design");
1681        let constraints = loop_constraints();
1682        let realized = trail
1683            .realize("one-way", &graph, &constraints, 1.0)
1684            .expect("forward direction is lawful");
1685
1686        assert!(matches!(
1687            realized.reverse_loop(&graph),
1688            Err(TrailgenError::OneWayReversal)
1689        ));
1690    }
1691
1692    #[test]
1693    fn support_insertion_follows_realized_leg_order() {
1694        let graph = graph();
1695        let constraints = LoopConstraints {
1696            min_distance_m: 0.0,
1697            max_distance_m: f64::MAX,
1698            max_repeated_edge_fraction: 0.0,
1699            allowed_shapes: vec![RouteShape::Loop],
1700            ..LoopConstraints::default()
1701        };
1702        let route = crate::ExactLoopSolver::default()
1703            .enumerate(&graph, VertexId(0), &constraints, 1)
1704            .into_iter()
1705            .next()
1706            .expect("fixture has a loop");
1707        let trail = Trail::infer(&graph, &route, RoutingLaw::default())
1708            .expect("loop has an exact support design");
1709        let realized = trail
1710            .realize("editable", &graph, &constraints, 1.0)
1711            .expect("realize recovered loop");
1712        let routed = realized.graph();
1713
1714        for slot in 1..realized.support_offsets.len() {
1715            let first = realized.support_offsets[slot - 1];
1716            let limit = realized.support_offsets[slot];
1717            if first == limit {
1718                continue;
1719            }
1720            let edge = &routed.edges[realized.route.edges[first].0];
1721            let insertion = realized
1722                .support_insertion(line_coord_at(
1723                    &edge.geometry,
1724                    edge.geometry.length_m() / 2.0,
1725                ))
1726                .expect("route edge admits insertion");
1727            assert_eq!(insertion.slot, slot);
1728            assert!(insertion.distance_m < 0.01);
1729        }
1730
1731        let closure = *realized.support_offsets.last().expect("support offset");
1732        if closure < realized.route.edges.len() {
1733            let edge = &routed.edges[realized.route.edges[closure].0];
1734            let insertion = realized
1735                .support_insertion(line_coord_at(
1736                    &edge.geometry,
1737                    edge.geometry.length_m() / 2.0,
1738                ))
1739                .expect("closure edge admits insertion");
1740            assert_eq!(insertion.slot, realized.bindings.len());
1741        }
1742    }
1743
1744    #[test]
1745    fn non_shortest_candidate_edges_recover_lossless_interior_supports() {
1746        let a = Coord::new(0.0, 0.0);
1747        let b = Coord::new(0.002, 0.0);
1748        let c = Coord::new(0.001, 0.000_4);
1749        let draft = |name: &str, from: Coord, to: Coord, road_exposure| SegmentDraft {
1750            geometry: LineString::new(vec![from, to]).expect("valid segment"),
1751            junctions: JunctionPolicy::Planar,
1752            turn_ref: None,
1753            junction_keys: None,
1754            turn_restrictions: Vec::new(),
1755            way_kind: WayKind::Path,
1756            realm: WayRealm::default(),
1757            geometry_claim: GeometryClaim::default(),
1758            crossing_control: CrossingControl::default(),
1759            standing: TrailStanding::Established,
1760            marking: crate::TrailMarking::default(),
1761            terrain: Terrain::Trail,
1762            terrain_confidence: Some(1.0),
1763            surface: Some("dirt".to_owned()),
1764            access: Access::Open,
1765            travel: EdgeTravel::Both,
1766            road_exposure,
1767            confidence: 1.0,
1768            provenance: vec![Provenance::fixture(name)],
1769        };
1770        let graph = GraphBuilder::default()
1771            .build(&[
1772                draft("direct", a, b, 1.0),
1773                draft("detour-a", a, c, 0.0),
1774                draft("detour-b", c, b, 0.0),
1775            ])
1776            .expect("build triangle");
1777        let vertex = |coord: Coord| {
1778            graph
1779                .vertices
1780                .iter()
1781                .find(|vertex| vertex.coord.planar_distance2(coord) < 1.0e-16)
1782                .expect("coordinate vertex")
1783                .id
1784        };
1785        let edge = |from: VertexId, to: VertexId| {
1786            graph.adjacency[from.0]
1787                .iter()
1788                .copied()
1789                .find(|edge| graph.edges[edge.0].other(from) == Some(to))
1790                .expect("adjacent vertices")
1791        };
1792        let va = vertex(a);
1793        let vb = vertex(b);
1794        let vc = vertex(c);
1795        let original = vec![edge(va, vb), edge(vb, vc), edge(vc, va)];
1796        let constraints = LoopConstraints {
1797            min_distance_m: 0.0,
1798            max_distance_m: f64::MAX,
1799            max_repeated_edge_fraction: 0.0,
1800            allowed_shapes: vec![RouteShape::Loop],
1801            ..LoopConstraints::default()
1802        };
1803        let route = Route::from_edges("non-shortest", &graph, va, original, &constraints);
1804        let trail = Trail::infer(&graph, &route, RoutingLaw::default())
1805            .expect("non-shortest edge gains an interior support");
1806        assert!(
1807            trail.support_points.len() >= 3,
1808            "interior support should make the direct road edge compulsory"
1809        );
1810        let realized = trail
1811            .realize("recovered", &graph, &constraints, 1.0)
1812            .expect("realize inferred controls");
1813        assert_eq!(realized.route.metrics.shape, RouteShape::Loop);
1814        assert!((realized.route.metrics.distance_m - route.metrics.distance_m).abs() < 0.01);
1815    }
1816
1817    #[test]
1818    fn closed_and_private_edges_are_not_routing_penalties() {
1819        let mut graph = graph();
1820        for edge in &mut graph.edges {
1821            edge.attr.access = Access::Closed;
1822        }
1823        assert!(
1824            shortest_path(
1825                &graph,
1826                VertexId(0),
1827                VertexId(1),
1828                None,
1829                RoutingLaw::default(),
1830                None
1831            )
1832            .is_none()
1833        );
1834    }
1835
1836    #[test]
1837    fn road_aversion_prefers_a_modest_trail_detour_without_banning_roads() {
1838        let draft = |points, terrain, road_exposure, name| SegmentDraft {
1839            geometry: LineString::new(points).expect("valid line"),
1840            junctions: JunctionPolicy::Planar,
1841            turn_ref: None,
1842            junction_keys: None,
1843            turn_restrictions: Vec::new(),
1844            way_kind: WayKind::Path,
1845            realm: WayRealm::default(),
1846            geometry_claim: GeometryClaim::default(),
1847            crossing_control: CrossingControl::default(),
1848            standing: TrailStanding::Established,
1849            marking: crate::TrailMarking::default(),
1850            terrain,
1851            terrain_confidence: Some(1.0),
1852            surface: None,
1853            access: Access::Open,
1854            travel: EdgeTravel::Both,
1855            road_exposure,
1856            confidence: 1.0,
1857            provenance: vec![Provenance::fixture(name)],
1858        };
1859        let start_coord = Coord::new(0.0, 0.0);
1860        let end_coord = Coord::new(0.002, 0.0);
1861        let graph = GraphBuilder::default()
1862            .build(&[
1863                draft(
1864                    vec![start_coord, end_coord],
1865                    Terrain::Road,
1866                    1.0,
1867                    "short-road",
1868                ),
1869                draft(
1870                    vec![start_coord, Coord::new(0.001, 0.001), end_coord],
1871                    Terrain::Trail,
1872                    0.0,
1873                    "trail-detour",
1874                ),
1875            ])
1876            .expect("build fork");
1877        let start = graph.nearest_vertex(start_coord).expect("start vertex");
1878        let end = graph.nearest_vertex(end_coord).expect("end vertex");
1879        let trail = shortest_path(&graph, start, end, None, RoutingLaw::default(), None)
1880            .expect("trail detour");
1881        let road = shortest_path(
1882            &graph,
1883            start,
1884            end,
1885            None,
1886            RoutingLaw { road_aversion: 0.0 },
1887            None,
1888        )
1889        .expect("road route");
1890        assert!(trail.len() > road.len());
1891        assert_eq!(graph.edges[road[0].0].attr.terrain, Terrain::Road);
1892        assert!(
1893            trail
1894                .iter()
1895                .all(|edge| graph.edges[edge.0].attr.terrain == Terrain::Trail)
1896        );
1897    }
1898}