route_snapper/
lib.rs

1//! A Rust library to accurately project an ordered list of stops onto a route line.
2//! This version uses a high-performance greedy algorithm with single-level backtracking
3//! to robustly handle complex route overlaps.
4//!
5//! # Algorithm Overview
6//!
7//! 1.  **Anchoring & Spatial Index:** The first stop is fixed to the start of the route
8//!     (`distance = 0`), and the last stop is fixed to the end. An R-tree is built
9//!     from the route's line segments for fast geometric lookups.
10//!
11//! 2.  **Greedy Forward Search:** For each intermediate stop, an adaptive search
12//!     finds the most plausible forward projection. It does this by creating a small
13//!     pool of valid forward candidates from the nearest segments and then selecting
14//!     the one with the best geographic fit (lowest projection error).
15//!
16//! 3.  **Backtracking on Failure:** If projecting `Stop N` fails (no valid forward
17//!     candidates are found), the algorithm assumes the greedy choice for `Stop N-1`
18//!     was incorrect (e.g., snapped to an early part of a large loop). It then:
19//!     a. Backtracks to `Stop N-1`.
20//!     b. Re-runs the search for `Stop N-1`, explicitly excluding the previous projection.
21//!     c. With a new, corrected projection for `Stop N-1`, it retries projecting `Stop N`.
22//!
23//! # Example Usage
24//!
25//! ```rust
26//! use route_snapper::{project_stops, Stop};
27//! use geo_types::{Coord, LineString};
28//!
29//! fn main() {
30//!     let route_line = LineString::from(vec![
31//!         Coord { x: 0.0, y: 0.0 },   // Start (matches Stop "A")
32//!         Coord { x: 100.0, y: 0.0 },
33//!         Coord { x: 200.0, y: 0.0 }, // End of cul-de-sac
34//!         Coord { x: 100.0, y: 0.0 },
35//!         Coord { x: 0.0, y: 0.0 },   // End (matches Stop "D")
36//!     ]);
37//!
38//!     let stops = vec![
39//!         Stop { id: "A".to_string(), location: Coord { x: 0.0, y: 1.0 } },   // First stop
40//!         Stop { id: "B".to_string(), location: Coord { x: 99.0, y: 2.0 } },  // Intermediate
41//!         Stop { id: "C".to_string(), location: Coord { x: 101.0, y: -2.0 } }, // Intermediate (on overlap)
42//!         Stop { id: "D".to_string(), location: Coord { x: 0.0, y: -1.0 } },  // Last stop
43//!     ];
44//!
45//!     let results = project_stops(&route_line, &stops, None).unwrap();
46//!
47//!     assert!((results[0].distance_along_route - 0.0).abs() < 1e-9);    // First stop anchored
48//!     assert!((results[1].distance_along_route - 99.0).abs() < 1e-9);   // Snapped correctly
49//!     assert!((results[2].distance_along_route - 299.0).abs() < 1e-9);  // Snapped correctly on return trip
50//!     assert!((results[3].distance_along_route - 400.0).abs() < 1e-9);  // Last stop anchored
51//! }
52//! ```
53
54use geo::prelude::*;
55use geo_types::{Coord, Line, LineString, Point};
56use rstar::{RTree, AABB};
57
58/// Represents a single bus stop with a unique identifier and location.
59#[derive(Debug, Clone)]
60pub struct Stop {
61    pub id: String,
62    pub location: Coord<f64>,
63}
64
65/// The output struct representing a stop's successful projection onto the route.
66#[derive(Debug, Clone, PartialEq)]
67pub struct ProjectedStop {
68    /// The original stop's ID.
69    pub id: String,
70    /// The original stop's real-world location.
71    pub original_location: Coord<f64>,
72    /// The coordinate of the projection on the route line.
73    pub projected_location: Coord<f64>,
74    /// The cumulative distance from the start of the route line to the projected point.
75    pub distance_along_route: f64,
76    /// The geographical distance between the original location and the projected location (projection error).
77    pub projection_error: f64,
78}
79
80/// A configuration struct for the projection algorithm.
81#[derive(Debug, Clone, Copy)]
82pub struct ProjectionConfig {}
83
84impl Default for ProjectionConfig {
85    fn default() -> Self {
86        Self {}
87    }
88}
89
90/// Custom error types for the library.
91#[derive(Debug, PartialEq)]
92pub enum ProjectionError {
93    /// The provided route `LineString` has fewer than two points.
94    RouteIsEmpty,
95    /// The provided slice of `Stop`s is empty.
96    NoStopsProvided,
97    /// The algorithm failed to find a valid forward projection for a stop, even after backtracking.
98    /// This likely indicates a data quality issue (e.g., out-of-order stops).
99    NoProjectionFound,
100}
101
102/// Internal struct to hold data for each route segment in the R-tree.
103#[derive(Debug, Clone, Copy)]
104struct RouteSegment {
105    line: Line<f64>,
106    cumulative_distance: f64,
107}
108
109/// The number of valid forward candidates to find before stopping the search.
110const CANDIDATE_POOL_SIZE: usize = 5;
111/// A safety limit to prevent infinite loops on malformed data.
112const MAX_SEGMENTS_TO_CHECK: usize = 1000;
113
114/// The main function of the library.
115pub fn project_stops(
116    route_line: &LineString<f64>,
117    stops: &[Stop],
118    _config: Option<ProjectionConfig>,
119) -> Result<Vec<ProjectedStop>, ProjectionError> {
120    if route_line.0.len() < 2 {
121        return Err(ProjectionError::RouteIsEmpty);
122    }
123    if stops.is_empty() {
124        return Err(ProjectionError::NoStopsProvided);
125    }
126    if stops.len() <= 2 {
127        return project_anchored_only(route_line, stops);
128    }
129
130    let cumulative_distances: Vec<f64> = std::iter::once(0.0)
131        .chain(route_line.lines().scan(0.0, |state, line| {
132            *state += Point(line.start).euclidean_distance(&Point(line.end));
133            Some(*state)
134        }))
135        .collect();
136
137    let rtree = build_rtree(route_line, &cumulative_distances);
138    let mut projected_stops = Vec::with_capacity(stops.len());
139
140    // 1. Anchor the first stop
141    projected_stops.push(anchor_stop(&stops[0], &route_line.0[0], 0.0));
142
143    // 2. Process intermediate stops with backtracking logic
144    let mut i = 1;
145    while i < stops.len() - 1 {
146        let stop_to_project = &stops[i];
147        let previous_projection = projected_stops.last().unwrap();
148
149        let mut best_candidate = find_best_candidate(
150            stop_to_project,
151            &rtree,
152            previous_projection.distance_along_route,
153            None, // No exclusions on the first try
154        );
155
156        // BACKTRACKING LOGIC
157        if best_candidate.is_none() && i > 0 {
158            let bad_projection = projected_stops.pop().unwrap();
159            let last_good_projection_dist =
160                projected_stops.last().map_or(0.0, |p| p.distance_along_route);
161
162            let stop_to_fix = &stops[i - 1];
163            if let Some(fixed_projection) = find_best_candidate(
164                stop_to_fix,
165                &rtree,
166                last_good_projection_dist,
167                Some(bad_projection.distance_along_route),
168            ) {
169                projected_stops.push(fixed_projection);
170                best_candidate = find_best_candidate(
171                    stop_to_project,
172                    &rtree,
173                    projected_stops.last().unwrap().distance_along_route,
174                    None,
175                );
176            } else {
177                projected_stops.push(bad_projection); // Put it back if we couldn't find an alternative
178            }
179        }
180
181        if let Some(winner) = best_candidate {
182            projected_stops.push(winner);
183            i += 1;
184        } else {
185            return Err(ProjectionError::NoProjectionFound);
186        }
187    }
188
189    // 3. Anchor the last stop
190    let total_route_length = *cumulative_distances.last().unwrap();
191    projected_stops.push(anchor_stop(
192        stops.last().unwrap(),
193        route_line.0.last().unwrap(),
194        total_route_length,
195    ));
196
197    Ok(projected_stops)
198}
199
200/// Helper for cases with only 0, 1, or 2 stops, which are purely anchored.
201fn project_anchored_only(
202    route_line: &LineString<f64>,
203    stops: &[Stop],
204) -> Result<Vec<ProjectedStop>, ProjectionError> {
205    let mut projected_stops = Vec::new();
206    if stops.is_empty() {
207        return Ok(projected_stops);
208    }
209
210    projected_stops.push(anchor_stop(&stops[0], &route_line.0[0], 0.0));
211
212    if stops.len() > 1 {
213        let total_route_length = route_line.lines().map(|l| l.euclidean_length()).sum();
214        projected_stops.push(anchor_stop(
215            stops.last().unwrap(),
216            route_line.0.last().unwrap(),
217            total_route_length,
218        ));
219    }
220    Ok(projected_stops)
221}
222
223/// Creates a `ProjectedStop` anchored to a specific point and distance.
224fn anchor_stop(stop: &Stop, location: &Coord<f64>, distance: f64) -> ProjectedStop {
225    ProjectedStop {
226        id: stop.id.clone(),
227        original_location: stop.location,
228        projected_location: *location,
229        distance_along_route: distance,
230        projection_error: Point(stop.location).euclidean_distance(&Point(*location)),
231    }
232}
233
234/// The core adaptive search function. Now accepts an optional distance to exclude.
235fn find_best_candidate(
236    stop: &Stop,
237    rtree: &RTree<RouteSegment>,
238    last_distance_along_route: f64,
239    exclude_distance: Option<f64>,
240) -> Option<ProjectedStop> {
241    let stop_point = Point::from(stop.location);
242    let search_point = [stop.location.x, stop.location.y];
243    let mut valid_candidates = Vec::new();
244    let mut segments_checked = 0;
245    let tolerance = 1e-9;
246
247    for segment in rtree.nearest_neighbor_iter(&search_point) {
248        segments_checked += 1;
249        let closest_pt = segment.line.closest_point(&stop_point);
250        let (projected_point, projected_location) = match closest_pt {
251            geo::Closest::Intersection(p) | geo::Closest::SinglePoint(p) => (p, p.into()),
252            geo::Closest::Indeterminate => continue,
253        };
254
255        let dist_on_segment = Point(segment.line.start).euclidean_distance(&projected_point);
256        let distance_along_route = segment.cumulative_distance + dist_on_segment;
257
258        if let Some(exclude_dist) = exclude_distance {
259            if (distance_along_route - exclude_dist).abs() < tolerance {
260                continue;
261            }
262        }
263
264        if distance_along_route >= last_distance_along_route {
265            let projection_error = stop_point.euclidean_distance(&projected_point);
266            valid_candidates.push(ProjectedStop {
267                id: stop.id.clone(),
268                original_location: stop.location,
269                projected_location,
270                distance_along_route,
271                projection_error,
272            });
273        }
274
275        if valid_candidates.len() >= CANDIDATE_POOL_SIZE
276            || segments_checked >= MAX_SEGMENTS_TO_CHECK
277        {
278            break;
279        }
280    }
281
282    valid_candidates
283        .into_iter()
284        .min_by(|a, b| a.projection_error.partial_cmp(&b.projection_error).unwrap())
285}
286
287/// Builds the R-tree from pre-calculated cumulative distances.
288fn build_rtree(route_line: &LineString<f64>, cumulative_distances: &[f64]) -> RTree<RouteSegment> {
289    let segments: Vec<RouteSegment> = route_line
290        .lines()
291        .enumerate()
292        .map(|(i, line)| RouteSegment {
293            line,
294            cumulative_distance: cumulative_distances[i],
295        })
296        .collect();
297
298    RTree::bulk_load(segments)
299}
300
301impl rstar::RTreeObject for RouteSegment {
302    type Envelope = AABB<[f64; 2]>;
303
304    fn envelope(&self) -> Self::Envelope {
305        let rect = self.line.bounding_rect();
306        AABB::from_corners([rect.min().x, rect.min().y], [rect.max().x, rect.max().y])
307    }
308}
309
310impl rstar::PointDistance for RouteSegment {
311    fn distance_2(&self, point: &[f64; 2]) -> f64 {
312        let p = Point::new(point[0], point[1]);
313        let closest_point = self.line.closest_point(&p);
314        let d2 = match closest_point {
315            geo::Closest::Intersection(p_on_line) | geo::Closest::SinglePoint(p_on_line) => {
316                p.euclidean_distance(&p_on_line)
317            }
318            geo::Closest::Indeterminate => 0.0,
319        };
320        d2 * d2
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn make_stop(id: &str, x: f64, y: f64) -> Stop {
329        Stop {
330            id: id.to_string(),
331            location: Coord { x, y },
332        }
333    }
334
335    #[test]
336    fn test_anchoring_with_two_stops() {
337        let route_line =
338            LineString::from(vec![Coord { x: 0.0, y: 0.0 }, Coord { x: 100.0, y: 0.0 }]);
339        let stops = vec![make_stop("1", 1.0, 1.0), make_stop("2", 99.0, -1.0)];
340        let results = project_stops(&route_line, &stops, None).unwrap();
341        assert_eq!(results[0].distance_along_route, 0.0);
342        assert_eq!(results[1].distance_along_route, 100.0);
343    }
344
345    #[test]
346    fn test_simple_linear_route_with_intermediate() {
347        let route_line =
348            LineString::from(vec![Coord { x: 0.0, y: 0.0 }, Coord { x: 100.0, y: 0.0 }]);
349        let stops = vec![
350            make_stop("start", 0.0, 1.0),
351            make_stop("mid", 50.0, -2.0),
352            make_stop("end", 100.0, 1.0),
353        ];
354        let results = project_stops(&route_line, &stops, None).unwrap();
355        assert_eq!(results.len(), 3);
356        assert!((results[1].distance_along_route - 50.0).abs() < 1e-9);
357    }
358
359    #[test]
360    fn test_critical_overlap_case() {
361        let route_line = LineString::from(vec![
362            Coord { x: 0.0, y: 0.0 }, Coord { x: 100.0, y: 0.0 }, Coord { x: 200.0, y: 0.0 },
363            Coord { x: 100.0, y: 0.0 }, Coord { x: 0.0, y: 0.0 },
364        ]);
365        let stops = vec![
366            make_stop("start", 0.0, 1.0),
367            make_stop("outbound", 99.0, 2.0),
368            make_stop("turnaround", 201.0, -1.0),
369            make_stop("inbound", 101.0, -2.0),
370            make_stop("end", 0.0, -1.0),
371        ];
372        let results = project_stops(&route_line, &stops, None).unwrap();
373        assert_eq!(results.len(), 5);
374        assert!((results[0].distance_along_route - 0.0).abs() < 1e-9);
375        assert!((results[1].distance_along_route - 99.0).abs() < 1e-9);
376        assert!((results[2].distance_along_route - 200.0).abs() < 1e-9);
377        assert!((results[3].distance_along_route - 299.0).abs() < 1e-9);
378        assert!((results[4].distance_along_route - 400.0).abs() < 1e-9);
379    }
380
381    #[test]
382    fn test_error_conditions() {
383        let empty_route = LineString::from(vec![Coord { x: 0.0, y: 0.0 }]);
384        assert_eq!(
385            project_stops(&empty_route, &[], None),
386            Err(ProjectionError::RouteIsEmpty)
387        );
388        let valid_route =
389            LineString::from(vec![Coord { x: 0.0, y: 0.0 }, Coord { x: 1.0, y: 1.0 }]);
390        assert_eq!(
391            project_stops(&valid_route, &[], None),
392            Err(ProjectionError::NoStopsProvided)
393        );
394    }
395    
396    #[test]
397    fn test_backtracking_on_hairpin_turn() {
398        // This route creates a "hairpin" where a later part of the route is
399        // geographically closer to an earlier stop than its correct segment.
400        let route_line = LineString::from(vec![
401            Coord { x: 0.0, y: 0.0 },    // 0m
402            Coord { x: 100.0, y: 0.0 },   // 100m
403            Coord { x: 100.0, y: 10.0 },  // 110m (start of hairpin)
404            Coord { x: 0.0, y: 10.0 },    // 210m (end of hairpin)
405            Coord { x: 0.0, y: 20.0 },    // 220m
406        ]);
407
408        let stops = vec![
409            make_stop("A", 0.0, 1.0),     // Anchor Start
410            // Stop B is at x=90. The greedy choice will be the first segment (dist 90).
411            make_stop("B", 90.0, -1.0),
412            // Stop C is at x=10. The greedy choice from B's dist=90 would be the
413            // hairpin return segment (dist 200), because the first segment (dist 10) is behind.
414            // BUT, the geographically closest segment to B is actually the hairpin return (dist ~200).
415            // A simple greedy algorithm would snap B to dist ~200, which would then fail for C.
416            // Our backtracking should fix this.
417            make_stop("C", 10.0, 11.0),
418            make_stop("D", 0.0, 19.0),     // Anchor End
419        ];
420        
421        let results = project_stops(&route_line, &stops, None).unwrap();
422
423        assert_eq!(results.len(), 4);
424        // A is anchored
425        assert!((results[0].distance_along_route - 0.0).abs() < 1e-9);
426        // B should be correctly placed on the first segment
427        assert!((results[1].distance_along_route - 90.0).abs() < 1e-9);
428        // C should be correctly placed on the hairpin return
429        assert!((results[2].distance_along_route - 200.0).abs() < 1e-9);
430        // D is anchored
431        assert!((results[3].distance_along_route - 220.0).abs() < 1e-9);
432    }
433}