Skip to main content

geometry_overlay/
validity.rs

1//! OVL6.T4 — `is_valid` for rings and polygons.
2//!
3//! Mirrors `boost/geometry/algorithms/is_valid.hpp` and the failure
4//! taxonomy in `boost/geometry/algorithms/validity_failure_type.hpp`.
5//! A geometry is valid when it satisfies the OGC simple-feature rules:
6//! finite, in-range coordinates; enough points; a closed boundary; no
7//! spikes; no self-intersections; the expected ring orientation; and,
8//! for polygons, every interior ring covered by the exterior.
9//!
10//! **Deferred:** ring×ring edge-crossing detection (a hole whose
11//! representative vertex is covered by the exterior but whose edges
12//! cross the exterior or another hole; Boost's polygon-level
13//! `failure_self_intersections`) and consecutive duplicate points
14//! (Boost's `failure_duplicate_points`) are not checked.
15//!
16//! v1 scope: `Ring` and `Polygon` (areal). The linear / multi cases
17//! reuse the same primitives and are added alongside the linear
18//! overlay. Coordinate validity (NaN / infinity) is checked because the
19//! robustness gate depends on finite input.
20
21use alloc::vec::Vec;
22
23use geometry_coords::CoordinateScalar;
24use geometry_cs::{CartesianFamily, CoordinateSystem};
25use geometry_model::Segment;
26use geometry_strategy::{AreaStrategy, ShoelaceArea, WithinRing, WithinStrategy};
27use geometry_tag::SameAs;
28use geometry_trait::{Point, PointMut, Polygon as PolygonTrait, Ring as RingTrait};
29
30use crate::predicate::range_guard::coordinate_in_range;
31use crate::predicate::segment_intersection::{SegmentIntersection, segment_intersection};
32
33/// Why a geometry failed [`is_valid_ring`] / [`is_valid_polygon`].
34///
35/// Mirrors the subset of Boost's `validity_failure_type`
36/// (`algorithms/validity_failure_type.hpp`) that the v1 areal validator
37/// can produce. The numeric groupings (few-points, not-closed,
38/// self-intersections, …) match Boost's categories.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ValidityFailure {
41    /// Fewer than the 4 points a closed ring needs (3 distinct + the
42    /// repeated closing vertex). Boost's `failure_few_points`.
43    FewPoints,
44    /// The ring's first and last vertices differ — it is not closed.
45    /// Boost's `failure_not_closed`.
46    NotClosed,
47    /// Two non-adjacent edges of the ring cross, or an edge touches a
48    /// non-adjacent vertex. Boost's `failure_self_intersections`.
49    SelfIntersection,
50    /// A coordinate is NaN or infinite. Boost's
51    /// `failure_invalid_coordinate`.
52    InvalidCoordinate,
53    /// An interior ring is not contained in the exterior ring. Boost's
54    /// `failure_interior_rings_outside`.
55    InteriorRingOutside,
56    /// A coordinate lies outside the safe arithmetic range
57    /// ([`SAFE_ABS_MAX`](crate::predicate::range_guard::SAFE_ABS_MAX)).
58    /// Past that magnitude the segment-intersection kernel yields
59    /// `OutOfRange` and the self-intersection test would silently miss a
60    /// real crossing, so validity cannot be confirmed. Reported as a
61    /// distinct failure rather than a bogus "valid" (there is no Boost
62    /// analogue — Boost's rescaling policy sidesteps the range limit this
63    /// no-rescale port trades for).
64    CoordinateOutOfRange,
65    /// A vertex triple folds back on itself along one line — the ring
66    /// carries a spike. Boost's `failure_spikes`.
67    Spikes,
68    /// The ring's stored vertex order contradicts its declared
69    /// [`PointOrder`](geometry_trait::PointOrder) (or the ring has zero
70    /// area, which admits no orientation). Exterior rings must traverse
71    /// in their declared order (strategy-level signed area positive);
72    /// interior rings the opposite. Boost's `failure_wrong_orientation`.
73    WrongOrientation,
74}
75
76/// Validate a single ring.
77///
78/// Checks point count, closure, coordinate finiteness, that no two
79/// non-adjacent edges intersect, that no vertex triple is a spike, and
80/// that the ring is wound in its declared order. Returns `Ok(())` for a
81/// valid ring.
82///
83/// Mirrors the ring arm of `boost::geometry::is_valid`
84/// (`algorithms/is_valid.hpp`, via `detail/is_valid/ring.hpp`).
85///
86/// # Errors
87///
88/// A [`ValidityFailure`] describing the first rule the ring violates,
89/// including [`ValidityFailure::Spikes`] and
90/// [`ValidityFailure::WrongOrientation`].
91///
92/// # Examples
93///
94/// ```
95/// use geometry_cs::Cartesian;
96/// use geometry_model::{Point2D, Ring};
97/// use geometry_overlay::validity::is_valid_ring;
98///
99/// type P = Point2D<f64, Cartesian>;
100/// let square: Ring<P> = Ring::from_vec(vec![
101///     P::new(0.0, 0.0), P::new(0.0, 1.0), P::new(1.0, 1.0), P::new(1.0, 0.0), P::new(0.0, 0.0),
102/// ]);
103/// assert!(is_valid_ring(&square).is_ok());
104/// ```
105pub fn is_valid_ring<R, P>(ring: &R) -> Result<(), ValidityFailure>
106where
107    R: RingTrait<Point = P>,
108    P: PointMut + Default + Copy,
109    P::Scalar: CoordinateScalar + Into<f64>,
110    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
111{
112    validate_ring(ring, false)
113}
114
115/// Shared ring validation. `is_interior` flips the orientation
116/// expectation: an exterior ring traverses in its declared order
117/// (strategy-level `ShoelaceArea` positive); an interior ring winds
118/// opposite (negative) — mirroring Boost's
119/// `is_properly_oriented<Ring, IsInteriorRing>`.
120fn validate_ring<R, P>(ring: &R, is_interior: bool) -> Result<(), ValidityFailure>
121where
122    R: RingTrait<Point = P>,
123    P: PointMut + Default + Copy,
124    P::Scalar: CoordinateScalar + Into<f64>,
125    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
126{
127    let pts: Vec<P> = ring.points().copied().collect();
128
129    // Coordinate finiteness.
130    for p in &pts {
131        let x: f64 = p.get::<0>().into();
132        let y: f64 = p.get::<1>().into();
133        if !x.is_finite() || !y.is_finite() {
134            return Err(ValidityFailure::InvalidCoordinate);
135        }
136    }
137
138    // Out-of-range coordinates: the self-intersection test below routes
139    // each edge pair through the segment-intersection kernel, which drops
140    // any crossing at coordinates past ±SAFE_ABS_MAX as `OutOfRange`. A
141    // genuinely self-intersecting ring would then pass unnoticed, so
142    // validity cannot be confirmed — refuse rather than claim valid.
143    for p in &pts {
144        if !coordinate_in_range(p) {
145            return Err(ValidityFailure::CoordinateOutOfRange);
146        }
147    }
148
149    // A closed ring needs at least 4 vertices (triangle + closing point).
150    if pts.len() < 4 {
151        return Err(ValidityFailure::FewPoints);
152    }
153
154    // First and last vertex must coincide.
155    if !same_point(&pts[0], &pts[pts.len() - 1]) {
156        return Err(ValidityFailure::NotClosed);
157    }
158
159    // A vertex triple that folds back on itself along one line — Boost's
160    // failure_spikes. Checked before self-intersection so spike inputs
161    // report a deterministic error (matches Boost's check order).
162    if has_spike(&pts) {
163        return Err(ValidityFailure::Spikes);
164    }
165
166    // No non-adjacent edge may intersect another.
167    if has_self_intersection(&pts) {
168        return Err(ValidityFailure::SelfIntersection);
169    }
170
171    // Orientation — Boost's failure_wrong_orientation. The strategy area
172    // already folds the declared PointOrder: a correctly wound exterior
173    // is positive, a correctly wound hole negative. Zero area
174    // (degenerate) fails either way.
175    let area = ShoelaceArea.area(ring);
176    let zero = <P::Scalar as CoordinateScalar>::ZERO;
177    let properly_oriented = if is_interior {
178        area < zero
179    } else {
180        area > zero
181    };
182    if !properly_oriented {
183        return Err(ValidityFailure::WrongOrientation);
184    }
185
186    Ok(())
187}
188
189/// Validate a polygon: its exterior ring (with exterior orientation
190/// expectations), each interior ring (with interior orientation
191/// expectations), and that every interior ring's representative vertex
192/// is covered by the exterior.
193///
194/// Mirrors the polygon arm of `boost::geometry::is_valid`
195/// (`detail/is_valid/polygon.hpp`). Hole↔exterior and hole↔hole
196/// *edge-crossing* detection, and consecutive duplicate points, remain
197/// deferred with the rest of the multi/degenerate overlay — a hole
198/// whose representative vertex is covered by the exterior but whose
199/// edges cross the exterior boundary is not detected.
200///
201/// # Errors
202///
203/// A [`ValidityFailure`] describing the first rule the polygon
204/// violates, including [`ValidityFailure::InteriorRingOutside`] when a
205/// hole's representative vertex lies strictly outside the exterior.
206///
207/// # Examples
208///
209/// ```
210/// use geometry_cs::Cartesian;
211/// use geometry_model::{polygon, Point2D, Polygon};
212/// use geometry_overlay::validity::is_valid_polygon;
213///
214/// type P = Point2D<f64, Cartesian>;
215/// let pg: Polygon<P> = polygon![[(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0), (0.0, 0.0)]];
216/// assert!(is_valid_polygon(&pg).is_ok());
217/// ```
218pub fn is_valid_polygon<G, P>(polygon: &G) -> Result<(), ValidityFailure>
219where
220    G: PolygonTrait<Point = P>,
221    P: PointMut + Default + Copy,
222    P::Scalar: CoordinateScalar + Into<f64>,
223    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
224{
225    validate_ring(polygon.exterior(), false)?;
226    for inner in polygon.interiors() {
227        validate_ring(inner, true)?;
228        // Containment: the hole's first vertex must not lie strictly
229        // outside the exterior. `covered_by` (interior OR boundary)
230        // deliberately admits an isolated boundary touch — Boost
231        // permits those; only `failure_interior_rings_outside` is
232        // detectable without ring×ring turn analysis. A hole whose
233        // first vertex is inside but whose edges cross the exterior
234        // remains undetected — deferred (module docs).
235        if let Some(rep) = inner.points().next() {
236            if !WithinRing.covered_by(rep, polygon.exterior()) {
237                return Err(ValidityFailure::InteriorRingOutside);
238            }
239        }
240    }
241    Ok(())
242}
243
244/// Whether any two non-adjacent edges of the vertex ring intersect. The
245/// closing edge (last→first) is represented by the repeated final
246/// vertex, so edges are the `pts[i] → pts[i+1]` pairs.
247fn has_self_intersection<P>(pts: &[P]) -> bool
248where
249    P: PointMut + Default + Copy,
250    P::Scalar: CoordinateScalar + Into<f64>,
251{
252    let n = pts.len();
253    // Edges: 0..n-1 (the last vertex repeats the first, closing the ring).
254    let edges = n - 1;
255    for i in 0..edges {
256        let a = Segment::new(pts[i], pts[i + 1]);
257        for j in (i + 1)..edges {
258            // Skip edges that share a vertex (adjacent, or the
259            // wrap-around pair of the first and last edge).
260            if j == i + 1 {
261                continue;
262            }
263            if i == 0 && j == edges - 1 {
264                continue;
265            }
266            let b = Segment::new(pts[j], pts[j + 1]);
267            match segment_intersection::<Segment<P>, P>(&a, &b) {
268                SegmentIntersection::Disjoint | SegmentIntersection::OutOfRange => {}
269                _ => return true,
270            }
271        }
272    }
273    false
274}
275
276fn same_point<P: Point>(a: &P, b: &P) -> bool
277where
278    P::Scalar: PartialEq,
279{
280    a.get::<0>() == b.get::<0>() && a.get::<1>() == b.get::<1>()
281}
282
283/// `true` iff `b` is a spike between `a` and `c`: collinear
284/// (`cross == 0`) and folding back (`dot < 0`). Same 2-D kernel as
285/// `geometry_algorithm::remove_spikes::is_spike_2d` (private there);
286/// duplicated locally rather than widening that crate's public
287/// surface — if a third consumer appears, hoist the predicate into a
288/// shared home per the aggregate-slicing rules.
289fn is_spike_triple<P: Point>(a: &P, b: &P, c: &P) -> bool
290where
291    P::Scalar: CoordinateScalar,
292{
293    let ux = b.get::<0>() - a.get::<0>();
294    let uy = b.get::<1>() - a.get::<1>();
295    let vx = c.get::<0>() - b.get::<0>();
296    let vy = c.get::<1>() - b.get::<1>();
297    let zero = <P::Scalar as CoordinateScalar>::ZERO;
298    ux * vy - uy * vx == zero && ux * vx + uy * vy < zero
299}
300
301/// Any spike anywhere on the closed ring cycle, seam included.
302/// `pts` is the stored sequence (closing duplicate present — the
303/// closure check has already passed). The walk drops the duplicate
304/// and indexes the remaining cycle modularly, so triples
305/// `(last-1, last, first)` and `(last, first, second)` are covered.
306fn has_spike<P: Point + Copy>(pts: &[P]) -> bool
307where
308    P::Scalar: CoordinateScalar,
309{
310    // pts.len() >= 4 and pts[0] == pts[len-1] are guaranteed by the
311    // earlier FewPoints / NotClosed checks.
312    let cycle = &pts[..pts.len() - 1];
313    let n = cycle.len(); // >= 3
314    (0..n).any(|i| is_spike_triple(&cycle[(i + n - 1) % n], &cycle[i], &cycle[(i + 1) % n]))
315}
316
317#[cfg(test)]
318mod tests {
319    //! OVL6.T4 done-when: valid / invalid rings and polygons. Mirrors
320    //! the case families in `test/algorithms/is_valid.cpp`.
321
322    use super::{ValidityFailure, is_valid_polygon, is_valid_ring};
323    use geometry_cs::Cartesian;
324    use geometry_model::{Point2D, Polygon, Ring, polygon};
325
326    type P = Point2D<f64, Cartesian>;
327
328    #[test]
329    fn valid_square_ring() {
330        let r: Ring<P> = Ring::from_vec(vec![
331            P::new(0.0, 0.0),
332            P::new(0.0, 1.0),
333            P::new(1.0, 1.0),
334            P::new(1.0, 0.0),
335            P::new(0.0, 0.0),
336        ]);
337        assert!(is_valid_ring(&r).is_ok());
338    }
339
340    #[test]
341    fn too_few_points() {
342        let r: Ring<P> = Ring::from_vec(vec![P::new(0.0, 0.0), P::new(1.0, 0.0), P::new(0.0, 0.0)]);
343        assert_eq!(is_valid_ring(&r), Err(ValidityFailure::FewPoints));
344    }
345
346    #[test]
347    fn out_of_range_self_intersection_is_not_reported_valid() {
348        // Regression: a self-crossing "bow-tie" ring at coordinates past
349        // ±2^26 had its crossing dropped as OutOfRange by the segment
350        // kernel, so `has_self_intersection` returned false and the ring
351        // was wrongly reported valid. The same shape in range is correctly
352        // SelfIntersection; out of range it must be CoordinateOutOfRange,
353        // never Ok.
354        let s = 2.0e14;
355        let huge_bowtie: Ring<P> = Ring::from_vec(vec![
356            P::new(0.0, 0.0),
357            P::new(s, s),
358            P::new(s, 0.0),
359            P::new(0.0, s),
360            P::new(0.0, 0.0),
361        ]);
362        assert_eq!(
363            is_valid_ring(&huge_bowtie),
364            Err(ValidityFailure::CoordinateOutOfRange)
365        );
366        // The in-range analogue is still caught as a self-intersection.
367        let small_bowtie: Ring<P> = Ring::from_vec(vec![
368            P::new(0.0, 0.0),
369            P::new(2.0, 2.0),
370            P::new(2.0, 0.0),
371            P::new(0.0, 2.0),
372            P::new(0.0, 0.0),
373        ]);
374        assert_eq!(
375            is_valid_ring(&small_bowtie),
376            Err(ValidityFailure::SelfIntersection)
377        );
378    }
379
380    #[test]
381    fn not_closed() {
382        let r: Ring<P> = Ring::from_vec(vec![
383            P::new(0.0, 0.0),
384            P::new(1.0, 0.0),
385            P::new(1.0, 1.0),
386            P::new(0.0, 1.0),
387        ]);
388        assert_eq!(is_valid_ring(&r), Err(ValidityFailure::NotClosed));
389    }
390
391    #[test]
392    fn self_intersecting_bowtie() {
393        // A "bow-tie" quadrilateral whose diagonals cross.
394        let r: Ring<P> = Ring::from_vec(vec![
395            P::new(0.0, 0.0),
396            P::new(2.0, 2.0),
397            P::new(2.0, 0.0),
398            P::new(0.0, 2.0),
399            P::new(0.0, 0.0),
400        ]);
401        assert_eq!(is_valid_ring(&r), Err(ValidityFailure::SelfIntersection));
402    }
403
404    #[test]
405    fn invalid_coordinate() {
406        let r: Ring<P> = Ring::from_vec(vec![
407            P::new(0.0, 0.0),
408            P::new(f64::NAN, 0.0),
409            P::new(1.0, 1.0),
410            P::new(0.0, 0.0),
411        ]);
412        assert_eq!(is_valid_ring(&r), Err(ValidityFailure::InvalidCoordinate));
413    }
414
415    #[test]
416    fn valid_polygon() {
417        let pg: Polygon<P> = polygon![[(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0), (0.0, 0.0)]];
418        assert!(is_valid_polygon(&pg).is_ok());
419    }
420
421    #[test]
422    fn valid_polygon_with_hole() {
423        let pg: Polygon<P> = polygon![
424            [
425                (0.0, 0.0),
426                (0.0, 10.0),
427                (10.0, 10.0),
428                (10.0, 0.0),
429                (0.0, 0.0)
430            ],
431            [(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0), (2.0, 2.0)]
432        ];
433        assert!(is_valid_polygon(&pg).is_ok());
434    }
435
436    #[test]
437    fn wrongly_oriented_ring_is_rejected() {
438        // CW-declared ring stored counter-clockwise. Boost:
439        // failure_wrong_orientation.
440        let r: Ring<P> = Ring::from_vec(vec![
441            P::new(0.0, 0.0),
442            P::new(2.0, 0.0),
443            P::new(2.0, 2.0),
444            P::new(0.0, 2.0),
445            P::new(0.0, 0.0),
446        ]);
447        assert_eq!(is_valid_ring(&r), Err(ValidityFailure::WrongOrientation));
448    }
449
450    #[test]
451    fn ccw_declared_ring_correctly_wound_is_ok() {
452        // CCW-declared ring stored counter-clockwise: strategy-level
453        // area positive, valid. Locks the convention shared with
454        // `correct()` (spec correct-orientation).
455        let r: Ring<P, false> = Ring::from_vec(vec![
456            P::new(0.0, 0.0),
457            P::new(2.0, 0.0),
458            P::new(2.0, 2.0),
459            P::new(0.0, 2.0),
460            P::new(0.0, 0.0),
461        ]);
462        assert!(is_valid_ring(&r).is_ok());
463    }
464
465    #[test]
466    fn all_collinear_ring_is_spikes() {
467        // The finding's repro: a "ring" that is a line. Every edge
468        // pair is adjacent or the wrap pair, so the old validator
469        // reported Ok. Boost: failure_spikes.
470        let flat: Ring<P> = Ring::from_vec(vec![
471            P::new(0.0, 0.0),
472            P::new(4.0, 0.0),
473            P::new(2.0, 0.0),
474            P::new(0.0, 0.0),
475        ]);
476        assert_eq!(is_valid_ring(&flat), Err(ValidityFailure::Spikes));
477    }
478
479    #[test]
480    fn square_with_spike_is_spikes() {
481        // A CW square with an out-and-back spur on its bottom edge.
482        // Both Spikes and SelfIntersection are arguably present; the
483        // pipeline order pins Spikes (matches Boost's check order).
484        let r: Ring<P> = Ring::from_vec(vec![
485            P::new(0.0, 0.0),
486            P::new(0.0, 4.0),
487            P::new(4.0, 4.0),
488            P::new(4.0, 0.0),
489            P::new(2.0, 0.0),
490            P::new(2.0, -2.0),
491            P::new(2.0, 0.0),
492            P::new(0.0, 0.0),
493        ]);
494        assert_eq!(is_valid_ring(&r), Err(ValidityFailure::Spikes));
495    }
496
497    #[test]
498    fn hole_outside_exterior_is_rejected() {
499        // The finding's repro. Doc promised InteriorRingOutside; the
500        // variant was unreachable. Boost:
501        // failure_interior_rings_outside. (Exterior CW-stored, hole
502        // CCW-stored — both correctly wound, so orientation passes and
503        // containment is what fails.)
504        let pg: Polygon<P> = polygon![
505            [(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0), (0.0, 0.0)],
506            [
507                (10.0, 10.0),
508                (12.0, 10.0),
509                (12.0, 12.0),
510                (10.0, 12.0),
511                (10.0, 10.0)
512            ]
513        ];
514        assert_eq!(
515            is_valid_polygon(&pg),
516            Err(ValidityFailure::InteriorRingOutside)
517        );
518    }
519
520    #[test]
521    fn hole_touching_exterior_boundary_is_ok() {
522        // The hole's first vertex lies ON the exterior boundary:
523        // covered_by (not within) is the containment predicate, so an
524        // isolated touch is permitted — matching Boost.
525        let pg: Polygon<P> = polygon![
526            [(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0), (0.0, 0.0)],
527            [(0.0, 2.0), (1.0, 1.0), (1.0, 3.0), (0.0, 2.0)]
528        ];
529        assert!(is_valid_polygon(&pg).is_ok());
530    }
531
532    #[test]
533    fn wrongly_oriented_hole_is_rejected() {
534        // Correct CW exterior, but the hole is ALSO CW-stored — holes
535        // must wind opposite. Boost: failure_wrong_orientation.
536        let pg: Polygon<P> = polygon![
537            [(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0), (0.0, 0.0)],
538            [(1.0, 1.0), (1.0, 2.0), (2.0, 2.0), (2.0, 1.0), (1.0, 1.0)]
539        ];
540        assert_eq!(
541            is_valid_polygon(&pg),
542            Err(ValidityFailure::WrongOrientation)
543        );
544    }
545}