Skip to main content

nodedb_spatial/
validate.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Geometry validation (geo_is_valid).
4//!
5//! Checks structural validity per OGC Simple Features:
6//! - Polygon rings must be closed (first == last coordinate)
7//! - Polygon rings must have at least 4 points (triangle + close)
8//! - Polygon exterior ring should be counter-clockwise (right-hand rule)
9//! - Polygon holes should be clockwise
10//! - No self-intersection of ring edges
11//! - LineStrings must have at least 2 points
12
13use nodedb_types::geometry::Geometry;
14
15use crate::predicates::edge::segments_intersect;
16
17/// Validate a geometry. Returns a list of issues (empty = valid).
18pub fn validate_geometry(geom: &Geometry) -> Vec<String> {
19    let mut issues = Vec::new();
20    validate_recursive(geom, &mut issues);
21    issues
22}
23
24/// Whether a geometry is valid (no issues).
25pub fn is_valid(geom: &Geometry) -> bool {
26    validate_geometry(geom).is_empty()
27}
28
29fn validate_recursive(geom: &Geometry, issues: &mut Vec<String>) {
30    match geom {
31        Geometry::Point { coordinates }
32            if coordinates[0].is_nan()
33                || coordinates[1].is_nan()
34                || coordinates[0].is_infinite()
35                || coordinates[1].is_infinite() =>
36        {
37            issues.push("Point has NaN or Infinite coordinate".to_string());
38        }
39
40        Geometry::LineString { coordinates } if coordinates.len() < 2 => {
41            issues.push(format!(
42                "LineString has {} points, minimum is 2",
43                coordinates.len()
44            ));
45        }
46
47        Geometry::Polygon { coordinates } => {
48            if coordinates.is_empty() {
49                issues.push("Polygon has no rings".to_string());
50                return;
51            }
52
53            for (ring_idx, ring) in coordinates.iter().enumerate() {
54                let label = if ring_idx == 0 {
55                    "Exterior ring".to_string()
56                } else {
57                    format!("Hole ring {ring_idx}")
58                };
59
60                if ring.len() < 4 {
61                    issues.push(format!(
62                        "{label} has {} points, minimum is 4 (triangle + close)",
63                        ring.len()
64                    ));
65                    continue;
66                }
67
68                // Check closed.
69                if let (Some(first), Some(last)) = (ring.first(), ring.last())
70                    && ((first[0] - last[0]).abs() > 1e-10 || (first[1] - last[1]).abs() > 1e-10)
71                {
72                    issues.push(format!("{label} is not closed (first != last)"));
73                }
74
75                // Check winding order.
76                let area = signed_area(ring);
77                if ring_idx == 0 && area < 0.0 {
78                    issues.push(
79                        "Exterior ring has clockwise winding (should be counter-clockwise)"
80                            .to_string(),
81                    );
82                } else if ring_idx > 0 && area > 0.0 {
83                    issues.push(format!(
84                        "{label} has counter-clockwise winding (holes should be clockwise)"
85                    ));
86                }
87
88                // Check self-intersection.
89                check_ring_self_intersection(ring, &label, issues);
90            }
91        }
92
93        Geometry::MultiPoint { coordinates } => {
94            for (i, c) in coordinates.iter().enumerate() {
95                if c[0].is_nan() || c[1].is_nan() || c[0].is_infinite() || c[1].is_infinite() {
96                    issues.push(format!("MultiPoint[{i}] has NaN or Infinite coordinate"));
97                }
98            }
99        }
100
101        Geometry::MultiLineString { coordinates } => {
102            for (i, ls) in coordinates.iter().enumerate() {
103                if ls.len() < 2 {
104                    issues.push(format!(
105                        "MultiLineString[{i}] has {} points, minimum is 2",
106                        ls.len()
107                    ));
108                }
109            }
110        }
111
112        Geometry::MultiPolygon { coordinates } => {
113            for (i, poly) in coordinates.iter().enumerate() {
114                validate_recursive(
115                    &Geometry::Polygon {
116                        coordinates: poly.clone(),
117                    },
118                    issues,
119                );
120                // Prefix existing issues from this sub-polygon.
121                // (Already added by recursive call.)
122                let _ = i; // used for context only
123            }
124        }
125
126        Geometry::GeometryCollection { geometries } => {
127            for geom in geometries {
128                validate_recursive(geom, issues);
129            }
130        }
131
132        // Unknown future geometry type — no validation rules yet.
133        _ => {}
134    }
135}
136
137/// Signed area of a ring (Shoelace formula). Positive = CCW, Negative = CW.
138fn signed_area(ring: &[[f64; 2]]) -> f64 {
139    let n = ring.len();
140    if n < 3 {
141        return 0.0;
142    }
143    let mut sum = 0.0;
144    for i in 0..n {
145        let j = (i + 1) % n;
146        sum += ring[i][0] * ring[j][1];
147        sum -= ring[j][0] * ring[i][1];
148    }
149    sum / 2.0
150}
151
152/// Check if any non-adjacent edges of a ring intersect.
153fn check_ring_self_intersection(ring: &[[f64; 2]], label: &str, issues: &mut Vec<String>) {
154    let n = if ring.first() == ring.last() && ring.len() > 1 {
155        ring.len() - 1
156    } else {
157        ring.len()
158    };
159
160    if n < 4 {
161        return; // Triangle can't self-intersect.
162    }
163
164    for i in 0..n {
165        let i_next = (i + 1) % n;
166        // Only check non-adjacent edges (skip i-1, i, i+1).
167        for j in (i + 2)..n {
168            let j_next = (j + 1) % n;
169            // Skip if edges share a vertex.
170            if j_next == i {
171                continue;
172            }
173            if segments_intersect(ring[i], ring[i_next], ring[j], ring[j_next]) {
174                // Check if it's just a shared endpoint (adjacent-ish for closed rings).
175                let shared_endpoint = ring[i] == ring[j]
176                    || ring[i] == ring[j_next]
177                    || ring[i_next] == ring[j]
178                    || ring[i_next] == ring[j_next];
179                if !shared_endpoint {
180                    issues.push(format!(
181                        "{label} has self-intersection at edges {i}-{i_next} and {j}-{j_next}"
182                    ));
183                    return; // One self-intersection is enough to report.
184                }
185            }
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn valid_polygon() {
196        let geom = Geometry::polygon(vec![vec![
197            [0.0, 0.0],
198            [10.0, 0.0],
199            [10.0, 10.0],
200            [0.0, 10.0],
201            [0.0, 0.0],
202        ]]);
203        assert!(is_valid(&geom));
204    }
205
206    #[test]
207    fn unclosed_ring() {
208        let geom = Geometry::polygon(vec![vec![
209            [0.0, 0.0],
210            [10.0, 0.0],
211            [10.0, 10.0],
212            [0.0, 10.0],
213        ]]);
214        let issues = validate_geometry(&geom);
215        assert!(issues.iter().any(|i| i.contains("not closed")));
216    }
217
218    #[test]
219    fn too_few_points() {
220        let geom = Geometry::polygon(vec![vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]]);
221        let issues = validate_geometry(&geom);
222        assert!(issues.iter().any(|i| i.contains("minimum is 4")));
223    }
224
225    #[test]
226    fn self_intersecting_ring() {
227        // Bowtie: edges cross.
228        let geom = Geometry::polygon(vec![vec![
229            [0.0, 0.0],
230            [10.0, 10.0],
231            [10.0, 0.0],
232            [0.0, 10.0],
233            [0.0, 0.0],
234        ]]);
235        let issues = validate_geometry(&geom);
236        assert!(issues.iter().any(|i| i.contains("self-intersection")));
237    }
238
239    #[test]
240    fn valid_linestring() {
241        let geom = Geometry::line_string(vec![[0.0, 0.0], [1.0, 1.0]]);
242        assert!(is_valid(&geom));
243    }
244
245    #[test]
246    fn linestring_too_short() {
247        let geom = Geometry::LineString {
248            coordinates: vec![[0.0, 0.0]],
249        };
250        assert!(!is_valid(&geom));
251    }
252
253    #[test]
254    fn valid_point() {
255        assert!(is_valid(&Geometry::point(0.0, 0.0)));
256    }
257
258    #[test]
259    fn nan_point_invalid() {
260        let geom = Geometry::Point {
261            coordinates: [f64::NAN, 0.0],
262        };
263        assert!(!is_valid(&geom));
264    }
265
266    #[test]
267    fn clockwise_exterior_warned() {
268        // Clockwise exterior ring.
269        let geom = Geometry::polygon(vec![vec![
270            [0.0, 0.0],
271            [0.0, 10.0],
272            [10.0, 10.0],
273            [10.0, 0.0],
274            [0.0, 0.0],
275        ]]);
276        let issues = validate_geometry(&geom);
277        assert!(issues.iter().any(|i| i.contains("clockwise")));
278    }
279
280    #[test]
281    fn geometry_collection_validates_all() {
282        let gc = Geometry::GeometryCollection {
283            geometries: vec![
284                Geometry::point(0.0, 0.0),
285                Geometry::LineString {
286                    coordinates: vec![[0.0, 0.0]],
287                }, // invalid
288            ],
289        };
290        let issues = validate_geometry(&gc);
291        assert!(!issues.is_empty());
292    }
293}