Skip to main content

rustial_engine/
simplify.rs

1// ---------------------------------------------------------------------------
2//! # Douglas-Peucker line simplification for vector LOD
3//!
4//! This module provides the [Douglas-Peucker][dp] algorithm for reducing
5//! the vertex count of polylines and polygon rings while preserving their
6//! visual shape within a caller-specified tolerance.
7//!
8//! ## Intended use
9//!
10//! The engine uses this during zoom-dependent level-of-detail (LOD)
11//! reduction in [`VectorLayer`](crate::layers::VectorLayer) and
12//! exposes it through the public API so that host applications can
13//! pre-simplify very large datasets before feeding them into layers.
14//!
15//! ## Coordinate space
16//!
17//! All distance calculations are performed in the **degree plane**
18//! (lon as X, lat as Y).  This is a deliberate trade-off:
19//!
20//! - At the equator, 1 degree of longitude ~ 111 km and 1 degree of
21//!   latitude ~ 111 km, so the metric is approximately isotropic.
22//! - At higher latitudes, longitude degrees shrink by `cos(lat)`, so
23//!   east-west features will be simplified more aggressively than
24//!   north-south features at the same `epsilon`.  For map display
25//!   purposes this is acceptable because Web Mercator stretches
26//!   east-west distances proportionally, cancelling the distortion on
27//!   screen.
28//! - For truly metric simplification, project to Web Mercator meters
29//!   first, simplify, then unproject.  The engine may do this
30//!   internally in a future release.
31//!
32//! ## Altitude
33//!
34//! The altitude component (`GeoCoord::alt`) is **carried through** but
35//! does **not** participate in the distance calculation.  A vertex is
36//! retained or discarded based solely on its 2D (lat/lon) deviation
37//! from the polyline spine.
38//!
39//! ## Algorithm complexity
40//!
41//! - Worst case: O(n^2) when every recursive split isolates a single
42//!   point (e.g. a zigzag polyline).
43//! - Average case on natural geographic data: O(n log n).
44//!
45//! [dp]: https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
46// ---------------------------------------------------------------------------
47
48use rustial_math::GeoCoord;
49
50// ---------------------------------------------------------------------------
51// Public API
52// ---------------------------------------------------------------------------
53
54/// Simplify a polyline using the Douglas-Peucker algorithm.
55///
56/// `epsilon` is the maximum allowed perpendicular distance **in degrees**
57/// (see [module docs](self) for the coordinate-space rationale).  Smaller
58/// values retain more detail; zero retains every vertex.
59///
60/// # Returns
61///
62/// A new `Vec<GeoCoord>` containing only the vertices that exceed the
63/// tolerance threshold, plus the first and last vertices which are
64/// always retained.
65///
66/// # Edge cases
67///
68/// | Input | Result |
69/// |-------|--------|
70/// | empty slice | empty `Vec` |
71/// | 1 vertex | that vertex (copied) |
72/// | 2 vertices | both vertices (copied) |
73/// | `epsilon <= 0.0` | all vertices (no simplification) |
74/// | `epsilon` is NaN | all vertices (no simplification -- NaN comparisons always false) |
75///
76/// # Example
77///
78/// ```
79/// use rustial_engine::simplify_douglas_peucker;
80/// use rustial_engine::GeoCoord;
81///
82/// let line = vec![
83///     GeoCoord::from_lat_lon(0.0, 0.0),
84///     GeoCoord::from_lat_lon(0.0, 0.5),  // collinear -- will be removed
85///     GeoCoord::from_lat_lon(0.0, 1.0),
86/// ];
87/// let simplified = simplify_douglas_peucker(&line, 0.01);
88/// assert_eq!(simplified.len(), 2);
89/// ```
90pub fn simplify_douglas_peucker(coords: &[GeoCoord], epsilon: f64) -> Vec<GeoCoord> {
91    if coords.len() <= 2 {
92        return coords.to_vec();
93    }
94
95    let mut keep = vec![false; coords.len()];
96    keep[0] = true;
97    keep[coords.len() - 1] = true;
98
99    dp_recurse(coords, 0, coords.len() - 1, epsilon, &mut keep);
100
101    coords
102        .iter()
103        .zip(keep.iter())
104        .filter_map(|(c, &k)| if k { Some(*c) } else { None })
105        .collect()
106}
107
108/// Simplify a closed polygon ring using Douglas-Peucker.
109///
110/// Like [`simplify_douglas_peucker`] but guarantees the result remains a
111/// valid ring:
112///
113/// - The first and last vertices are always retained.
114/// - If the ring has a closing duplicate (first == last), it is preserved
115///   in the output so the ring stays closed.
116/// - The result is guaranteed to have at least 3 distinct vertices (plus
117///   the optional closing duplicate), because a polygon with fewer than
118///   3 vertices is degenerate.  If simplification would reduce the ring
119///   below this minimum, the original ring is returned unchanged.
120///
121/// # Example
122///
123/// ```
124/// use rustial_engine::simplify_polygon_ring;
125/// use rustial_engine::GeoCoord;
126///
127/// let ring = vec![
128///     GeoCoord::from_lat_lon(0.0, 0.0),
129///     GeoCoord::from_lat_lon(0.0, 1.0),
130///     GeoCoord::from_lat_lon(1.0, 1.0),
131///     GeoCoord::from_lat_lon(1.0, 0.0),
132///     GeoCoord::from_lat_lon(0.0, 0.0), // closing vertex
133/// ];
134/// let simplified = simplify_polygon_ring(&ring, 0.01);
135/// // All corners are significant; result keeps them.
136/// assert!(simplified.len() >= 4); // 4 corners or 4 + closing
137/// ```
138pub fn simplify_polygon_ring(coords: &[GeoCoord], epsilon: f64) -> Vec<GeoCoord> {
139    if coords.len() <= 3 {
140        return coords.to_vec();
141    }
142
143    // Detect whether the ring has a closing duplicate vertex.
144    let n = coords.len();
145    let has_closing = n > 1
146        && (coords[0].lat - coords[n - 1].lat).abs() < 1e-12
147        && (coords[0].lon - coords[n - 1].lon).abs() < 1e-12;
148
149    // Work on the open ring (without closing duplicate).
150    let open = if has_closing {
151        &coords[..n - 1]
152    } else {
153        coords
154    };
155
156    let simplified = simplify_douglas_peucker(open, epsilon);
157
158    // A polygon ring must have at least 3 distinct vertices.
159    if simplified.len() < 3 {
160        return coords.to_vec();
161    }
162
163    if has_closing {
164        // Re-close the ring.
165        let mut result = simplified;
166        result.push(result[0]);
167        result
168    } else {
169        simplified
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Internal: recursive Douglas-Peucker
175// ---------------------------------------------------------------------------
176
177/// Recursive core of the Douglas-Peucker algorithm.
178///
179/// Finds the vertex between `start` and `end` with the greatest
180/// perpendicular distance to the line segment `coords[start]`--
181/// `coords[end]`.  If that distance exceeds `epsilon`, the vertex is
182/// marked for retention and the algorithm recurses on both halves.
183fn dp_recurse(coords: &[GeoCoord], start: usize, end: usize, epsilon: f64, keep: &mut [bool]) {
184    if end <= start + 1 {
185        return;
186    }
187
188    let mut max_dist = 0.0_f64;
189    let mut max_idx = start;
190
191    let a = coords[start];
192    let b = coords[end];
193
194    for (i, coord) in coords.iter().enumerate().take(end).skip(start + 1) {
195        let d = perpendicular_distance(coord, &a, &b);
196        if d > max_dist {
197            max_dist = d;
198            max_idx = i;
199        }
200    }
201
202    if max_dist > epsilon {
203        keep[max_idx] = true;
204        dp_recurse(coords, start, max_idx, epsilon, keep);
205        dp_recurse(coords, max_idx, end, epsilon, keep);
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Internal: geometry helpers
211// ---------------------------------------------------------------------------
212
213/// Perpendicular distance from point `p` to the line through `a`--`b`,
214/// computed in the degree plane (lon = X, lat = Y).
215///
216/// ## Formula
217///
218/// Given the line direction `d = b - a`, the signed area of the
219/// parallelogram formed by `d` and `(p - a)` is:
220///
221/// ```text
222/// cross = (p.lon - a.lon) * (b.lat - a.lat)
223///       - (p.lat - a.lat) * (b.lon - a.lon)
224/// ```
225///
226/// The perpendicular distance is `|cross| / |d|`.
227///
228/// ## Degenerate case
229///
230/// When `a` and `b` coincide (segment length < 1e-12 degrees, i.e.
231/// sub-millimeter), the function falls back to the Euclidean distance
232/// from `p` to `a`.
233fn perpendicular_distance(p: &GeoCoord, a: &GeoCoord, b: &GeoCoord) -> f64 {
234    let dx = b.lon - a.lon;
235    let dy = b.lat - a.lat;
236    let len_sq = dx * dx + dy * dy;
237
238    // Degenerate: a and b are effectively the same point.
239    // 1e-24 in degree^2 corresponds to ~1e-12 degrees (~0.1 mm).
240    if len_sq < 1e-24 {
241        let ex = p.lon - a.lon;
242        let ey = p.lat - a.lat;
243        return (ex * ex + ey * ey).sqrt();
244    }
245
246    let cross = ((p.lon - a.lon) * dy - (p.lat - a.lat) * dx).abs();
247    cross / len_sq.sqrt()
248}
249
250// ---------------------------------------------------------------------------
251// Tests
252// ---------------------------------------------------------------------------
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    // -- simplify_douglas_peucker -----------------------------------------
259
260    #[test]
261    fn empty_input() {
262        let result = simplify_douglas_peucker(&[], 0.1);
263        assert!(result.is_empty());
264    }
265
266    #[test]
267    fn single_point() {
268        let coords = vec![GeoCoord::from_lat_lon(51.0, 17.0)];
269        let result = simplify_douglas_peucker(&coords, 0.1);
270        assert_eq!(result.len(), 1);
271        assert_eq!(result[0], coords[0]);
272    }
273
274    #[test]
275    fn no_simplification_for_two_points() {
276        let coords = vec![
277            GeoCoord::from_lat_lon(0.0, 0.0),
278            GeoCoord::from_lat_lon(1.0, 1.0),
279        ];
280        let result = simplify_douglas_peucker(&coords, 0.1);
281        assert_eq!(result.len(), 2);
282    }
283
284    #[test]
285    fn straight_line_simplified() {
286        // Three collinear points: the middle one should be removed.
287        let coords = vec![
288            GeoCoord::from_lat_lon(0.0, 0.0),
289            GeoCoord::from_lat_lon(0.0, 0.5),
290            GeoCoord::from_lat_lon(0.0, 1.0),
291        ];
292        let result = simplify_douglas_peucker(&coords, 0.01);
293        assert_eq!(result.len(), 2);
294        assert_eq!(result[0], coords[0]);
295        assert_eq!(result[1], coords[2]);
296    }
297
298    #[test]
299    fn bent_line_keeps_vertex() {
300        // A 90-degree bend: the apex must be retained at tight tolerance.
301        let coords = vec![
302            GeoCoord::from_lat_lon(0.0, 0.0),
303            GeoCoord::from_lat_lon(1.0, 0.5),
304            GeoCoord::from_lat_lon(0.0, 1.0),
305        ];
306        let result = simplify_douglas_peucker(&coords, 0.01);
307        assert_eq!(result.len(), 3);
308    }
309
310    #[test]
311    fn large_epsilon_simplifies_everything() {
312        let coords = vec![
313            GeoCoord::from_lat_lon(0.0, 0.0),
314            GeoCoord::from_lat_lon(0.1, 0.5),
315            GeoCoord::from_lat_lon(0.0, 1.0),
316            GeoCoord::from_lat_lon(-0.1, 1.5),
317            GeoCoord::from_lat_lon(0.0, 2.0),
318        ];
319        let result = simplify_douglas_peucker(&coords, 10.0);
320        assert_eq!(result.len(), 2);
321    }
322
323    #[test]
324    fn zero_epsilon_retains_all() {
325        let coords = vec![
326            GeoCoord::from_lat_lon(0.0, 0.0),
327            GeoCoord::from_lat_lon(0.1, 0.5),
328            GeoCoord::from_lat_lon(0.0, 1.0),
329        ];
330        let result = simplify_douglas_peucker(&coords, 0.0);
331        assert_eq!(result.len(), 3);
332    }
333
334    #[test]
335    fn negative_epsilon_retains_all() {
336        // Negative epsilon means the `max_dist > epsilon` check always
337        // passes (any positive distance > negative), so all vertices
338        // are retained.
339        let coords = vec![
340            GeoCoord::from_lat_lon(0.0, 0.0),
341            GeoCoord::from_lat_lon(0.1, 0.5),
342            GeoCoord::from_lat_lon(0.0, 1.0),
343        ];
344        let result = simplify_douglas_peucker(&coords, -1.0);
345        assert_eq!(result.len(), 3);
346    }
347
348    #[test]
349    fn altitude_is_preserved() {
350        let coords = vec![
351            GeoCoord::new(0.0, 0.0, 100.0),
352            GeoCoord::new(1.0, 0.5, 200.0), // significant bend
353            GeoCoord::new(0.0, 1.0, 300.0),
354        ];
355        let result = simplify_douglas_peucker(&coords, 0.01);
356        assert_eq!(result.len(), 3);
357        assert_eq!(result[0].alt, 100.0);
358        assert_eq!(result[1].alt, 200.0);
359        assert_eq!(result[2].alt, 300.0);
360    }
361
362    #[test]
363    fn many_collinear_points() {
364        // 100 points on a straight east-west line at lat=0.
365        let coords: Vec<GeoCoord> = (0..100)
366            .map(|i| GeoCoord::from_lat_lon(0.0, i as f64 * 0.01))
367            .collect();
368        let result = simplify_douglas_peucker(&coords, 0.001);
369        assert_eq!(result.len(), 2, "all collinear points should be removed");
370        assert_eq!(result[0], coords[0]);
371        assert_eq!(result[1], coords[99]);
372    }
373
374    #[test]
375    fn zigzag_retains_peaks() {
376        // A zigzag with 5 peaks should retain all of them at tight epsilon.
377        let mut coords = Vec::new();
378        for i in 0..11 {
379            let lon = i as f64 * 0.1;
380            let lat = if i % 2 == 0 { 0.0 } else { 1.0 };
381            coords.push(GeoCoord::from_lat_lon(lat, lon));
382        }
383        let result = simplify_douglas_peucker(&coords, 0.01);
384        assert_eq!(
385            result.len(),
386            coords.len(),
387            "tight epsilon retains all zigzag vertices"
388        );
389    }
390
391    // -- simplify_polygon_ring --------------------------------------------
392
393    #[test]
394    fn ring_preserves_square() {
395        let ring = vec![
396            GeoCoord::from_lat_lon(0.0, 0.0),
397            GeoCoord::from_lat_lon(0.0, 1.0),
398            GeoCoord::from_lat_lon(1.0, 1.0),
399            GeoCoord::from_lat_lon(1.0, 0.0),
400            GeoCoord::from_lat_lon(0.0, 0.0), // closing
401        ];
402        let result = simplify_polygon_ring(&ring, 0.01);
403        // All 4 corners are significant.
404        assert_eq!(result.len(), 5, "4 corners + closing vertex");
405        // Must still be closed.
406        assert_eq!(result[0], result[result.len() - 1]);
407    }
408
409    #[test]
410    fn ring_simplifies_collinear_edges() {
411        // A rectangle with extra collinear midpoints on each edge.
412        let ring = vec![
413            GeoCoord::from_lat_lon(0.0, 0.0),
414            GeoCoord::from_lat_lon(0.0, 0.5), // collinear
415            GeoCoord::from_lat_lon(0.0, 1.0),
416            GeoCoord::from_lat_lon(0.5, 1.0), // collinear
417            GeoCoord::from_lat_lon(1.0, 1.0),
418            GeoCoord::from_lat_lon(1.0, 0.5), // collinear
419            GeoCoord::from_lat_lon(1.0, 0.0),
420            GeoCoord::from_lat_lon(0.5, 0.0), // collinear
421            GeoCoord::from_lat_lon(0.0, 0.0), // closing
422        ];
423        let result = simplify_polygon_ring(&ring, 0.01);
424        // The result must be shorter than the original (collinear midpoints
425        // are removed) and must remain a valid closed ring.
426        assert!(
427            result.len() < ring.len(),
428            "expected fewer vertices, got {} (original {})",
429            result.len(),
430            ring.len()
431        );
432        assert!(
433            result.len() >= 4,
434            "ring must have at least 3 corners + close"
435        );
436        assert_eq!(result[0], result[result.len() - 1], "ring must be closed");
437    }
438
439    #[test]
440    fn ring_open_without_closing_vertex() {
441        // An open ring (no closing duplicate).
442        let ring = vec![
443            GeoCoord::from_lat_lon(0.0, 0.0),
444            GeoCoord::from_lat_lon(0.0, 1.0),
445            GeoCoord::from_lat_lon(1.0, 1.0),
446            GeoCoord::from_lat_lon(1.0, 0.0),
447        ];
448        let result = simplify_polygon_ring(&ring, 0.01);
449        assert_eq!(result.len(), 4);
450    }
451
452    #[test]
453    fn ring_too_small_is_returned_unchanged() {
454        // 3 vertices (triangle) cannot be further simplified.
455        let ring = vec![
456            GeoCoord::from_lat_lon(0.0, 0.0),
457            GeoCoord::from_lat_lon(0.0, 1.0),
458            GeoCoord::from_lat_lon(1.0, 0.0),
459        ];
460        let result = simplify_polygon_ring(&ring, 100.0);
461        assert_eq!(result.len(), 3);
462    }
463
464    #[test]
465    fn ring_degenerate_returns_unchanged() {
466        // 2 vertices: degenerate ring, returned as-is.
467        let ring = vec![
468            GeoCoord::from_lat_lon(0.0, 0.0),
469            GeoCoord::from_lat_lon(1.0, 1.0),
470        ];
471        let result = simplify_polygon_ring(&ring, 0.01);
472        assert_eq!(result.len(), 2);
473    }
474
475    // -- perpendicular_distance -------------------------------------------
476
477    #[test]
478    fn distance_on_line_is_zero() {
479        let a = GeoCoord::from_lat_lon(0.0, 0.0);
480        let b = GeoCoord::from_lat_lon(0.0, 1.0);
481        let p = GeoCoord::from_lat_lon(0.0, 0.5); // on the line
482        let d = perpendicular_distance(&p, &a, &b);
483        assert!(d < 1e-12, "point on line should have ~0 distance, got {d}");
484    }
485
486    #[test]
487    fn distance_off_line_is_positive() {
488        let a = GeoCoord::from_lat_lon(0.0, 0.0);
489        let b = GeoCoord::from_lat_lon(0.0, 1.0);
490        let p = GeoCoord::from_lat_lon(1.0, 0.5); // 1 degree north of line
491        let d = perpendicular_distance(&p, &a, &b);
492        assert!((d - 1.0).abs() < 1e-10, "expected ~1.0 degree, got {d}");
493    }
494
495    #[test]
496    fn distance_coincident_endpoints() {
497        // When a == b, distance degenerates to Euclidean from p to a.
498        let a = GeoCoord::from_lat_lon(0.0, 0.0);
499        let b = GeoCoord::from_lat_lon(0.0, 0.0);
500        let p = GeoCoord::from_lat_lon(3.0, 4.0);
501        let d = perpendicular_distance(&p, &a, &b);
502        assert!((d - 5.0).abs() < 1e-10, "expected 5.0, got {d}");
503    }
504}