Skip to main content

nodedb_spatial/
validate.rs

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