Skip to main content

tp_lib_core/projection/
spatial.rs

1//! Spatial indexing for efficient nearest-netelement queries
2
3use crate::errors::ProjectionError;
4use crate::models::Netelement;
5use geo::Point;
6use rstar::{PointDistance, RTree, RTreeObject, AABB};
7
8/// Wrapper for netelement entries in the R-tree with bounding box
9#[derive(Debug, Clone)]
10struct NetelementIndexEntry {
11    /// Index of the netelement in the original Vec<Netelement>
12    index: usize,
13    /// Bounding box of the netelement geometry (min/max lon/lat)
14    bbox: AABB<[f64; 2]>,
15    /// Segments of the linestring geometry stored as pairs of endpoints.
16    /// Used by `distance_2` to compute the true point-to-linestring distance
17    /// instead of the coarser bounding-box distance.  Without this, points
18    /// that fall *inside* multiple bounding boxes all get distance² = 0,
19    /// which makes `nearest_neighbor` return an arbitrary (often wrong) result.
20    segments: Vec<([f64; 2], [f64; 2])>,
21}
22
23impl RTreeObject for NetelementIndexEntry {
24    type Envelope = AABB<[f64; 2]>;
25
26    fn envelope(&self) -> Self::Envelope {
27        self.bbox
28    }
29}
30
31/// Squared Euclidean distance from a point to a line segment (in degree units).
32///
33/// Returns the minimum squared distance from `p` to the segment `[a, b]`.
34#[inline]
35fn point_to_segment_dist_2(p: &[f64; 2], a: &[f64; 2], b: &[f64; 2]) -> f64 {
36    let dx = b[0] - a[0];
37    let dy = b[1] - a[1];
38    let len_sq = dx * dx + dy * dy;
39    let (px, py) = if len_sq == 0.0 {
40        // Degenerate segment: both endpoints are identical
41        (a[0], a[1])
42    } else {
43        let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len_sq;
44        let t = t.clamp(0.0, 1.0);
45        (a[0] + t * dx, a[1] + t * dy)
46    };
47    let ex = p[0] - px;
48    let ey = p[1] - py;
49    ex * ex + ey * ey
50}
51
52impl PointDistance for NetelementIndexEntry {
53    fn distance_2(&self, point: &[f64; 2]) -> f64 {
54        // Use the actual point-to-linestring distance (squared, in degree units)
55        // instead of the bounding-box distance.  The bbox distance is 0 for any
56        // point that lies inside the bbox, which makes it impossible for the
57        // R-tree to distinguish between overlapping netelements.
58        self.segments
59            .iter()
60            .map(|(a, b)| point_to_segment_dist_2(point, a, b))
61            .fold(f64::MAX, f64::min)
62    }
63}
64
65/// Spatial index for netelements using R-tree
66#[derive(Clone)]
67pub struct NetworkIndex {
68    tree: RTree<NetelementIndexEntry>,
69    netelements: Vec<Netelement>,
70}
71
72impl NetworkIndex {
73    /// Build spatial index from netelements
74    pub fn new(netelements: Vec<Netelement>) -> Result<Self, ProjectionError> {
75        if netelements.is_empty() {
76            return Err(ProjectionError::EmptyNetwork);
77        }
78
79        // Build R-tree entries with bounding boxes and segment lists
80        let mut entries = Vec::new();
81
82        // Loop over all netelements
83        for (index, netelement) in netelements.iter().enumerate() {
84            let coords = &netelement.geometry.0;
85            if coords.is_empty() {
86                continue; // Skip empty geometries
87            }
88
89            // Calculate bounding box of netelement
90            let mut min_x = f64::MAX;
91            let mut max_x = f64::MIN;
92            let mut min_y = f64::MAX;
93            let mut max_y = f64::MIN;
94
95            for coord in coords {
96                min_x = min_x.min(coord.x);
97                max_x = max_x.max(coord.x);
98                min_y = min_y.min(coord.y);
99                max_y = max_y.max(coord.y);
100            }
101
102            let bbox = AABB::from_corners([min_x, min_y], [max_x, max_y]);
103
104            // Store linestring segments for accurate distance computation
105            let segments: Vec<([f64; 2], [f64; 2])> = coords
106                .windows(2)
107                .map(|w| ([w[0].x, w[0].y], [w[1].x, w[1].y]))
108                .collect();
109
110            entries.push(NetelementIndexEntry {
111                index,
112                bbox,
113                segments,
114            });
115        }
116
117        let tree = RTree::bulk_load(entries);
118
119        Ok(Self { tree, netelements })
120    }
121
122    /// Get a reference to the netelements
123    pub fn netelements(&self) -> &[Netelement] {
124        &self.netelements
125    }
126}
127
128/// Find the nearest netelement to a given point using R-tree spatial index
129pub fn find_nearest_netelement(
130    point: &Point<f64>,
131    index: &NetworkIndex,
132) -> Result<usize, ProjectionError> {
133    if index.netelements.is_empty() {
134        return Err(ProjectionError::EmptyNetwork);
135    }
136
137    let query_point = [point.x(), point.y()];
138
139    // nearest_neighbor uses PointDistance::distance_2, which now computes the
140    // true point-to-linestring distance, so this returns the geometrically
141    // nearest netelement even when the query point is inside multiple bboxes.
142    let nearest_entry = index.tree.nearest_neighbor(&query_point).ok_or_else(|| {
143        ProjectionError::InvalidGeometry("Could not find nearest netelement".to_string())
144    })?;
145
146    Ok(nearest_entry.index)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use geo::{Coord, LineString};
153
154    #[test]
155    fn test_network_index_empty() {
156        let result = NetworkIndex::new(vec![]);
157        assert!(result.is_err());
158        if let Err(ProjectionError::EmptyNetwork) = result {
159            // Expected
160        } else {
161            panic!("Expected EmptyNetwork error");
162        }
163    }
164
165    #[test]
166    fn test_network_index_single_netelement() {
167        let linestring =
168            LineString::from(vec![Coord { x: 4.0, y: 50.0 }, Coord { x: 5.0, y: 50.0 }]);
169        let netelement =
170            Netelement::new("NE1".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
171
172        let index = NetworkIndex::new(vec![netelement]);
173        assert!(index.is_ok());
174        let index = index.unwrap();
175        assert_eq!(index.netelements().len(), 1);
176    }
177
178    #[test]
179    fn test_find_nearest_netelement() {
180        // Create two netelements
181        let linestring1 =
182            LineString::from(vec![Coord { x: 4.0, y: 50.0 }, Coord { x: 5.0, y: 50.0 }]);
183        let netelement1 =
184            Netelement::new("NE1".to_string(), linestring1, "EPSG:4326".to_string()).unwrap();
185
186        let linestring2 =
187            LineString::from(vec![Coord { x: 6.0, y: 51.0 }, Coord { x: 7.0, y: 51.0 }]);
188        let netelement2 =
189            Netelement::new("NE2".to_string(), linestring2, "EPSG:4326".to_string()).unwrap();
190
191        let index = NetworkIndex::new(vec![netelement1, netelement2]).unwrap();
192
193        // Point closer to first netelement
194        let point1 = Point::new(4.5, 50.0);
195        let nearest1 = find_nearest_netelement(&point1, &index).unwrap();
196        assert_eq!(nearest1, 0, "Point should be nearest to first netelement");
197
198        // Point closer to second netelement
199        let point2 = Point::new(6.5, 51.0);
200        let nearest2 = find_nearest_netelement(&point2, &index).unwrap();
201        assert_eq!(nearest2, 1, "Point should be nearest to second netelement");
202    }
203
204    /// Regression test: when the query point falls inside multiple bounding boxes,
205    /// the R-tree must still return the geometrically nearest linestring, not an
206    /// arbitrary one with bbox-distance = 0.
207    ///
208    /// Setup (approximate layout, not to scale):
209    ///
210    /// ```text
211    ///  y=51.0 ─────────────── A[0]────────────────── A[1]
212    ///                                 ^ query     ↑ B[0]
213    ///  y=50.9 ────────────────────────────────────────────
214    ///                                             ↓ B[1]
215    /// ```
216    ///
217    /// Both netelements' bboxes contain the query point, so the old bbox-based
218    /// implementation returns the wrong result.  The query point is 0.1° above
219    /// netelement B but only 0.01° below netelement A, so A is nearest.
220    #[test]
221    fn test_find_nearest_with_overlapping_bboxes() {
222        // Netelement A: horizontal line at y = 51.0, x from 4.0 to 6.0
223        //   bbox: x=[4.0, 6.0], y=[51.0, 51.0]  (degenerate in y)
224        let ls_a = LineString::from(vec![Coord { x: 4.0, y: 51.0 }, Coord { x: 6.0, y: 51.0 }]);
225        let ne_a = Netelement::new("A".to_string(), ls_a, "EPSG:4326".to_string()).unwrap();
226
227        // Netelement B: vertical line at x = 5.5, y from 50.8 to 51.2 (tall bbox)
228        //   bbox: x=[5.5, 5.5], y=[50.8, 51.2]
229        let ls_b = LineString::from(vec![Coord { x: 5.5, y: 50.8 }, Coord { x: 5.5, y: 51.2 }]);
230        let ne_b = Netelement::new("B".to_string(), ls_b, "EPSG:4326".to_string()).unwrap();
231
232        let index = NetworkIndex::new(vec![ne_a, ne_b]).unwrap();
233
234        // Query point: (5.0, 50.99)
235        //   Distance to A (y=51.0): 0.01° in y  → dist² ≈ 0.0001
236        //   Distance to B (x=5.5):  0.50° in x  → dist² ≈ 0.25
237        //   → A is clearly closer
238        let query = Point::new(5.0, 50.99);
239        let nearest = find_nearest_netelement(&query, &index).unwrap();
240        assert_eq!(
241            nearest, 0,
242            "Point at (5.0, 50.99) should be nearest to netelement A (idx 0), not B (idx 1)"
243        );
244    }
245
246    #[test]
247    fn test_point_to_segment_dist_2_midpoint() {
248        // Closest point on [a,b] to p is the midpoint
249        let p = [0.0, 1.0];
250        let a = [0.0, 0.0];
251        let b = [2.0, 0.0];
252        // Foot is (0,0), distance² = 1
253        let d = point_to_segment_dist_2(&p, &a, &b);
254        assert!((d - 1.0).abs() < 1e-12, "expected 1.0, got {}", d);
255    }
256
257    #[test]
258    fn test_point_to_segment_dist_2_beyond_endpoint() {
259        // Closest point is endpoint b
260        let p = [3.0, 0.0];
261        let a = [0.0, 0.0];
262        let b = [2.0, 0.0];
263        // Foot is b=(2,0), distance² = 1
264        let d = point_to_segment_dist_2(&p, &a, &b);
265        assert!((d - 1.0).abs() < 1e-12, "expected 1.0, got {}", d);
266    }
267}