Skip to main content

geonative_utils/
simplify.rs

1//! Douglas-Peucker line simplification.
2//!
3//! Classic recursive algorithm: keep the two endpoints; find the vertex
4//! farthest from the chord between them; if its perpendicular distance is
5//! above `tolerance`, keep it and recurse on both halves; otherwise discard
6//! all intermediate vertices.
7//!
8//! Complexity is `O(n²)` worst case, `O(n log n)` typical. Tolerances are
9//! in the **same units as the input coordinates** — pass degree-based
10//! tolerances for lng/lat data, meter-based for projected data.
11
12use geonative_core::{Coord, Geometry, LineString, Polygon};
13
14/// Simplify a polyline (or polygon ring without its closing duplicate) using
15/// Douglas-Peucker. Always preserves the first and last coords.
16///
17/// Returns the input unchanged when it has fewer than 3 coords (nothing to
18/// drop) or when `tolerance` is non-positive.
19pub fn douglas_peucker(coords: &[Coord], tolerance: f64) -> Vec<Coord> {
20    if coords.len() < 3 || !tolerance.is_finite() || tolerance <= 0.0 {
21        return coords.to_vec();
22    }
23    let n = coords.len();
24    let mut keep = vec![false; n];
25    keep[0] = true;
26    keep[n - 1] = true;
27    dp_recurse(coords, 0, n - 1, tolerance * tolerance, &mut keep);
28
29    coords
30        .iter()
31        .zip(keep.iter())
32        .filter_map(|(c, &k)| if k { Some(*c) } else { None })
33        .collect()
34}
35
36fn dp_recurse(coords: &[Coord], start: usize, end: usize, tol_sq: f64, keep: &mut [bool]) {
37    if end <= start + 1 {
38        return;
39    }
40    let (max_dist_sq, max_idx) = farthest_index(coords, start, end);
41    if max_dist_sq > tol_sq {
42        keep[max_idx] = true;
43        dp_recurse(coords, start, max_idx, tol_sq, keep);
44        dp_recurse(coords, max_idx, end, tol_sq, keep);
45    }
46}
47
48/// Index (within `coords`) of the point farthest from the line `[start, end]`,
49/// and its squared perpendicular distance.
50fn farthest_index(coords: &[Coord], start: usize, end: usize) -> (f64, usize) {
51    let a = coords[start];
52    let b = coords[end];
53    let dx = b.x - a.x;
54    let dy = b.y - a.y;
55    let len_sq = dx * dx + dy * dy;
56
57    let mut max_d_sq = -1.0;
58    let mut max_idx = start;
59    for (i, p) in coords.iter().enumerate().take(end).skip(start + 1) {
60        let d_sq = if len_sq == 0.0 {
61            let px = p.x - a.x;
62            let py = p.y - a.y;
63            px * px + py * py
64        } else {
65            // Perpendicular distance squared from p to the infinite line through a,b.
66            let num = dy * p.x - dx * p.y + b.x * a.y - b.y * a.x;
67            (num * num) / len_sq
68        };
69        if d_sq > max_d_sq {
70            max_d_sq = d_sq;
71            max_idx = i;
72        }
73    }
74    (max_d_sq, max_idx)
75}
76
77/// Apply Douglas-Peucker to every linear part of a `Geometry`. Polygon rings
78/// are kept closed (the closing duplicate is restored if simplification
79/// dropped it). `Point` / `MultiPoint` / `Empty` pass through unchanged.
80pub fn simplify_geometry(geom: &Geometry, tolerance: f64) -> Geometry {
81    match geom {
82        Geometry::Point(_) | Geometry::MultiPoint(_) | Geometry::Empty(_) => geom.clone(),
83        Geometry::LineString(ls) => Geometry::LineString(simplify_linestring(ls, tolerance)),
84        Geometry::MultiLineString(parts) => Geometry::MultiLineString(
85            parts
86                .iter()
87                .map(|ls| simplify_linestring(ls, tolerance))
88                .collect(),
89        ),
90        Geometry::Polygon(p) => Geometry::Polygon(simplify_polygon(p, tolerance)),
91        Geometry::MultiPolygon(polys) => Geometry::MultiPolygon(
92            polys
93                .iter()
94                .map(|p| simplify_polygon(p, tolerance))
95                .collect(),
96        ),
97        Geometry::GeometryCollection(v) => Geometry::GeometryCollection(
98            v.iter().map(|g| simplify_geometry(g, tolerance)).collect(),
99        ),
100        // Future variants (e.g. curves, surfaces): pass through unchanged.
101        // A new SemVer-minor of geonative-core may add Geometry variants;
102        // we don't break for callers that haven't upgraded geonative-utils.
103        _ => geom.clone(),
104    }
105}
106
107fn simplify_linestring(ls: &LineString, tolerance: f64) -> LineString {
108    LineString::new(douglas_peucker(&ls.coords, tolerance))
109}
110
111fn simplify_polygon(p: &Polygon, tolerance: f64) -> Polygon {
112    Polygon::new(
113        simplify_ring(&p.exterior, tolerance),
114        p.holes
115            .iter()
116            .map(|h| simplify_ring(h, tolerance))
117            .collect(),
118    )
119}
120
121/// Simplify a polygon ring, preserving the closing vertex.
122///
123/// The IR uses OGC-closed rings (first == last). DP could drop one of the
124/// endpoints if the ring is small; we explicitly re-close after simplifying.
125fn simplify_ring(ring: &LineString, tolerance: f64) -> LineString {
126    if ring.coords.len() <= 4 {
127        // Triangle (4 coords with closing dup): can't drop anything meaningful.
128        return ring.clone();
129    }
130    let mut simplified = douglas_peucker(&ring.coords, tolerance);
131    // Re-close if needed: simplification preserves first + last vertices, so
132    // the close should still hold — but if input wasn't closed, leave it.
133    if let (Some(first), Some(last)) = (simplified.first(), simplified.last()) {
134        if first != last && ring.coords.first() == ring.coords.last() {
135            simplified.push(*first);
136        }
137    }
138    LineString::new(simplified)
139}
140
141/// A small preset tolerance (in **degrees**) for typical web-map zoom levels.
142///
143/// Calibrated so that a coord moved by less than `tolerance` would be
144/// indistinguishable at the given zoom on a 4096-extent MVT tile. The
145/// numbers are powers-of-two-ish, intentionally generous at low zooms (where
146/// dropping detail is desirable for tile size) and tight at high zooms
147/// (where users zoom in to see fine geometry).
148///
149/// Pipe `Geometry`s through `simplify_geometry(geom, tolerance_for_zoom(z))`
150/// before MVT encoding to get LOD-aware tiles.
151pub fn tolerance_for_zoom(z: u8) -> f64 {
152    match z {
153        0..=2 => 0.5,
154        3..=5 => 0.1,
155        6..=8 => 0.02,
156        9..=11 => 0.004,
157        12..=14 => 0.0008,
158        15..=17 => 0.000_15,
159        _ => 0.000_03,
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use geonative_core::GeometryType;
167
168    fn xy(x: f64, y: f64) -> Coord {
169        Coord {
170            x,
171            y,
172            z: None,
173            m: None,
174        }
175    }
176
177    #[test]
178    fn dp_drops_colinear_interior_points() {
179        // Five colinear points; tolerance > 0 should reduce to just endpoints.
180        let pts = vec![
181            xy(0.0, 0.0),
182            xy(1.0, 0.0),
183            xy(2.0, 0.0),
184            xy(3.0, 0.0),
185            xy(4.0, 0.0),
186        ];
187        let out = douglas_peucker(&pts, 0.001);
188        assert_eq!(out, vec![xy(0.0, 0.0), xy(4.0, 0.0)]);
189    }
190
191    #[test]
192    fn dp_keeps_jagged_extremes() {
193        // Zigzag with peaks at (1,10) and (3,-10); the midpoint (2,0) is
194        // colinear with the chord between the peaks, so DP correctly drops
195        // it. Endpoints + two extremes = 4 kept.
196        let pts = vec![
197            xy(0.0, 0.0),
198            xy(1.0, 10.0),
199            xy(2.0, 0.0),
200            xy(3.0, -10.0),
201            xy(4.0, 0.0),
202        ];
203        let out = douglas_peucker(&pts, 0.5);
204        assert!(out.contains(&xy(0.0, 0.0)), "endpoint dropped");
205        assert!(out.contains(&xy(4.0, 0.0)), "endpoint dropped");
206        assert!(out.contains(&xy(1.0, 10.0)), "first extreme dropped");
207        assert!(out.contains(&xy(3.0, -10.0)), "second extreme dropped");
208        assert_eq!(out.len(), 4);
209    }
210
211    #[test]
212    fn dp_keeps_non_colinear_intermediate() {
213        // Same zigzag but with a midpoint that's not on the (1,10)→(3,-10)
214        // chord. Tighten tolerance so the midpoint's perpendicular distance
215        // from that chord (~0.5°) is comfortably above threshold.
216        let pts = vec![
217            xy(0.0, 0.0),
218            xy(1.0, 10.0),
219            xy(2.0, 5.0),
220            xy(3.0, -10.0),
221            xy(4.0, 0.0),
222        ];
223        let out = douglas_peucker(&pts, 0.1);
224        assert_eq!(out.len(), pts.len(), "all 5 points should be kept");
225    }
226
227    #[test]
228    fn dp_zero_or_negative_tolerance_is_identity() {
229        let pts = vec![xy(0.0, 0.0), xy(1.0, 0.5), xy(2.0, 0.0)];
230        assert_eq!(douglas_peucker(&pts, 0.0), pts);
231        assert_eq!(douglas_peucker(&pts, -1.0), pts);
232    }
233
234    #[test]
235    fn dp_short_input_is_identity() {
236        let pts = vec![xy(0.0, 0.0), xy(1.0, 1.0)];
237        assert_eq!(douglas_peucker(&pts, 0.5), pts);
238    }
239
240    #[test]
241    fn dp_degenerate_chord_uses_distance_to_first_point() {
242        // a == b at endpoints; perpendicular distance falls back to Euclidean.
243        let pts = vec![xy(0.0, 0.0), xy(5.0, 5.0), xy(0.0, 0.0)];
244        let out = douglas_peucker(&pts, 1.0);
245        // Middle point is sqrt(50) ≈ 7.07 from the endpoints; > 1.0, so kept.
246        assert_eq!(out.len(), 3);
247    }
248
249    #[test]
250    fn simplify_geometry_dispatches_per_variant() {
251        // Point is unchanged.
252        let p = Geometry::Point(xy(1.0, 2.0));
253        assert_eq!(simplify_geometry(&p, 0.001), p);
254
255        // Empty is unchanged.
256        let e = Geometry::Empty(GeometryType::Polygon);
257        assert_eq!(simplify_geometry(&e, 0.001), e);
258
259        // LineString gets simplified.
260        let ls = Geometry::LineString(LineString::new(vec![
261            xy(0.0, 0.0),
262            xy(1.0, 0.0),
263            xy(2.0, 0.0),
264            xy(3.0, 0.0),
265        ]));
266        match simplify_geometry(&ls, 0.001) {
267            Geometry::LineString(out) => assert_eq!(out.coords.len(), 2),
268            _ => panic!(),
269        }
270    }
271
272    #[test]
273    fn simplify_polygon_preserves_ring_closure() {
274        let p = Polygon::new(
275            LineString::new(vec![
276                xy(0.0, 0.0),
277                xy(10.0, 0.0),
278                xy(10.001, 0.0001),
279                xy(10.0, 10.0),
280                xy(0.0, 10.0),
281                xy(0.0, 0.0),
282            ]),
283            vec![],
284        );
285        let simplified = simplify_polygon(&p, 0.01);
286        // The near-collinear vertex at (10.001, 0.0001) should be dropped.
287        assert!(simplified.exterior.coords.len() < p.exterior.coords.len());
288        // Ring must still be closed.
289        assert_eq!(
290            simplified.exterior.coords.first(),
291            simplified.exterior.coords.last(),
292            "ring closure lost: {:?}",
293            simplified.exterior.coords
294        );
295    }
296
297    #[test]
298    fn simplify_polygon_with_hole_handles_both_rings() {
299        let p = Polygon::new(
300            LineString::new(vec![
301                xy(0.0, 0.0),
302                xy(10.0, 0.0),
303                xy(10.0, 10.0),
304                xy(5.0, 10.0001),
305                xy(0.0, 10.0),
306                xy(0.0, 0.0),
307            ]),
308            vec![LineString::new(vec![
309                xy(2.0, 2.0),
310                xy(4.0, 2.0),
311                xy(4.001, 2.0),
312                xy(4.0, 4.0),
313                xy(2.0, 4.0),
314                xy(2.0, 2.0),
315            ])],
316        );
317        let simplified = simplify_polygon(&p, 0.001);
318        assert_eq!(simplified.holes.len(), 1);
319        assert_eq!(
320            simplified.holes[0].coords.first(),
321            simplified.holes[0].coords.last(),
322            "hole closure lost"
323        );
324    }
325
326    #[test]
327    fn simplify_tiny_polygon_passes_through() {
328        // Triangle (3 distinct points + closing dup): no simplification possible.
329        let p = Polygon::new(
330            LineString::new(vec![xy(0.0, 0.0), xy(1.0, 0.0), xy(0.5, 1.0), xy(0.0, 0.0)]),
331            vec![],
332        );
333        let simplified = simplify_polygon(&p, 100.0);
334        assert_eq!(simplified.exterior.coords.len(), 4);
335    }
336
337    #[test]
338    fn tolerance_for_zoom_is_monotonic() {
339        for z in 0u8..18 {
340            assert!(
341                tolerance_for_zoom(z) >= tolerance_for_zoom(z + 1),
342                "tolerance not monotonically non-increasing at z={z}"
343            );
344        }
345    }
346
347    #[test]
348    fn tolerance_for_zoom_low_zoom_aggressive() {
349        // At z=0 the world fits in one 4096-pixel tile; 1 degree ≈ 11 pixels,
350        // so tolerance 0.5° ≈ 5.5 px — a reasonable LOD threshold.
351        assert!(tolerance_for_zoom(0) >= 0.1);
352        assert!(tolerance_for_zoom(20) < 0.001);
353    }
354}