Skip to main content

geometry_algorithm/
remove_spikes.rs

1//! `remove_spikes(&mut g)` — drop collinear-and-reversed vertices.
2//!
3//! Mirrors `boost::geometry::remove_spikes` from
4//! `boost/geometry/algorithms/remove_spikes.hpp`. A spike is a triple
5//! `(a, b, c)` where `(b-a) × (c-b) == 0` (collinear) AND
6//! `(b-a) · (c-b) < 0` (reversed). The middle vertex `b` is removed;
7//! the walk repeats until no spike remains, because collapsing one
8//! spike can create a new one at the now-adjacent pair.
9//!
10//! Per-kind:
11//! * `Linestring`, `Ring`  → spike-walk the backing `Vec<P>`
12//! * `Polygon`             → walk outer + every inner ring
13//! * `MultiPolygon`        → walk each member
14//!
15//! Cartesian-only: the collinearity / reversal predicate is the 2D
16//! cross/dot product. Spherical / geographic spike detection needs
17//! angle-aware predicates; deferred until a downstream caller appears.
18
19use geometry_coords::CoordinateScalar;
20use geometry_model::{Linestring, MultiPolygon, Polygon, Ring};
21use geometry_trait::Point as PointTrait;
22
23/// Remove spikes from `g` in place.
24///
25/// Mirrors `boost::geometry::remove_spikes(g)` from
26/// `boost/geometry/algorithms/remove_spikes.hpp`.
27pub fn remove_spikes<G: RemoveSpikes>(g: &mut G) {
28    g.remove_spikes();
29}
30
31/// Per-kind spike-removal dispatch.
32#[doc(hidden)]
33pub trait RemoveSpikes {
34    fn remove_spikes(&mut self);
35}
36
37/// True iff `b` is a spike between `a` and `c` (2D cross `== 0` AND
38/// dot `< 0`).
39fn is_spike_2d<P: PointTrait>(a: &P, b: &P, c: &P) -> bool {
40    let ux = b.get::<0>() - a.get::<0>();
41    let uy = b.get::<1>() - a.get::<1>();
42    let vx = c.get::<0>() - b.get::<0>();
43    let vy = c.get::<1>() - b.get::<1>();
44    let cross = ux * vy - uy * vx;
45    let dot = ux * vx + uy * vy;
46    let zero = <P::Scalar as CoordinateScalar>::ZERO;
47    cross == zero && dot < zero
48}
49
50fn walk_spikes<P: PointTrait>(pts: &mut alloc::vec::Vec<P>) {
51    let mut changed = true;
52    while changed && pts.len() >= 3 {
53        changed = false;
54        let mut i = 1;
55        while i + 1 < pts.len() {
56            if is_spike_2d(&pts[i - 1], &pts[i], &pts[i + 1]) {
57                pts.remove(i);
58                changed = true;
59                // Do not advance `i`: the new `pts[i]` (was `pts[i+1]`)
60                // may now form a spike with `pts[i-1]`.
61                if i > 1 {
62                    i -= 1;
63                }
64            } else {
65                i += 1;
66            }
67        }
68    }
69}
70
71impl<P: PointTrait> RemoveSpikes for Linestring<P> {
72    fn remove_spikes(&mut self) {
73        walk_spikes(&mut self.0);
74    }
75}
76
77/// Spike-walk a **ring**: the interior linear pass plus the wrap-around
78/// seam that a linestring does not have.
79///
80/// Mirrors `detail::remove_spikes::range_remove_spikes::apply`
81/// (`algorithms/remove_spikes.hpp:99-141`). After the interior pass,
82/// Boost drops the closing point of a closed ring, then repeatedly
83/// removes a spike formed at the *first* vertex — the triple
84/// `(back-1, back, front)` — and at the *second* — `(back, front,
85/// front+1)` — until neither fires, and re-adds the closing point. The
86/// interior [`walk_spikes`] alone never forms those seam triples, so a
87/// spike sitting on the ring's first/last vertex would otherwise survive.
88///
89/// `closed` is `true` when the backing vector repeats its first vertex as
90/// its last (the model's `CLOSED` const generic).
91fn walk_ring_spikes<P: PointTrait + Copy>(pts: &mut alloc::vec::Vec<P>, closed: bool) {
92    // Interior pass first.
93    walk_spikes(pts);
94
95    // Work on the open sequence: drop the duplicated closing vertex, if
96    // any, so `first` and `last` are distinct ring vertices.
97    let had_closing = closed && pts.len() >= 2 && same_point(&pts[0], &pts[pts.len() - 1]);
98    if had_closing {
99        pts.pop();
100    }
101
102    // Seam cleanup: alternately peel a spike off the back (last vertex)
103    // and the front (first vertex) until the seam is clean.
104    let mut found = true;
105    while found {
106        found = false;
107        // Spike at the first point: (prev = back-1, back, front).
108        while pts.len() >= 3 && is_spike_2d(&pts[pts.len() - 2], &pts[pts.len() - 1], &pts[0]) {
109            pts.pop();
110            found = true;
111        }
112        // Spike at the second point: (back, front, front+1).
113        while pts.len() >= 3 && is_spike_2d(&pts[pts.len() - 1], &pts[0], &pts[1]) {
114            pts.remove(0);
115            found = true;
116        }
117    }
118
119    // Re-add the closing vertex we removed, restoring the ring's closure.
120    if had_closing && !pts.is_empty() {
121        let first = pts[0];
122        pts.push(first);
123    }
124}
125
126/// Coordinate equality of two points (2D).
127fn same_point<P: PointTrait>(a: &P, b: &P) -> bool {
128    a.get::<0>() == b.get::<0>() && a.get::<1>() == b.get::<1>()
129}
130
131impl<P: PointTrait + Copy, const CW: bool, const CL: bool> RemoveSpikes for Ring<P, CW, CL> {
132    fn remove_spikes(&mut self) {
133        walk_ring_spikes(&mut self.0, CL);
134    }
135}
136
137impl<P: PointTrait + Copy, const CW: bool, const CL: bool> RemoveSpikes for Polygon<P, CW, CL> {
138    fn remove_spikes(&mut self) {
139        walk_ring_spikes(&mut self.outer.0, CL);
140        for inner in &mut self.inners {
141            walk_ring_spikes(&mut inner.0, CL);
142        }
143    }
144}
145
146impl<Pg: RemoveSpikes + geometry_trait::Polygon> RemoveSpikes for MultiPolygon<Pg> {
147    fn remove_spikes(&mut self) {
148        for p in &mut self.0 {
149            p.remove_spikes();
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    //! Reference behaviour from
157    //! `boost/geometry/test/algorithms/remove_spikes.cpp`: an
158    //! out-and-back spur on a linestring is collapsed to its base
159    //! vertex.
160
161    use super::remove_spikes;
162    use geometry_cs::Cartesian;
163    use geometry_model::{Point2D, linestring};
164    use geometry_trait::Linestring as _;
165
166    type P = Point2D<f64, Cartesian>;
167
168    #[test]
169    fn out_and_back_spur_is_removed() {
170        // (0,0) → (1,0) → (3,0) → (2,0): the tip (3,0) is a reversed
171        // collinear overshoot between (1,0) and (2,0), so it is dropped,
172        // leaving the monotone run (0,0) → (1,0) → (2,0).
173        let mut ls: geometry_model::Linestring<P> =
174            linestring![(0.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)];
175        remove_spikes(&mut ls);
176        let xs: Vec<f64> = ls.points().map(geometry_trait::Point::get::<0>).collect();
177        assert_eq!(xs, vec![0.0, 1.0, 2.0]);
178    }
179
180    #[test]
181    fn spike_free_linestring_is_unchanged() {
182        let mut ls: geometry_model::Linestring<P> = linestring![(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)];
183        remove_spikes(&mut ls);
184        assert_eq!(ls.points().count(), 3);
185    }
186
187    /// A cascading spike: removing the inner tip exposes a second spike
188    /// at the now-adjacent pair, which the non-advancing backtrack
189    /// (`i -= 1`) then also removes. All overshoots collapse to the base
190    /// monotone run.
191    #[test]
192    fn cascading_spikes_all_collapse() {
193        // (0,0) → (2,0) → (5,0) → (3,0) → (1,0): both (5,0) and the
194        // resulting reversed vertices are collinear overshoots along the
195        // x-axis. After the walk only a monotone sequence survives.
196        let mut ls: geometry_model::Linestring<P> =
197            linestring![(0.0, 0.0), (2.0, 0.0), (5.0, 0.0), (3.0, 0.0), (1.0, 0.0)];
198        remove_spikes(&mut ls);
199        let xs: Vec<f64> = ls.points().map(geometry_trait::Point::get::<0>).collect();
200        // The sequence is strictly monotone (no reversal remains): each
201        // step moves in one direction only.
202        for w in xs.windows(3) {
203            let d1 = w[1] - w[0];
204            let d2 = w[2] - w[1];
205            assert!(d1 * d2 >= 0.0, "residual reversal in {xs:?}");
206        }
207    }
208
209    /// A `Polygon` removes spikes from its exterior *and* every interior
210    /// ring.
211    #[test]
212    fn polygon_removes_spikes_in_outer_and_holes() {
213        use geometry_model::{Polygon, Ring};
214        use geometry_trait::{Point as _, Polygon as _, Ring as _};
215        // Outer square with a spur vertex (5,0) on the bottom edge.
216        let outer = Ring::from_vec(vec![
217            P::new(0.0, 0.0),
218            P::new(4.0, 0.0),
219            P::new(5.0, 0.0), // reversed-collinear overshoot then back
220            P::new(4.0, 0.0),
221            P::new(4.0, 4.0),
222            P::new(0.0, 4.0),
223            P::new(0.0, 0.0),
224        ]);
225        // Hole with its own spur.
226        let hole = Ring::from_vec(vec![
227            P::new(1.0, 1.0),
228            P::new(2.0, 1.0),
229            P::new(3.0, 1.0), // overshoot
230            P::new(2.0, 1.0),
231            P::new(2.0, 2.0),
232            P::new(1.0, 1.0),
233        ]);
234        let mut pg: Polygon<P> = Polygon::with_inners(outer, vec![hole]);
235        remove_spikes(&mut pg);
236        // The (5,0) and (3,1) overshoot vertices are gone.
237        let ext: Vec<(f64, f64)> = pg
238            .exterior()
239            .points()
240            .map(|p| (p.get::<0>(), p.get::<1>()))
241            .collect();
242        assert!(!ext.contains(&(5.0, 0.0)), "outer spike survived: {ext:?}");
243        let hole_pts: Vec<(f64, f64)> = pg
244            .interiors()
245            .next()
246            .unwrap()
247            .points()
248            .map(|p| (p.get::<0>(), p.get::<1>()))
249            .collect();
250        assert!(!hole_pts.contains(&(3.0, 1.0)), "hole spike survived");
251    }
252
253    /// A `MultiPolygon` removes spikes from each member polygon.
254    #[test]
255    fn multipolygon_removes_spikes_from_each_member() {
256        use geometry_model::{MultiPolygon, Polygon, Ring};
257        use geometry_trait::{Point as _, Polygon as _, Ring as _};
258        let spiky = || {
259            Polygon::<P>::new(Ring::from_vec(vec![
260                P::new(0.0, 0.0),
261                P::new(4.0, 0.0),
262                P::new(5.0, 0.0),
263                P::new(4.0, 0.0),
264                P::new(4.0, 4.0),
265                P::new(0.0, 4.0),
266                P::new(0.0, 0.0),
267            ]))
268        };
269        let mut mpg: MultiPolygon<Polygon<P>> = MultiPolygon(vec![spiky(), spiky()]);
270        remove_spikes(&mut mpg);
271        for pg in &mpg.0 {
272            let pts: Vec<(f64, f64)> = pg
273                .exterior()
274                .points()
275                .map(|p| (p.get::<0>(), p.get::<1>()))
276                .collect();
277            assert!(!pts.contains(&(5.0, 0.0)), "member spike survived");
278        }
279    }
280
281    #[test]
282    fn ring_seam_spike_is_removed() {
283        // A closed ring whose FIRST vertex is a reversed-collinear spike
284        // straddling the seam — a triple the interior pass never inspects.
285        // Vertices: (0,0)[seam], (2,0), (2,2), (0,2), (1,0), close(0,0).
286        // Dropping the closing duplicate leaves the open loop
287        //   [(0,0), (2,0), (2,2), (0,2), (1,0)].
288        // Seam triple at the first vertex is (back=(1,0), front=(0,0),
289        // front+1=(2,0)): u=(0,0)−(1,0)=(−1,0), v=(2,0)−(0,0)=(2,0),
290        // cross=0 and dot=−2<0 → a spike at (0,0). Boost removes it; the
291        // wrap-around seam cleanup must too.
292        use geometry_model::Ring;
293        use geometry_trait::{Point as _, Ring as _};
294
295        let mut r: Ring<P> = Ring::from_vec(vec![
296            P::new(0.0, 0.0),
297            P::new(2.0, 0.0),
298            P::new(2.0, 2.0),
299            P::new(0.0, 2.0),
300            P::new(1.0, 0.0),
301            P::new(0.0, 0.0),
302        ]);
303        remove_spikes(&mut r);
304
305        let pts: Vec<(f64, f64)> = r.points().map(|p| (p.get::<0>(), p.get::<1>())).collect();
306        // The seam spike vertex (0,0) was dropped.
307        assert!(
308            !pts.contains(&(0.0, 0.0)),
309            "seam spike vertex (0,0) must be gone: {pts:?}"
310        );
311        // Ring stays closed and non-degenerate.
312        assert!(r.points().count() >= 4);
313        assert_eq!(pts.first(), pts.last(), "ring must remain closed");
314    }
315}