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}