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
49/// Visvalingam–Whyatt area-ranked line simplification.
50///
51/// Repeatedly removes the interior vertex with the smallest adjacent-triangle
52/// area while that area is at most the tolerance supplied to
53/// [`SimplifyStrategy::simplify`]. Endpoints are always retained.
54///
55/// Implements the method from Visvalingam and Whyatt, “Line Generalisation by
56/// Repeated Elimination of Points” (1993). Boost.Geometry has no equivalent
57/// strategy; this is an opt-in peer of [`DouglasPeucker`].
58#[derive(Debug, Default, Clone, Copy)]
59pub struct VisvalingamWhyatt;
60
61/// Topology-preserving Visvalingam–Whyatt line simplification.
62///
63/// Uses the same area ranking as [`VisvalingamWhyatt`], and applies the Davies
64/// refinement when removing a vertex would introduce a self-intersection: the
65/// preceding retained vertex is removed next so the transient crossing is
66/// eliminated. The implementation uses an allocation-only quadratic scan,
67/// keeping the strategy available in `no_std` builds.
68#[derive(Debug, Default, Clone, Copy)]
69pub struct VisvalingamWhyattPreserve;
70
71impl<P, L, D> SimplifyStrategy<L> for DouglasPeucker<D>
72where
73    P: Point<Scalar = f64> + PointMut + Default + Copy,
74    L: Linestring<Point = P>,
75    D: DistanceStrategy<P, geometry_model::Segment<P>, Out = f64>,
76    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
77{
78    type Output = geometry_model::Linestring<P>;
79
80    fn simplify(&self, ls: &L, max_distance: f64) -> Self::Output {
81        let pts: Vec<P> = ls.points().copied().collect();
82        // A negative tolerance is a legal input that Boost copies through
83        // unchanged (`algorithms/simplify.hpp:294,359`, `|| max_distance
84        // < 0`). Guarding it here is also load-bearing for termination:
85        // with `eps < 0` and a fully collinear/coincident run, every
86        // interior deviation is `0.0`, so `dp_recurse`'s `max_d > eps`
87        // guard is satisfied while `max_i` never advances past `lo` —
88        // the second recursive call would then be identical to its
89        // parent and recurse forever (stack overflow). Returning early
90        // keeps every vertex, matching Boost.
91        if pts.len() < 3 || max_distance < 0.0 {
92            return geometry_model::Linestring::from_vec(pts);
93        }
94
95        let mut keep = vec![false; pts.len()];
96        keep[0] = true;
97        keep[pts.len() - 1] = true;
98        dp_recurse(&pts, 0, pts.len() - 1, max_distance, &self.0, &mut keep);
99
100        let out: Vec<P> = pts
101            .iter()
102            .zip(keep.iter())
103            .filter_map(|(p, &k)| if k { Some(*p) } else { None })
104            .collect();
105        geometry_model::Linestring::from_vec(out)
106    }
107}
108
109impl<P, L> SimplifyStrategy<L> for VisvalingamWhyatt
110where
111    P: Point<Scalar = f64> + PointMut + Default + Copy,
112    L: Linestring<Point = P>,
113    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
114{
115    type Output = geometry_model::Linestring<P>;
116
117    fn simplify(&self, ls: &L, max_distance: f64) -> Self::Output {
118        visvalingam_whyatt(ls, max_distance, false)
119    }
120}
121
122impl<P, L> SimplifyStrategy<L> for VisvalingamWhyattPreserve
123where
124    P: Point<Scalar = f64> + PointMut + Default + Copy,
125    L: Linestring<Point = P>,
126    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
127{
128    type Output = geometry_model::Linestring<P>;
129
130    fn simplify(&self, ls: &L, max_distance: f64) -> Self::Output {
131        visvalingam_whyatt(ls, max_distance, true)
132    }
133}
134
135fn visvalingam_whyatt<P, L>(
136    ls: &L,
137    minimum_area: f64,
138    preserve_topology: bool,
139) -> geometry_model::Linestring<P>
140where
141    P: Point<Scalar = f64> + PointMut + Default + Copy,
142    L: Linestring<Point = P>,
143{
144    let points: Vec<P> = ls.points().copied().collect();
145    if points.len() < 3 || minimum_area <= 0.0 || minimum_area.is_nan() {
146        return geometry_model::Linestring::from_vec(points);
147    }
148
149    let mut retained: Vec<usize> = (0..points.len()).collect();
150    let mut forced_predecessor = None;
151
152    while retained.len() > 2 {
153        let selected = forced_predecessor
154            .take()
155            .and_then(|index| retained.iter().position(|candidate| *candidate == index))
156            .filter(|slot| *slot > 0 && *slot + 1 < retained.len())
157            .map(|slot| (slot, 0.0))
158            .or_else(|| smallest_triangle(&points, &retained));
159        // `retained.len() > 2` guarantees at least one interior triangle, so
160        // the fallback scan always produces a candidate even when a forced
161        // predecessor is no longer eligible.
162        let (slot, area) = selected.expect("an interior triangle is available");
163        if area > minimum_area {
164            break;
165        }
166
167        let creates_crossing =
168            preserve_topology && removal_creates_crossing(&points, &retained, slot);
169        let predecessor = retained[slot - 1];
170        retained.remove(slot);
171        if creates_crossing {
172            forced_predecessor = Some(predecessor);
173        }
174    }
175
176    geometry_model::Linestring::from_vec(retained.into_iter().map(|index| points[index]).collect())
177}
178
179fn smallest_triangle<P>(points: &[P], retained: &[usize]) -> Option<(usize, f64)>
180where
181    P: Point<Scalar = f64>,
182{
183    let mut selected = None;
184    for slot in 1..retained.len().saturating_sub(1) {
185        let area = triangle_area(
186            &points[retained[slot - 1]],
187            &points[retained[slot]],
188            &points[retained[slot + 1]],
189        );
190        if selected.is_none_or(|(_, smallest)| area < smallest) {
191            selected = Some((slot, area));
192        }
193    }
194    selected
195}
196
197#[inline]
198fn triangle_area<P>(first: &P, middle: &P, last: &P) -> f64
199where
200    P: Point<Scalar = f64>,
201{
202    let twice_area = (middle.get::<0>() - first.get::<0>()) * (last.get::<1>() - first.get::<1>())
203        - (middle.get::<1>() - first.get::<1>()) * (last.get::<0>() - first.get::<0>());
204    twice_area.abs() * 0.5
205}
206
207fn removal_creates_crossing<P>(points: &[P], retained: &[usize], slot: usize) -> bool
208where
209    P: Point<Scalar = f64>,
210{
211    let left = retained[slot - 1];
212    let current = retained[slot];
213    let right = retained[slot + 1];
214
215    retained.windows(2).any(|edge| {
216        let start = edge[0];
217        let end = edge[1];
218        if start == left
219            || end == left
220            || start == current
221            || end == current
222            || start == right
223            || end == right
224        {
225            return false;
226        }
227        segments_intersect(&points[left], &points[right], &points[start], &points[end])
228    })
229}
230
231fn segments_intersect<P>(first: &P, second: &P, third: &P, fourth: &P) -> bool
232where
233    P: Point<Scalar = f64>,
234{
235    let o1 = orientation(first, second, third);
236    let o2 = orientation(first, second, fourth);
237    let o3 = orientation(third, fourth, first);
238    let o4 = orientation(third, fourth, second);
239
240    if o1 != o2 && o3 != o4 && o1 != 0 && o2 != 0 && o3 != 0 && o4 != 0 {
241        return true;
242    }
243    (o1 == 0 && point_on_segment(third, first, second))
244        || (o2 == 0 && point_on_segment(fourth, first, second))
245        || (o3 == 0 && point_on_segment(first, third, fourth))
246        || (o4 == 0 && point_on_segment(second, third, fourth))
247}
248
249#[inline]
250fn orientation<P>(first: &P, second: &P, third: &P) -> i8
251where
252    P: Point<Scalar = f64>,
253{
254    let cross = (second.get::<0>() - first.get::<0>()) * (third.get::<1>() - first.get::<1>())
255        - (second.get::<1>() - first.get::<1>()) * (third.get::<0>() - first.get::<0>());
256    if cross > 0.0 {
257        1
258    } else if cross < 0.0 {
259        -1
260    } else {
261        0
262    }
263}
264
265#[inline]
266fn point_on_segment<P>(point: &P, first: &P, second: &P) -> bool
267where
268    P: Point<Scalar = f64>,
269{
270    let x = point.get::<0>();
271    let y = point.get::<1>();
272    first.get::<0>().min(second.get::<0>()) <= x
273        && x <= first.get::<0>().max(second.get::<0>())
274        && first.get::<1>().min(second.get::<1>()) <= y
275        && y <= first.get::<1>().max(second.get::<1>())
276}
277
278/// Recursive Douglas–Peucker split over `pts[lo..=hi]`.
279///
280/// Marks the vertex furthest from the chord `pts[lo]`-`pts[hi]` as kept
281/// when its distance exceeds `eps`, then recurses on the two halves.
282/// Mirrors the `douglas_peucker::apply` recursion in
283/// `boost/geometry/strategies/agnostic/simplify_douglas_peucker.hpp`.
284fn dp_recurse<P, D>(pts: &[P], lo: usize, hi: usize, eps: f64, dist: &D, keep: &mut [bool])
285where
286    P: Point<Scalar = f64> + PointMut + Default + Copy,
287    D: DistanceStrategy<P, geometry_model::Segment<P>, Out = f64>,
288    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
289{
290    if hi <= lo + 1 {
291        return;
292    }
293    let seg = geometry_model::Segment::new(pts[lo], pts[hi]);
294
295    // Find the interior vertex furthest from the chord.
296    let mut max_d = 0.0_f64;
297    let mut max_i = lo;
298    for (i, p) in pts.iter().enumerate().take(hi).skip(lo + 1) {
299        let d = dist.distance(p, &seg);
300        if d > max_d {
301            max_d = d;
302            max_i = i;
303        }
304    }
305
306    // If the furthest deviation exceeds the tolerance, keep that vertex
307    // and recurse on the two halves; otherwise all interior vertices
308    // drop.
309    if max_d > eps {
310        keep[max_i] = true;
311        dp_recurse(pts, lo, max_i, eps, dist, keep);
312        dp_recurse(pts, max_i, hi, eps, dist, keep);
313    }
314}
315
316#[cfg(test)]
317#[allow(
318    clippy::float_cmp,
319    reason = "Simplified coordinates are exact literals."
320)]
321mod tests {
322    //! Reference behaviour from
323    //! `boost/geometry/test/algorithms/simplify.cpp` — a straight
324    //! polyline collapses to its endpoints, and a spike above tolerance
325    //! survives while a sub-tolerance wiggle drops.
326
327    extern crate alloc;
328
329    use super::{
330        DouglasPeucker, SimplifyStrategy, orientation, point_on_segment, segments_intersect,
331    };
332    use crate::cartesian::{PointToSegment, Pythagoras};
333    use alloc::vec;
334    use alloc::vec::Vec;
335    use geometry_cs::Cartesian;
336    use geometry_model::{Linestring, Point2D, linestring};
337    use geometry_trait::Point as _;
338
339    type Pt = Point2D<f64, Cartesian>;
340
341    fn default_dp() -> DouglasPeucker<PointToSegment<Pythagoras>> {
342        DouglasPeucker::default()
343    }
344
345    fn coords(ls: &Linestring<Pt>) -> Vec<(f64, f64)> {
346        ls.0.iter().map(|p| (p.get::<0>(), p.get::<1>())).collect()
347    }
348
349    #[test]
350    fn straight_three_point_line_collapses_to_endpoints() {
351        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 0.), (2., 0.)];
352        let s = default_dp().simplify(&ls, 0.5);
353        assert_eq!(coords(&s), vec![(0., 0.), (2., 0.)]);
354    }
355
356    #[test]
357    fn collinear_polyline_collapses_to_endpoints() {
358        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 1.), (2., 2.), (3., 3.)];
359        let s = default_dp().simplify(&ls, 0.001);
360        assert_eq!(coords(&s), vec![(0., 0.), (3., 3.)]);
361    }
362
363    #[test]
364    fn tiny_deviation_drops_but_large_deviation_kept() {
365        // A near-straight run with one vertex bumped far off the chord.
366        // Small tolerance drops the tiny wiggle but keeps the spike.
367        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 0.01), (2., 0.), (3., 5.), (4., 0.)];
368        let s = default_dp().simplify(&ls, 0.5);
369        let c = coords(&s);
370        assert_eq!(c.first().unwrap(), &(0., 0.));
371        assert_eq!(c.last().unwrap(), &(4., 0.));
372        // The (3, 5) spike survives; the (1, 0.01) wiggle does not.
373        assert!(c.contains(&(3., 5.)));
374        assert!(!c.contains(&(1., 0.01)));
375    }
376
377    #[test]
378    fn short_linestring_is_returned_unchanged() {
379        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 1.)];
380        let s = default_dp().simplify(&ls, 10.0);
381        assert_eq!(coords(&s), vec![(0., 0.), (1., 1.)]);
382    }
383
384    #[test]
385    fn negative_tolerance_copies_through_without_overflow() {
386        // Regression: a negative tolerance over a fully collinear run
387        // used to recurse forever (max_d == 0.0 > eps, but the split
388        // index never advanced) and abort the process with a stack
389        // overflow. Boost copies the range through unchanged for
390        // `max_distance < 0` (simplify.hpp:294,359); so must the port.
391        let ls: Linestring<Pt> = linestring![(0., 0.), (1., 0.), (2., 0.)];
392        let s = default_dp().simplify(&ls, -0.5);
393        assert_eq!(coords(&s), vec![(0., 0.), (1., 0.), (2., 0.)]);
394        // Coincident interior points, also negative tolerance.
395        let dup: Linestring<Pt> = linestring![(0., 0.), (0., 0.), (0., 0.), (5., 0.)];
396        let s2 = default_dp().simplify(&dup, -1.0);
397        assert_eq!(s2.0.len(), 4, "negative tolerance keeps every vertex");
398    }
399
400    #[test]
401    fn private_collinear_intersection_guards_cover_every_endpoint_order() {
402        let a = Pt::new(0.0, 0.0);
403        let b = Pt::new(2.0, 0.0);
404        assert!(segments_intersect(
405            &a,
406            &b,
407            &Pt::new(1.0, 0.0),
408            &Pt::new(1.0, 1.0)
409        ));
410        assert!(segments_intersect(
411            &a,
412            &b,
413            &Pt::new(1.0, 1.0),
414            &Pt::new(1.0, 0.0)
415        ));
416        assert!(segments_intersect(
417            &Pt::new(1.0, 0.0),
418            &Pt::new(1.0, 1.0),
419            &a,
420            &b,
421        ));
422        assert!(segments_intersect(
423            &Pt::new(1.0, 1.0),
424            &Pt::new(1.0, 0.0),
425            &a,
426            &b,
427        ));
428        assert_eq!(orientation(&a, &b, &Pt::new(1.0, 1.0)), 1);
429        assert_eq!(orientation(&a, &b, &Pt::new(1.0, -1.0)), -1);
430        assert_eq!(orientation(&a, &b, &Pt::new(1.0, 0.0)), 0);
431        assert!(point_on_segment(&Pt::new(1.0, 0.0), &a, &b));
432    }
433}