Skip to content

Commit dfa491f

Browse files
bretttullyclaude
andcommitted
Implement simply connected interior validation for polygons
Add validation to detect when a polygon's interior is not simply connected, which violates the OGC Simple Feature Specification requirement that "the interior of every Surface is a connected point set." The validation detects two types of disconnection: - Multi-touch: Two rings sharing 2+ vertices at different coordinates - Cycle detection: Rings forming a cycle through distinct single-touch points Key implementation details: - Build a touch graph where nodes are rings and edges connect touching rings - Detect vertex-vertex touches (same coordinate on both rings) - Detect vertex-on-edge touches (vertex lies on another ring's edge) - Use DFS to find cycles through distinct coordinates The error message now includes the problematic coordinates to aid debugging, e.g., "polygon interior is not simply connected at coordinate(s): (2, 2), (3, 3)" Tests cover: - Two L-shaped holes sharing two vertices (invalid) - Checkerboard patterns with shared vertices (invalid) - Four holes forming a cycle of single touches (invalid) - Three holes meeting at one vertex (valid - interior still connected) - Holes with vertex-on-edge touches to exterior (invalid) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 18eb3af commit dfa491f

File tree

8 files changed

+1016
-223
lines changed

8 files changed

+1016
-223
lines changed

geo-benches/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,8 @@ harness = false
164164
name = "kmeans"
165165
path = "src/kmeans.rs"
166166
harness = false
167+
168+
[[bench]]
169+
name = "validation"
170+
path = "src/validation.rs"
171+
harness = false

geo-benches/fixtures/rust-geo-booleanop-fixtures/benchmarks/validate.geojson

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

geo-benches/src/validation.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//! Benchmarks for polygon validation, focusing on:
2+
//! - Checkerboard patterns (stress test simply-connected interior detection)
3+
//! - Complex geometries from real-world datasets
4+
5+
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
6+
use geo::algorithm::Validation;
7+
use geo::coord;
8+
use geo::geometry::{Geometry, LineString, Polygon};
9+
use geo_test_fixtures::checkerboard::create_checkerboard_polygon;
10+
use geojson::GeoJson;
11+
use std::convert::TryInto;
12+
use std::fs;
13+
use std::path::PathBuf;
14+
15+
/// Load benchmark geometries from the validate.geojson fixture.
16+
/// Returns a vector of (name, polygon) tuples.
17+
fn load_benchmark_geometries() -> Vec<(String, Polygon<f64>)> {
18+
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
19+
path.push("fixtures/rust-geo-booleanop-fixtures/benchmarks/validate.geojson");
20+
21+
let data = match fs::read_to_string(&path) {
22+
Ok(d) => d,
23+
Err(e) => {
24+
eprintln!(
25+
"Warning: Could not read {}: {}, skipping validation benchmarks",
26+
path.display(),
27+
e
28+
);
29+
return vec![];
30+
}
31+
};
32+
33+
let geojson: GeoJson = match data.parse() {
34+
Ok(g) => g,
35+
Err(e) => {
36+
eprintln!(
37+
"Warning: Could not parse GeoJSON: {}, skipping validation benchmarks",
38+
e
39+
);
40+
return vec![];
41+
}
42+
};
43+
44+
let mut results = Vec::new();
45+
46+
if let GeoJson::FeatureCollection(fc) = geojson {
47+
for (idx, feature) in fc.features.into_iter().enumerate().take(20) {
48+
if let Some(geom) = feature.geometry {
49+
let geo_geom: Result<Geometry<f64>, _> = geom.try_into();
50+
if let Ok(Geometry::MultiPolygon(mp)) = geo_geom {
51+
// Extract individual polygons from the MultiPolygon
52+
for (poly_idx, poly) in mp.0.into_iter().enumerate() {
53+
let num_coords: usize = poly.exterior().coords().count()
54+
+ poly
55+
.interiors()
56+
.iter()
57+
.map(|r| r.coords().count())
58+
.sum::<usize>();
59+
let name = format!("feat_{}_poly_{}_{}_coords", idx, poly_idx, num_coords);
60+
results.push((name, poly));
61+
}
62+
} else if let Ok(Geometry::Polygon(poly)) = geo_geom {
63+
let num_coords: usize = poly.exterior().coords().count()
64+
+ poly
65+
.interiors()
66+
.iter()
67+
.map(|r| r.coords().count())
68+
.sum::<usize>();
69+
let name = format!("feat_{}_{}_coords", idx, num_coords);
70+
results.push((name, poly));
71+
}
72+
}
73+
}
74+
}
75+
76+
results
77+
}
78+
79+
/// Create a simple valid polygon for baseline comparison.
80+
fn create_simple_polygon(size: usize) -> Polygon<f64> {
81+
let n = size;
82+
let mut coords = Vec::with_capacity(n + 1);
83+
for i in 0..n {
84+
let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64);
85+
coords.push(coord! { x: angle.cos() * 100.0, y: angle.sin() * 100.0 });
86+
}
87+
coords.push(coords[0]); // Close the ring
88+
Polygon::new(LineString::new(coords), vec![])
89+
}
90+
91+
fn validation_benchmark(c: &mut Criterion) {
92+
let mut group = c.benchmark_group("polygon_validation");
93+
94+
// Benchmark simple polygons (baseline - no holes)
95+
for size in [10, 100, 1000] {
96+
let simple = create_simple_polygon(size);
97+
group.bench_with_input(
98+
BenchmarkId::new("simple_polygon", size),
99+
&simple,
100+
|b, poly| b.iter(|| criterion::black_box(poly.is_valid())),
101+
);
102+
}
103+
104+
// Benchmark checkerboard patterns (many holes touching at vertices)
105+
// These stress-test the simply-connected interior detection
106+
for level in 0..=3 {
107+
let checkerboard = create_checkerboard_polygon(level);
108+
let num_holes = checkerboard.interiors().len();
109+
let label = format!("level_{}_{}holes", level, num_holes);
110+
group.bench_with_input(
111+
BenchmarkId::new("checkerboard", &label),
112+
&checkerboard,
113+
|b, poly| b.iter(|| criterion::black_box(poly.is_valid())),
114+
);
115+
}
116+
117+
// Benchmark complex geometries from fixture file
118+
let fixture_polys = load_benchmark_geometries();
119+
for (name, poly) in fixture_polys.iter().take(15) {
120+
group.bench_with_input(BenchmarkId::new("fixture", name), poly, |b, p| {
121+
b.iter(|| criterion::black_box(p.is_valid()))
122+
});
123+
}
124+
125+
group.finish();
126+
}
127+
128+
criterion_group!(benches, validation_benchmark);
129+
criterion_main!(benches);
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//! Checkerboard polygon generator for testing simply-connected interior validation.
2+
//!
3+
//! The checkerboard pattern is useful for testing cycle detection: each pair of
4+
//! adjacent holes shares exactly one vertex, and these single-touch connections
5+
//! form cycles in the ring adjacency graph, disconnecting the interior.
6+
//!
7+
//! # Pattern Structure
8+
//!
9+
//! ```text
10+
//! 0 1 2 3 4 5 6 7
11+
//! 7 ┌───────────────────────────┐
12+
//! │ │
13+
//! 6 │ ┌───┐ ┌───┐ ┌───┐ │
14+
//! │ │ ░ │ │ ░ │ │ ░ │ │
15+
//! 5 │ └───●───●───●───●───┘ │
16+
//! │ │ ░ │ │ ░ │ │
17+
//! 4 │ ┌───●───●───●───●───┐ │
18+
//! │ │ ░ │ │ ░ │ │ ░ │ │ ░ = holes
19+
//! 3 │ └───●───●───●───●───┘ │ ● = shared vertices
20+
//! │ │ ░ │ │ ░ │ │
21+
//! 2 │ ┌───●───●───●───●───┐ │
22+
//! │ │ ░ │ │ ░ │ │ ░ │ │
23+
//! 1 │ └───┘ └───┘ └───┘ │
24+
//! │ │
25+
//! 0 └───────────────────────────┘
26+
//! ```
27+
28+
use geo_types::{coord, LineString, Polygon};
29+
30+
/// Create a box as a closed LineString (for use as exterior or hole).
31+
pub fn box_ring(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> LineString<f64> {
32+
LineString::new(vec![
33+
coord! { x: min_x, y: min_y },
34+
coord! { x: max_x, y: min_y },
35+
coord! { x: max_x, y: max_y },
36+
coord! { x: min_x, y: max_y },
37+
coord! { x: min_x, y: min_y },
38+
])
39+
}
40+
41+
/// Generate checkerboard hole positions for a given level.
42+
///
43+
/// The pattern creates holes that share vertices at their corners.
44+
/// Level 0 uses unit size 1, level 1 uses unit size 7, etc.
45+
pub fn checkerboard_holes_at_level(level: usize) -> Vec<LineString<f64>> {
46+
let base_sz: f64 = 7.0;
47+
let sz = base_sz.powi(level as i32);
48+
49+
let mut holes = Vec::new();
50+
51+
// Diagonal holes: (i, i) for i in 1..6
52+
for i in 1..6 {
53+
let fi = i as f64;
54+
holes.push(box_ring(fi * sz, fi * sz, (fi + 1.0) * sz, (fi + 1.0) * sz));
55+
}
56+
57+
// Off-diagonal holes above the diagonal
58+
for i in 1..4 {
59+
let fi = i as f64;
60+
holes.push(box_ring(
61+
fi * sz,
62+
(fi + 2.0) * sz,
63+
(fi + 1.0) * sz,
64+
(fi + 3.0) * sz,
65+
));
66+
}
67+
for i in 1..2 {
68+
let fi = i as f64;
69+
holes.push(box_ring(
70+
fi * sz,
71+
(fi + 4.0) * sz,
72+
(fi + 1.0) * sz,
73+
(fi + 5.0) * sz,
74+
));
75+
}
76+
77+
// Off-diagonal holes below the diagonal
78+
for i in 1..4 {
79+
let fi = i as f64;
80+
holes.push(box_ring(
81+
(fi + 2.0) * sz,
82+
fi * sz,
83+
(fi + 3.0) * sz,
84+
(fi + 1.0) * sz,
85+
));
86+
}
87+
for i in 1..2 {
88+
let fi = i as f64;
89+
holes.push(box_ring(
90+
(fi + 4.0) * sz,
91+
fi * sz,
92+
(fi + 5.0) * sz,
93+
(fi + 1.0) * sz,
94+
));
95+
}
96+
97+
holes
98+
}
99+
100+
/// Translate a ring by an offset.
101+
pub fn translate_ring(ring: &LineString<f64>, dx: f64, dy: f64) -> LineString<f64> {
102+
LineString::new(
103+
ring.coords()
104+
.map(|c| coord! { x: c.x + dx, y: c.y + dy })
105+
.collect(),
106+
)
107+
}
108+
109+
/// Create a checkerboard polygon with the given nesting level.
110+
///
111+
/// - Level 0: Simple 7x7 checkerboard with 13 holes
112+
/// - Level 1: 49x49 with level-0 checkerboard nested inside one of the "solid" squares
113+
/// - Level 2: 343x343 with level-1 nested inside, etc.
114+
///
115+
/// The nested checkerboards are placed at offset (2*sz, 3*sz) where
116+
/// sz is the size of the outer checkerboard's unit cell. This places the nested pattern
117+
/// inside the solid square at grid position (2,3).
118+
///
119+
/// Returns the polygon and its expected area.
120+
pub fn create_checkerboard(level: usize) -> (Polygon<f64>, f64) {
121+
let base_sz: f64 = 7.0;
122+
let sz = base_sz.powi((level + 1) as i32);
123+
124+
let exterior = box_ring(0.0, 0.0, sz, sz);
125+
let exterior_area = sz * sz;
126+
127+
let mut all_holes: Vec<LineString<f64>> = Vec::new();
128+
let mut total_hole_area = 0.0;
129+
130+
// Start with level 0 holes at the outermost scale
131+
// Then recursively add smaller checkerboards inside one of the "solid" squares
132+
fn add_holes_recursive(
133+
all_holes: &mut Vec<LineString<f64>>,
134+
total_hole_area: &mut f64,
135+
current_level: usize,
136+
max_level: usize,
137+
offset_x: f64,
138+
offset_y: f64,
139+
base_sz: f64,
140+
) {
141+
// Size of unit cell at this level
142+
let unit_sz = base_sz.powi((max_level - current_level) as i32);
143+
144+
// Add the 13 holes for this checkerboard level
145+
let holes = checkerboard_holes_at_level(max_level - current_level);
146+
let hole_area = unit_sz * unit_sz;
147+
148+
for hole in holes {
149+
let translated = translate_ring(&hole, offset_x, offset_y);
150+
all_holes.push(translated);
151+
*total_hole_area += hole_area;
152+
}
153+
154+
// If not at the innermost level, recurse into one of the solid squares
155+
// The solid square at position (2, 3) in the 7x7 grid
156+
if current_level < max_level {
157+
let next_offset_x = offset_x + 2.0 * unit_sz;
158+
let next_offset_y = offset_y + 3.0 * unit_sz;
159+
add_holes_recursive(
160+
all_holes,
161+
total_hole_area,
162+
current_level + 1,
163+
max_level,
164+
next_offset_x,
165+
next_offset_y,
166+
base_sz,
167+
);
168+
}
169+
}
170+
171+
add_holes_recursive(
172+
&mut all_holes,
173+
&mut total_hole_area,
174+
0,
175+
level,
176+
0.0,
177+
0.0,
178+
base_sz,
179+
);
180+
181+
let polygon = Polygon::new(exterior, all_holes);
182+
let expected_area = exterior_area - total_hole_area;
183+
184+
(polygon, expected_area)
185+
}
186+
187+
/// Create a checkerboard polygon without computing expected area.
188+
///
189+
/// This is a convenience wrapper for benchmarking where only the polygon is needed.
190+
pub fn create_checkerboard_polygon(level: usize) -> Polygon<f64> {
191+
create_checkerboard(level).0
192+
}

geo-test-fixtures/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use std::{path::PathBuf, str::FromStr};
33
use geo_types::{LineString, MultiPolygon, Point, Polygon};
44
use wkt::{TryFromWkt, WktFloat};
55

6+
pub mod checkerboard;
7+
68
pub fn louisiana<T>() -> LineString<T>
79
where
810
T: WktFloat + Default + FromStr,

geo/CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 0.32.0 - 2025-12-05
44

5+
- Add simply connected interior validation for polygons. Polygons with holes that touch at vertices in ways that disconnect the interior (e.g., two holes sharing 2+ vertices, or cycles of holes each sharing a vertex) are now detected as invalid via `Validation::is_valid()`. This aligns with OGC Simple Features and matches PostGIS behavior.
6+
- Performance: Polygon validation now uses `PreparedGeometry` to cache R-tree structures, improving validation speed by 26-45% for polygons with many holes.
57
- Move `PreparedGeometry` into a new `indexed` module intended to provide index-backed geometries. `relate::PreparedGeometry` has been deprecated.
68
- Use an interval tree for faster (Multi)Point in MultiPolygon checks
79
- LOF algorithm efficiency improvements due to caching kth distance

0 commit comments

Comments
 (0)