Skip to main content

tp_lib_core/projection/
geom.rs

1//! Geometric projection operations
2
3use crate::errors::ProjectionError;
4use crate::models::{GnssPosition, ProjectedPosition};
5use geo::algorithm::HaversineDistance;
6use geo::{LineString, Point};
7
8#[cfg(test)]
9use geo::Coord;
10
11/// Project a point onto the nearest location on a LineString.
12///
13/// Uses an equirectangular approximation (cos(lat) scaling on longitude) so
14/// that the closest-point computation is metrically correct for geographic
15/// (WGS 84) coordinates.
16pub fn project_point_onto_linestring(
17    point: &Point<f64>,
18    linestring: &LineString<f64>,
19) -> Result<Point<f64>, ProjectionError> {
20    let coords = &linestring.0;
21    if coords.len() < 2 {
22        return Err(ProjectionError::InvalidGeometry(
23            "Linestring must have at least 2 points for projection".to_string(),
24        ));
25    }
26
27    let cos_lat = point.y().to_radians().cos();
28    let mut min_dist_sq = f64::INFINITY;
29    let mut best = coords[0];
30
31    for i in 0..coords.len() - 1 {
32        let p1 = &coords[i];
33        let p2 = &coords[i + 1];
34
35        // Work in a locally-scaled coordinate frame where distances
36        // approximate true metric distances (up to a constant factor).
37        let dx = (p2.x - p1.x) * cos_lat;
38        let dy = p2.y - p1.y;
39        let len_sq = dx * dx + dy * dy;
40
41        let t = if len_sq > 0.0 {
42            let px = (point.x() - p1.x) * cos_lat;
43            let py = point.y() - p1.y;
44            ((px * dx + py * dy) / len_sq).clamp(0.0, 1.0)
45        } else {
46            0.0
47        };
48
49        // Interpolate back in degree space.
50        let proj_x = p1.x + t * (p2.x - p1.x);
51        let proj_y = p1.y + t * (p2.y - p1.y);
52
53        let ex = (point.x() - proj_x) * cos_lat;
54        let ey = point.y() - proj_y;
55        let dist_sq = ex * ex + ey * ey;
56
57        if dist_sq < min_dist_sq {
58            min_dist_sq = dist_sq;
59            best = geo::Coord {
60                x: proj_x,
61                y: proj_y,
62            };
63        }
64    }
65
66    Ok(Point::from(best))
67}
68
69/// Calculate the distance along a linestring from its start to a given point.
70///
71/// Locates the segment closest to `point` using an equirectangular
72/// approximation (cos(lat) scaling), then accumulates haversine distances
73/// up to that segment plus the fractional part within it.
74pub fn calculate_measure_along_linestring(
75    linestring: &LineString<f64>,
76    point: &Point<f64>,
77) -> Result<f64, ProjectionError> {
78    let coords = &linestring.0;
79    if coords.is_empty() {
80        return Err(ProjectionError::InvalidGeometry(
81            "Cannot calculate measure on empty linestring".to_string(),
82        ));
83    }
84    if coords.len() < 2 {
85        return Err(ProjectionError::InvalidGeometry(
86            "Linestring must have at least 2 points".to_string(),
87        ));
88    }
89
90    // Find the segment the point projects onto (same metric as
91    // project_point_onto_linestring).
92    let cos_lat = point.y().to_radians().cos();
93    let mut min_dist_sq = f64::INFINITY;
94    let mut best_seg: usize = 0;
95    let mut best_t: f64 = 0.0;
96
97    for i in 0..coords.len() - 1 {
98        let p1 = &coords[i];
99        let p2 = &coords[i + 1];
100
101        let dx = (p2.x - p1.x) * cos_lat;
102        let dy = p2.y - p1.y;
103        let len_sq = dx * dx + dy * dy;
104
105        let t = if len_sq > 0.0 {
106            let px = (point.x() - p1.x) * cos_lat;
107            let py = point.y() - p1.y;
108            ((px * dx + py * dy) / len_sq).clamp(0.0, 1.0)
109        } else {
110            0.0
111        };
112
113        let proj_x = p1.x + t * (p2.x - p1.x);
114        let proj_y = p1.y + t * (p2.y - p1.y);
115        let ex = (point.x() - proj_x) * cos_lat;
116        let ey = point.y() - proj_y;
117        let dist_sq = ex * ex + ey * ey;
118
119        if dist_sq < min_dist_sq {
120            min_dist_sq = dist_sq;
121            best_seg = i;
122            best_t = t;
123        }
124    }
125
126    // Accumulate haversine distances for complete segments before best_seg.
127    let mut measure = 0.0;
128    for i in 0..best_seg {
129        let a = Point::new(coords[i].x, coords[i].y);
130        let b = Point::new(coords[i + 1].x, coords[i + 1].y);
131        measure += a.haversine_distance(&b);
132    }
133
134    // Add fractional distance within the best segment.
135    let seg_start = Point::new(coords[best_seg].x, coords[best_seg].y);
136    let seg_end = Point::new(coords[best_seg + 1].x, coords[best_seg + 1].y);
137    measure += best_t * seg_start.haversine_distance(&seg_end);
138
139    Ok(measure)
140}
141
142/// Project a GNSS position onto a netelement
143pub fn project_gnss_position(
144    gnss: &GnssPosition,
145    netelement_id: String,
146    linestring: &LineString<f64>,
147    crs: String,
148) -> Result<ProjectedPosition, ProjectionError> {
149    // Convert GNSS position to Point
150    let gnss_point = Point::new(gnss.longitude, gnss.latitude);
151
152    // Project onto linestring
153    let projected = project_point_onto_linestring(&gnss_point, linestring)?;
154
155    // Calculate measure
156    let measure = calculate_measure_along_linestring(linestring, &projected)?;
157
158    // Calculate projection distance using haversine
159    let projection_distance = gnss_point.haversine_distance(&projected);
160
161    Ok(ProjectedPosition::new(
162        gnss.clone(),
163        projected,
164        netelement_id,
165        measure,
166        projection_distance,
167        crs,
168    ))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use chrono::{FixedOffset, TimeZone};
175
176    #[test]
177    fn test_project_point_on_line() {
178        let linestring =
179            LineString::from(vec![Coord { x: 0.0, y: 0.0 }, Coord { x: 10.0, y: 0.0 }]);
180
181        let point = Point::new(5.0, 2.0);
182        let projected = project_point_onto_linestring(&point, &linestring);
183
184        assert!(projected.is_ok());
185        let result = projected.unwrap();
186        // Should be projected onto the line at (5.0, 0.0) approximately
187        assert!((result.y()).abs() < 0.1);
188    }
189
190    #[test]
191    fn test_calculate_measure_empty_linestring() {
192        let linestring = LineString::<f64>::new(vec![]);
193        let point = Point::new(4.0, 50.0);
194        let result = calculate_measure_along_linestring(&linestring, &point);
195        assert!(result.is_err());
196        if let Err(ProjectionError::InvalidGeometry(msg)) = result {
197            assert!(msg.contains("empty"));
198        }
199    }
200
201    #[test]
202    fn test_calculate_measure_single_point_linestring() {
203        let linestring = LineString::from(vec![Coord { x: 4.0, y: 50.0 }]);
204        let point = Point::new(4.0, 50.0);
205        let result = calculate_measure_along_linestring(&linestring, &point);
206        assert!(result.is_err());
207        if let Err(ProjectionError::InvalidGeometry(msg)) = result {
208            assert!(msg.contains("at least 2 points"));
209        }
210    }
211
212    #[test]
213    fn test_calculate_measure_at_start() {
214        // Simple linestring: (4.0, 50.0) -> (5.0, 50.0)
215        let linestring =
216            LineString::from(vec![Coord { x: 4.0, y: 50.0 }, Coord { x: 5.0, y: 50.0 }]);
217        let point = Point::new(4.0, 50.0);
218        let result = calculate_measure_along_linestring(&linestring, &point);
219        assert!(result.is_ok());
220        let measure = result.unwrap();
221        // Should be very close to 0 (at start)
222        assert!(
223            measure < 10.0,
224            "Measure at start should be near 0, got {}",
225            measure
226        );
227    }
228
229    #[test]
230    fn test_calculate_measure_at_end() {
231        // Simple linestring: (4.0, 50.0) -> (5.0, 50.0)
232        let linestring =
233            LineString::from(vec![Coord { x: 4.0, y: 50.0 }, Coord { x: 5.0, y: 50.0 }]);
234        let point = Point::new(5.0, 50.0);
235        let result = calculate_measure_along_linestring(&linestring, &point);
236        assert!(result.is_ok());
237        let measure = result.unwrap();
238
239        // Calculate expected total length
240        let start = Point::new(4.0, 50.0);
241        let end = Point::new(5.0, 50.0);
242        let expected_length = start.haversine_distance(&end);
243
244        // Measure should be close to total length
245        assert!(
246            (measure - expected_length).abs() < 10.0,
247            "Measure at end should be near {}, got {}",
248            expected_length,
249            measure
250        );
251    }
252
253    #[test]
254    fn test_calculate_measure_at_middle() {
255        // Simple linestring: (4.0, 50.0) -> (4.5, 50.0) -> (5.0, 50.0)
256        let linestring = LineString::from(vec![
257            Coord { x: 4.0, y: 50.0 },
258            Coord { x: 4.5, y: 50.0 },
259            Coord { x: 5.0, y: 50.0 },
260        ]);
261        let point = Point::new(4.5, 50.0);
262        let result = calculate_measure_along_linestring(&linestring, &point);
263        assert!(result.is_ok());
264        let measure = result.unwrap();
265
266        // Calculate expected distance to middle
267        let start = Point::new(4.0, 50.0);
268        let middle = Point::new(4.5, 50.0);
269        let expected_measure = start.haversine_distance(&middle);
270
271        // Measure should be close to distance to middle point
272        assert!(
273            (measure - expected_measure).abs() < 10.0,
274            "Measure at middle should be near {}, got {}",
275            expected_measure,
276            measure
277        );
278    }
279
280    #[test]
281    fn test_calculate_measure_point_off_line() {
282        // Linestring: (4.0, 50.0) -> (5.0, 50.0)
283        let linestring =
284            LineString::from(vec![Coord { x: 4.0, y: 50.0 }, Coord { x: 5.0, y: 50.0 }]);
285        // Point off the line (perpendicular offset)
286        let point = Point::new(4.5, 50.1);
287        let result = calculate_measure_along_linestring(&linestring, &point);
288        assert!(result.is_ok());
289        let measure = result.unwrap();
290
291        // Should project to nearest point on line, which is around middle
292        let start = Point::new(4.0, 50.0);
293        let projected_approx = Point::new(4.5, 50.0);
294        let expected_measure = start.haversine_distance(&projected_approx);
295
296        // Measure should be close to distance to projected point
297        assert!(
298            (measure - expected_measure).abs() < 1000.0,
299            "Measure for point off line should be near {}, got {}",
300            expected_measure,
301            measure
302        );
303    }
304
305    #[test]
306    fn test_project_gnss_position() {
307        // Create a simple linestring
308        let linestring =
309            LineString::from(vec![Coord { x: 4.0, y: 50.0 }, Coord { x: 5.0, y: 50.0 }]);
310
311        // Create a GNSS position near the middle
312        let timestamp = FixedOffset::east_opt(0)
313            .unwrap()
314            .with_ymd_and_hms(2024, 1, 1, 12, 0, 0)
315            .unwrap();
316        let gnss = GnssPosition::new(4.5, 50.01, timestamp, "EPSG:4326".to_string()).unwrap();
317
318        let result = project_gnss_position(
319            &gnss,
320            "NE123".to_string(),
321            &linestring,
322            "EPSG:4326".to_string(),
323        );
324
325        assert!(result.is_ok());
326        let projected = result.unwrap();
327
328        // Verify basic fields
329        assert_eq!(projected.netelement_id, "NE123");
330        assert_eq!(projected.crs, "EPSG:4326");
331
332        // Verify projection distance is calculated (should be > 0 since point is off line)
333        assert!(
334            projected.projection_distance_meters > 0.0,
335            "Projection distance should be positive, got {}",
336            projected.projection_distance_meters
337        );
338
339        // Verify measure is reasonable (somewhere along the linestring, not negative)
340        assert!(
341            projected.measure_meters >= 0.0,
342            "Measure should be non-negative, got {}",
343            projected.measure_meters
344        );
345
346        // Verify measure is not beyond the end of the linestring
347        let start = Point::new(4.0, 50.0);
348        let end = Point::new(5.0, 50.0);
349        let total_length = start.haversine_distance(&end);
350        assert!(
351            projected.measure_meters <= total_length + 1.0,
352            "Measure should not exceed linestring length {}, got {}",
353            total_length,
354            projected.measure_meters
355        );
356    }
357}