Skip to main content

geometry_strategy/
simplify.rs

1//! Line simplification — Douglas–Peucker.
2//!
3//! Mirrors `boost::geometry::strategy::simplify::douglas_peucker<P, DistStr>`
4//! from `boost/geometry/strategies/agnostic/simplify_douglas_peucker.hpp`.
5//! Recursive split: for the current point range, find the vertex
6//! furthest from the chord between the endpoints; if that distance is
7//! below `max_distance`, drop all interior vertices; otherwise split at
8//! the furthest vertex and recurse on the two halves.
9//!
10//! The strategy is *agnostic* in Boost — it accepts any point-to-segment
11//! distance kernel, so the same recursion works in Cartesian, spherical,
12//! and geographic coordinate systems. This port ships the Cartesian
13//! flavour first, keyed on
14//! [`crate::PointToSegment`]`<`[`crate::Pythagoras`]`>`.
15
16use alloc::vec;
17use alloc::vec::Vec;
18
19use geometry_cs::{CartesianFamily, CoordinateSystem};
20use geometry_tag::SameAs;
21use geometry_trait::{Linestring, Point, PointMut};
22
23use crate::cartesian::{PointToSegment, Pythagoras};
24use crate::distance::DistanceStrategy;
25
26/// A strategy for simplifying a geometry.
27///
28/// Mirrors the per-coordinate-system simplify-strategy concept from
29/// `boost/geometry/strategies/simplify.hpp`. `simplify` returns a *new*
30/// geometry of the same kind.
31pub trait SimplifyStrategy<G> {
32    /// The simplified geometry type.
33    type Output;
34
35    /// Return a simplified copy of `g`, dropping vertices whose
36    /// deviation from the retained chord is below `max_distance`.
37    fn simplify(&self, g: &G, max_distance: f64) -> Self::Output;
38}
39
40/// Douglas–Peucker line simplification.
41///
42/// Parameterised on a point-to-segment distance strategy. The default
43/// [`crate::PointToSegment`]`<`[`crate::Pythagoras`]`>`
44/// matches Boost's `strategies/agnostic/simplify_douglas_peucker.hpp`
45/// default.
46#[derive(Debug, Default, Clone, Copy)]
47pub struct DouglasPeucker<D = PointToSegment<Pythagoras>>(pub D);
48
49impl<P, L, D> SimplifyStrategy<L> for DouglasPeucker<D>
50where
51    P: Point<Scalar = f64> + PointMut + Default + Copy,
52    L: Linestring<Point = P>,
53    D: DistanceStrategy<P, geometry_model::Segment<P>, Out = f64>,
54    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
55{
56    type Output = geometry_model::Linestring<P>;
57
58    fn simplify(&self, ls: &L, max_distance: f64) -> Self::Output {
59        let pts: Vec<P> = ls.points().copied().collect();
60        // A negative tolerance is a legal input that Boost copies through
61        // unchanged (`algorithms/simplify.hpp:294,359`, `|| max_distance
62        // < 0`). Guarding it here is also load-bearing for termination:
63        // with `eps < 0` and a fully collinear/coincident run, every
64        // interior deviation is `0.0`, so `dp_recurse`'s `max_d > eps`
65        // guard is satisfied while `max_i` never advances past `lo` —
66        // the second recursive call would then be identical to its
67        // parent and recurse forever (stack overflow). Returning early
68        // keeps every vertex, matching Boost.
69        if pts.len() < 3 || max_distance < 0.0 {
70            return geometry_model::Linestring::from_vec(pts);
71        }
72
73        let mut keep = vec![false; pts.len()];
74        keep[0] = true;
75        keep[pts.len() - 1] = true;
76        dp_recurse(&pts, 0, pts.len() - 1, max_distance, &self.0, &mut keep);
77
78        let out: Vec<P> = pts
79            .iter()
80            .zip(keep.iter())
81            .filter_map(|(p, &k)| if k { Some(*p) } else { None })
82            .collect();
83        geometry_model::Linestring::from_vec(out)
84    }
85}
86
87/// Recursive Douglas–Peucker split over `pts[lo..=hi]`.
88///
89/// Marks the vertex furthest from the chord `pts[lo]`-`pts[hi]` as kept
90/// when its distance exceeds `eps`, then recurses on the two halves.
91/// Mirrors the `douglas_peucker::apply` recursion in
92/// `boost/geometry/strategies/agnostic/simplify_douglas_peucker.hpp`.
93fn dp_recurse<P, D>(pts: &[P], lo: usize, hi: usize, eps: f64, dist: &D, keep: &mut [bool])
94where
95    P: Point<Scalar = f64> + PointMut + Default + Copy,
96    D: DistanceStrategy<P, geometry_model::Segment<P>, Out = f64>,
97    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
98{
99    if hi <= lo + 1 {
100        return;
101    }
102    let seg = geometry_model::Segment::new(pts[lo], pts[hi]);
103
104    // Find the interior vertex furthest from the chord.
105    let mut max_d = 0.0_f64;
106    let mut max_i = lo;
107    for (i, p) in pts.iter().enumerate().take(hi).skip(lo + 1) {
108        let d = dist.distance(p, &seg);
109        if d > max_d {
110            max_d = d;
111            max_i = i;
112        }
113    }
114
115    // If the furthest deviation exceeds the tolerance, keep that vertex
116    // and recurse on the two halves; otherwise all interior vertices
117    // drop.
118    if max_d > eps {
119        keep[max_i] = true;
120        dp_recurse(pts, lo, max_i, eps, dist, keep);
121        dp_recurse(pts, max_i, hi, eps, dist, keep);
122    }
123}
124
125#[cfg(test)]
126#[allow(
127    clippy::float_cmp,
128    reason = "Simplified coordinates are exact literals."
129)]
130mod tests {
131    //! Reference behaviour from
132    //! `boost/geometry/test/algorithms/simplify.cpp` — a straight
133    //! polyline collapses to its endpoints, and a spike above tolerance
134    //! survives while a sub-tolerance wiggle drops.
135
136    extern crate alloc;
137
138    use super::{DouglasPeucker, SimplifyStrategy};
139    use crate::cartesian::{PointToSegment, Pythagoras};
140    use alloc::vec;
141    use alloc::vec::Vec;
142    use geometry_cs::Cartesian;
143    use geometry_model::{Linestring, Point2D, linestring};
144    use geometry_trait::Point as _;
145
146    type Pt = Point2D<f64, Cartesian>;
147
148    fn default_dp() -> DouglasPeucker<PointToSegment<Pythagoras>> {
149        DouglasPeucker::default()
150    }
151
152    fn coords(ls: &Linestring<Pt>) -> Vec<(f64, f64)> {
153        ls.0.iter().map(|p| (p.get::<0>(), p.get::<1>())).collect()
154    }
155
156    #[test]
157    fn straight_three_point_line_collapses_to_endpoints() {
158        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 0.), (2., 0.)];
159        let s = default_dp().simplify(&ls, 0.5);
160        assert_eq!(coords(&s), vec![(0., 0.), (2., 0.)]);
161    }
162
163    #[test]
164    fn collinear_polyline_collapses_to_endpoints() {
165        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 1.), (2., 2.), (3., 3.)];
166        let s = default_dp().simplify(&ls, 0.001);
167        assert_eq!(coords(&s), vec![(0., 0.), (3., 3.)]);
168    }
169
170    #[test]
171    fn tiny_deviation_drops_but_large_deviation_kept() {
172        // A near-straight run with one vertex bumped far off the chord.
173        // Small tolerance drops the tiny wiggle but keeps the spike.
174        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 0.01), (2., 0.), (3., 5.), (4., 0.)];
175        let s = default_dp().simplify(&ls, 0.5);
176        let c = coords(&s);
177        assert_eq!(c.first().unwrap(), &(0., 0.));
178        assert_eq!(c.last().unwrap(), &(4., 0.));
179        // The (3, 5) spike survives; the (1, 0.01) wiggle does not.
180        assert!(c.contains(&(3., 5.)));
181        assert!(!c.contains(&(1., 0.01)));
182    }
183
184    #[test]
185    fn short_linestring_is_returned_unchanged() {
186        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 1.)];
187        let s = default_dp().simplify(&ls, 10.0);
188        assert_eq!(coords(&s), vec![(0., 0.), (1., 1.)]);
189    }
190
191    #[test]
192    fn negative_tolerance_copies_through_without_overflow() {
193        // Regression: a negative tolerance over a fully collinear run
194        // used to recurse forever (max_d == 0.0 > eps, but the split
195        // index never advanced) and abort the process with a stack
196        // overflow. Boost copies the range through unchanged for
197        // `max_distance < 0` (simplify.hpp:294,359); so must the port.
198        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 0.), (2., 0.)];
199        let s = default_dp().simplify(&ls, -0.5);
200        assert_eq!(coords(&s), vec![(0., 0.), (1., 0.), (2., 0.)]);
201        // Coincident interior points, also negative tolerance.
202        let dup: Linestring<Pt> = linestring![(0., 0.), (0., 0.), (0., 0.), (5., 0.)];
203        let s2 = default_dp().simplify(&dup, -1.0);
204        assert_eq!(s2.0.len(), 4, "negative tolerance keeps every vertex");
205    }
206}