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}