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`. The predicate is
5//! Boost's `point_is_spike_or_equal`, and the `or_equal` half carries
6//! its weight: a triple `(a, b, c)` qualifies when `(b-a) × (c-b) == 0`
7//! (collinear) and `(b-a) · (c-b) <= 0`, which covers both a reversal
8//! and a zero-length step — that is, a repeated vertex. The middle
9//! vertex `b` is removed; the walk repeats until nothing qualifies,
10//! because collapsing one spike can create a new one at the
11//! now-adjacent pair, and peeling a spike off a ring routinely leaves a
12//! repeated vertex behind.
13//!
14//! Per-kind:
15//! * `Linestring`, `Ring`  → spike-walk the backing `Vec<P>`
16//! * `Polygon`             → walk outer + every inner ring
17//! * `MultiPolygon`        → walk each member
18//!
19//! Cartesian-only: the collinearity / reversal predicate is the 2D
20//! cross/dot product. Spherical / geographic spike detection needs
21//! angle-aware predicates; deferred until a downstream caller appears.
22
23use geometry_coords::CoordinateScalar;
24use geometry_model::{Linestring, MultiPolygon, Polygon, Ring};
25use geometry_trait::Point as PointTrait;
26
27/// Remove spikes from `g` in place.
28///
29/// Mirrors `boost::geometry::remove_spikes(g)` from
30/// `boost/geometry/algorithms/remove_spikes.hpp`.
31pub fn remove_spikes<G: RemoveSpikes>(g: &mut G) {
32    g.remove_spikes();
33}
34
35/// Per-kind spike-removal dispatch.
36#[doc(hidden)]
37pub trait RemoveSpikes {
38    fn remove_spikes(&mut self);
39}
40
41/// True iff `b` is a spike between `a` and `c`, **or** duplicates one of
42/// them: 2D cross `== 0` and dot `<= 0`.
43///
44/// Mirrors `detail::point_is_spike_or_equal`
45/// (`algorithms/detail/point_is_spike_or_equal.hpp`). Requiring `dot < 0`
46/// instead would leave every repeated vertex in place, including the ones
47/// this function creates: removing the apex of `(4,0) (6,0) (4,0)` leaves
48/// `(4,0) (4,0)` adjacent, and Boost collapses that.
49///
50/// `dot <= 0` cannot over-match. Two non-zero vectors that are both
51/// parallel (`cross == 0`) and perpendicular (`dot == 0`) do not exist, so
52/// the equality arm fires only when one of the steps has zero length.
53fn is_spike_or_equal_2d<P: PointTrait>(a: &P, b: &P, c: &P) -> bool {
54    let ux = b.get::<0>() - a.get::<0>();
55    let uy = b.get::<1>() - a.get::<1>();
56    let vx = c.get::<0>() - b.get::<0>();
57    let vy = c.get::<1>() - b.get::<1>();
58    let cross = ux * vy - uy * vx;
59    let dot = ux * vx + uy * vy;
60    let zero = <P::Scalar as CoordinateScalar>::ZERO;
61    // The collinearity half is Boost's `side_by_triangle`, which calls three
62    // points collinear whenever any *two* of them are equal by `math::equals`
63    // — a relative epsilon — before it looks at any determinant
64    // (`side_by_triangle.hpp:150-164`). A hairline whose two ends are a few
65    // last bits apart at a large coordinate is a spike to Boost and a genuine
66    // sliver to an exact cross product, which is how one survived into a tile
67    // that the reference drew as nothing.
68    let same = |ax: P::Scalar, ay: P::Scalar, bx: P::Scalar, by: P::Scalar| {
69        ax.tolerant_eq(bx) && ay.tolerant_eq(by)
70    };
71    let collinear = cross == zero
72        || same(a.get::<0>(), a.get::<1>(), b.get::<0>(), b.get::<1>())
73        || same(a.get::<0>(), a.get::<1>(), c.get::<0>(), c.get::<1>())
74        || same(b.get::<0>(), b.get::<1>(), c.get::<0>(), c.get::<1>());
75    collinear && dot <= zero
76}
77
78fn walk_spikes<P: PointTrait>(pts: &mut alloc::vec::Vec<P>) {
79    let mut changed = true;
80    while changed && pts.len() >= 3 {
81        changed = false;
82        let mut i = 1;
83        while i + 1 < pts.len() {
84            if is_spike_or_equal_2d(&pts[i - 1], &pts[i], &pts[i + 1]) {
85                pts.remove(i);
86                changed = true;
87                // Do not advance `i`: the new `pts[i]` (was `pts[i+1]`)
88                // may now form a spike with `pts[i-1]`.
89                if i > 1 {
90                    i -= 1;
91                }
92            } else {
93                i += 1;
94            }
95        }
96    }
97}
98
99impl<P: PointTrait> RemoveSpikes for Linestring<P> {
100    fn remove_spikes(&mut self) {
101        walk_spikes(&mut self.0);
102    }
103}
104
105/// Spike-walk a **ring**: the interior linear pass plus the wrap-around
106/// seam that a linestring does not have.
107///
108/// Mirrors `detail::remove_spikes::range_remove_spikes::apply`
109/// (`algorithms/remove_spikes.hpp:99-141`). After the interior pass,
110/// Boost drops the closing point of a closed ring, then repeatedly
111/// removes a spike formed at the *first* vertex — the triple
112/// `(back-1, back, front)` — and at the *second* — `(back, front,
113/// front+1)` — until neither fires, and re-adds the closing point. The
114/// interior [`walk_spikes`] alone never forms those seam triples, so a
115/// spike sitting on the ring's first/last vertex would otherwise survive.
116///
117/// `closed` is `true` when the backing vector repeats its first vertex as
118/// its last (the model's `CLOSED` const generic).
119fn walk_ring_spikes<P: PointTrait + Copy>(pts: &mut alloc::vec::Vec<P>, closed: bool) {
120    // Interior pass first.
121    walk_spikes(pts);
122
123    // Work on the open sequence: drop the duplicated closing vertex, if
124    // any, so `first` and `last` are distinct ring vertices.
125    let had_closing = closed && pts.len() >= 2 && same_point(&pts[0], &pts[pts.len() - 1]);
126    if had_closing {
127        pts.pop();
128    }
129
130    // Seam cleanup: alternately peel a spike off the back (last vertex)
131    // and the front (first vertex) until the seam is clean.
132    let mut found = true;
133    while found {
134        found = false;
135        // Spike at the first point: (prev = back-1, back, front).
136        while pts.len() >= 3
137            && is_spike_or_equal_2d(&pts[pts.len() - 2], &pts[pts.len() - 1], &pts[0])
138        {
139            pts.pop();
140            found = true;
141        }
142        // Spike at the second point: (back, front, front+1).
143        while pts.len() >= 3 && is_spike_or_equal_2d(&pts[pts.len() - 1], &pts[0], &pts[1]) {
144            pts.remove(0);
145            found = true;
146        }
147    }
148
149    // Re-add the closing vertex we removed, restoring the ring's closure.
150    if had_closing && !pts.is_empty() {
151        let first = pts[0];
152        pts.push(first);
153    }
154}
155
156/// Coordinate equality of two points (2D).
157fn same_point<P: PointTrait>(a: &P, b: &P) -> bool {
158    a.get::<0>() == b.get::<0>() && a.get::<1>() == b.get::<1>()
159}
160
161impl<P: PointTrait + Copy, const CW: bool, const CL: bool> RemoveSpikes for Ring<P, CW, CL> {
162    fn remove_spikes(&mut self) {
163        walk_ring_spikes(&mut self.0, CL);
164    }
165}
166
167impl<P: PointTrait + Copy, const CW: bool, const CL: bool> RemoveSpikes for Polygon<P, CW, CL> {
168    fn remove_spikes(&mut self) {
169        walk_ring_spikes(&mut self.outer.0, CL);
170        for inner in &mut self.inners {
171            walk_ring_spikes(&mut inner.0, CL);
172        }
173    }
174}
175
176impl<Pg: RemoveSpikes + geometry_trait::Polygon> RemoveSpikes for MultiPolygon<Pg> {
177    fn remove_spikes(&mut self) {
178        for p in &mut self.0 {
179            p.remove_spikes();
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    //! Ring and polygon behaviour follows
187    //! `boost/geometry/test/algorithms/remove_spikes.cpp`. Boost only
188    //! dispatches areal kinds and leaves a linestring untouched;
189    //! collapsing an out-and-back spur on a linestring to its base
190    //! vertex is this port's extension, not a Boost fixture.
191
192    use super::remove_spikes;
193    use geometry_cs::Cartesian;
194    use geometry_model::{Point2D, Ring, linestring};
195    use geometry_trait::{Linestring as _, Point as _, Ring as _};
196
197    type P = Point2D<f64, Cartesian>;
198
199    fn spike_ring(points: &[(f64, f64)]) -> Ring<P> {
200        let mut ring = Ring::new();
201        for &(x, y) in points {
202            ring.push(P::new(x, y));
203        }
204        ring
205    }
206
207    /// A hairline whose two ends are four last bits apart at a coordinate of
208    /// 3540, which is inside one epsilon of it.
209    ///
210    /// C++: `side_by_triangle` calls three points collinear when any two of
211    /// them are `math::equals` — a *relative* epsilon — before it computes any
212    /// determinant, so Boost sees a spike here and collapses the ring to a
213    /// single repeated point. An exact cross product sees a sliver with real
214    /// area and keeps it, which is how one survived into a monaco tile the
215    /// reference drew as nothing.
216    #[test]
217    fn a_hairline_within_an_epsilon_is_a_spike() {
218        let mut ring = spike_ring(&[
219            (3_539.999_999_999_999_5, 482.199_999_999_999_76),
220            (3540.0, 482.199_999_999_999_8),
221            (3540.0, 479.0),
222            (3_539.999_999_999_999_5, 482.199_999_999_999_76),
223        ]);
224        remove_spikes(&mut ring);
225        assert_eq!(ring.0.len(), 2, "{:?}", ring.0);
226    }
227
228    /// The same ring with its two ends far enough apart to be two points,
229    /// where the sliver has real area and stays.
230    #[test]
231    fn a_sliver_wider_than_an_epsilon_is_kept() {
232        let mut ring = spike_ring(&[
233            (3_539.999_999_9, 482.199_999_9),
234            (3540.0, 482.2),
235            (3540.0, 479.0),
236            (3_539.999_999_9, 482.199_999_9),
237        ]);
238        remove_spikes(&mut ring);
239        assert_eq!(ring.0.len(), 4, "{:?}", ring.0);
240    }
241
242    #[test]
243    fn out_and_back_spur_is_removed() {
244        // (0,0) → (1,0) → (3,0) → (2,0): the tip (3,0) is a reversed
245        // collinear overshoot between (1,0) and (2,0), so it is dropped,
246        // leaving the monotone run (0,0) → (1,0) → (2,0).
247        let mut ls: geometry_model::Linestring<P> =
248            linestring![(0.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)];
249        remove_spikes(&mut ls);
250        let xs: Vec<f64> = ls.points().map(geometry_trait::Point::get::<0>).collect();
251        assert_eq!(xs, vec![0.0, 1.0, 2.0]);
252    }
253
254    #[test]
255    fn spike_free_linestring_is_unchanged() {
256        let mut ls: geometry_model::Linestring<P> = linestring![(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)];
257        remove_spikes(&mut ls);
258        assert_eq!(ls.points().count(), 3);
259    }
260
261    /// A cascading spike: removing the inner tip exposes a second spike
262    /// at the now-adjacent pair, which the non-advancing backtrack
263    /// (`i -= 1`) then also removes. All overshoots collapse to the base
264    /// monotone run.
265    #[test]
266    fn cascading_spikes_all_collapse() {
267        // (0,0) → (2,0) → (5,0) → (3,0) → (1,0): both (5,0) and the
268        // resulting reversed vertices are collinear overshoots along the
269        // x-axis. After the walk only a monotone sequence survives.
270        let mut ls: geometry_model::Linestring<P> =
271            linestring![(0.0, 0.0), (2.0, 0.0), (5.0, 0.0), (3.0, 0.0), (1.0, 0.0)];
272        remove_spikes(&mut ls);
273        let xs: Vec<f64> = ls.points().map(geometry_trait::Point::get::<0>).collect();
274        assert_eq!(xs, vec![0.0, 1.0]);
275    }
276
277    /// A `Polygon` removes spikes from its exterior *and* every interior
278    /// ring.
279    #[test]
280    fn polygon_removes_spikes_in_outer_and_holes() {
281        use geometry_model::{Polygon, Ring};
282        use geometry_trait::{Point as _, Polygon as _, Ring as _};
283        // Outer square with a spur vertex (5,0) on the bottom edge.
284        let outer = Ring::from_vec(vec![
285            P::new(0.0, 0.0),
286            P::new(4.0, 0.0),
287            P::new(5.0, 0.0), // reversed-collinear overshoot then back
288            P::new(4.0, 0.0),
289            P::new(4.0, 4.0),
290            P::new(0.0, 4.0),
291            P::new(0.0, 0.0),
292        ]);
293        // Hole with its own spur.
294        let hole = Ring::from_vec(vec![
295            P::new(1.0, 1.0),
296            P::new(2.0, 1.0),
297            P::new(3.0, 1.0), // overshoot
298            P::new(2.0, 1.0),
299            P::new(2.0, 2.0),
300            P::new(1.0, 1.0),
301        ]);
302        let mut pg: Polygon<P> = Polygon::with_inners(outer, vec![hole]);
303        remove_spikes(&mut pg);
304        // The (5,0) and (3,1) overshoot vertices are gone.
305        let ext: Vec<(f64, f64)> = pg
306            .exterior()
307            .points()
308            .map(|p| (p.get::<0>(), p.get::<1>()))
309            .collect();
310        assert!(!ext.contains(&(5.0, 0.0)), "outer spike survived: {ext:?}");
311        let hole_pts: Vec<(f64, f64)> = pg
312            .interiors()
313            .next()
314            .unwrap()
315            .points()
316            .map(|p| (p.get::<0>(), p.get::<1>()))
317            .collect();
318        assert!(!hole_pts.contains(&(3.0, 1.0)), "hole spike survived");
319    }
320
321    /// A `MultiPolygon` removes spikes from each member polygon.
322    #[test]
323    fn multipolygon_removes_spikes_from_each_member() {
324        use geometry_model::{MultiPolygon, Polygon, Ring};
325        use geometry_trait::{Point as _, Polygon as _, Ring as _};
326        let spiky = || {
327            Polygon::<P>::new(Ring::from_vec(vec![
328                P::new(0.0, 0.0),
329                P::new(4.0, 0.0),
330                P::new(5.0, 0.0),
331                P::new(4.0, 0.0),
332                P::new(4.0, 4.0),
333                P::new(0.0, 4.0),
334                P::new(0.0, 0.0),
335            ]))
336        };
337        let mut mpg: MultiPolygon<Polygon<P>> = MultiPolygon(vec![spiky(), spiky()]);
338        remove_spikes(&mut mpg);
339        for pg in &mpg.0 {
340            let pts: Vec<(f64, f64)> = pg
341                .exterior()
342                .points()
343                .map(|p| (p.get::<0>(), p.get::<1>()))
344                .collect();
345            assert!(!pts.contains(&(5.0, 0.0)), "member spike survived");
346        }
347    }
348
349    #[test]
350    fn ring_seam_spike_is_removed() {
351        // A closed ring whose FIRST vertex is a reversed-collinear spike
352        // straddling the seam — a triple the interior pass never inspects.
353        // Vertices: (0,0)[seam], (2,0), (2,2), (0,2), (1,0), close(0,0).
354        // Dropping the closing duplicate leaves the open loop
355        //   [(0,0), (2,0), (2,2), (0,2), (1,0)].
356        // Seam triple at the first vertex is (back=(1,0), front=(0,0),
357        // front+1=(2,0)): u=(0,0)−(1,0)=(−1,0), v=(2,0)−(0,0)=(2,0),
358        // cross=0 and dot=−2<0 → a spike at (0,0). Boost removes it; the
359        // wrap-around seam cleanup must too.
360        use geometry_model::Ring;
361        use geometry_trait::{Point as _, Ring as _};
362
363        let mut r: Ring<P> = Ring::from_vec(vec![
364            P::new(0.0, 0.0),
365            P::new(2.0, 0.0),
366            P::new(2.0, 2.0),
367            P::new(0.0, 2.0),
368            P::new(1.0, 0.0),
369            P::new(0.0, 0.0),
370        ]);
371        remove_spikes(&mut r);
372
373        let pts: Vec<(f64, f64)> = r.points().map(|p| (p.get::<0>(), p.get::<1>())).collect();
374        // The seam spike vertex (0,0) was dropped.
375        assert!(
376            !pts.contains(&(0.0, 0.0)),
377            "seam spike vertex (0,0) must be gone: {pts:?}"
378        );
379        // Ring stays closed and non-degenerate.
380        assert!(r.points().count() >= 4);
381        assert_eq!(pts.first(), pts.last(), "ring must remain closed");
382    }
383
384    /// Boost collapses a repeated vertex the same way it collapses a
385    /// spike — `point_is_spike_or_equal` covers both. Expected values from
386    /// `boost::geometry::remove_spikes` on a clockwise `model::polygon`
387    /// (Boost 1.83):
388    ///
389    /// ```text
390    /// consecutive dup  -> (0,0) (0,4) (4,4) (4,0) (0,0)
391    /// dup at start     -> (0,0) (0,4) (4,4) (4,0) (0,0)
392    /// triple dup       -> (0,0) (0,4) (4,4) (4,0) (0,0)
393    /// real spike       -> (0,0) (0,4) (4,4) (4,0) (0,0)
394    /// dup + spike      -> (0,0) (0,4) (4,4) (4,0) (0,0)
395    /// ```
396    ///
397    /// The `real spike` row is the one that shows why: removing the apex
398    /// of `(4,0) (6,0) (4,0)` leaves `(4,0) (4,0)` adjacent, so a
399    /// spike-only predicate makes duplicates out of its own output.
400    #[test]
401    fn repeated_vertices_are_collapsed() {
402        let square = [(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0), (0.0, 0.0)];
403
404        for (name, input) in [
405            (
406                "consecutive dup",
407                vec![
408                    (0.0, 0.0),
409                    (0.0, 4.0),
410                    (4.0, 4.0),
411                    (4.0, 4.0),
412                    (4.0, 0.0),
413                    (0.0, 0.0),
414                ],
415            ),
416            (
417                "dup at start",
418                vec![
419                    (0.0, 0.0),
420                    (0.0, 0.0),
421                    (0.0, 4.0),
422                    (4.0, 4.0),
423                    (4.0, 0.0),
424                    (0.0, 0.0),
425                ],
426            ),
427            (
428                "triple dup",
429                vec![
430                    (0.0, 0.0),
431                    (0.0, 4.0),
432                    (4.0, 4.0),
433                    (4.0, 4.0),
434                    (4.0, 4.0),
435                    (4.0, 0.0),
436                    (0.0, 0.0),
437                ],
438            ),
439            (
440                "real spike",
441                vec![
442                    (0.0, 0.0),
443                    (0.0, 4.0),
444                    (4.0, 4.0),
445                    (4.0, 0.0),
446                    (6.0, 0.0),
447                    (4.0, 0.0),
448                    (0.0, 0.0),
449                ],
450            ),
451            (
452                "dup + spike",
453                vec![
454                    (0.0, 0.0),
455                    (0.0, 4.0),
456                    (4.0, 4.0),
457                    (4.0, 4.0),
458                    (4.0, 0.0),
459                    (6.0, 0.0),
460                    (4.0, 0.0),
461                    (0.0, 0.0),
462                ],
463            ),
464        ] {
465            let mut ring: Ring<P> =
466                Ring::from_vec(input.iter().map(|&(x, y)| P::new(x, y)).collect());
467            remove_spikes(&mut ring);
468            let pts: Vec<(f64, f64)> = ring
469                .points()
470                .map(|p| (p.get::<0>(), p.get::<1>()))
471                .collect();
472            assert_eq!(pts, square, "{name}");
473        }
474    }
475}