-
Notifications
You must be signed in to change notification settings - Fork 230
Add simply connected interior validation and test suite #1472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| //! Benchmarks for polygon validation, focusing on: | ||
| //! - Checkerboard patterns (stress test simply-connected interior detection) | ||
| //! - Complex geometries from real-world datasets | ||
| use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; | ||
| use geo::algorithm::Validation; | ||
| use geo::coord; | ||
| use geo::geometry::{Geometry, LineString, Polygon}; | ||
| use geo_test_fixtures::checkerboard::create_checkerboard_polygon; | ||
| use geojson::GeoJson; | ||
| use std::convert::TryInto; | ||
| use std::fs; | ||
| use std::path::PathBuf; | ||
|
|
||
| /// Load benchmark geometries from the validate.geojson fixture. | ||
| /// Returns a vector of (name, polygon) tuples. | ||
| fn load_benchmark_geometries() -> Vec<(String, Polygon<f64>)> { | ||
| let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); | ||
| path.push("fixtures/rust-geo-booleanop-fixtures/benchmarks/validate.geojson"); | ||
|
|
||
| let data = match fs::read_to_string(&path) { | ||
| Ok(d) => d, | ||
| Err(e) => { | ||
| eprintln!( | ||
| "Warning: Could not read {}: {}, skipping validation benchmarks", | ||
| path.display(), | ||
| e | ||
| ); | ||
| return vec![]; | ||
| } | ||
| }; | ||
|
|
||
| let geojson: GeoJson = match data.parse() { | ||
| Ok(g) => g, | ||
| Err(e) => { | ||
| eprintln!( | ||
| "Warning: Could not parse GeoJSON: {}, skipping validation benchmarks", | ||
| e | ||
| ); | ||
| return vec![]; | ||
| } | ||
| }; | ||
|
|
||
| let mut results = Vec::new(); | ||
|
|
||
| if let GeoJson::FeatureCollection(fc) = geojson { | ||
| for (idx, feature) in fc.features.into_iter().enumerate().take(20) { | ||
| if let Some(geom) = feature.geometry { | ||
| let geo_geom: Result<Geometry<f64>, _> = geom.try_into(); | ||
| if let Ok(Geometry::MultiPolygon(mp)) = geo_geom { | ||
| // Extract individual polygons from the MultiPolygon | ||
| for (poly_idx, poly) in mp.0.into_iter().enumerate() { | ||
| let num_coords: usize = poly.exterior().coords().count() | ||
| + poly | ||
| .interiors() | ||
| .iter() | ||
| .map(|r| r.coords().count()) | ||
| .sum::<usize>(); | ||
| let name = format!("feat_{}_poly_{}_{}_coords", idx, poly_idx, num_coords); | ||
| results.push((name, poly)); | ||
| } | ||
| } else if let Ok(Geometry::Polygon(poly)) = geo_geom { | ||
| let num_coords: usize = poly.exterior().coords().count() | ||
| + poly | ||
| .interiors() | ||
| .iter() | ||
| .map(|r| r.coords().count()) | ||
| .sum::<usize>(); | ||
| let name = format!("feat_{}_{}_coords", idx, num_coords); | ||
| results.push((name, poly)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| results | ||
| } | ||
|
|
||
| /// Create a simple valid polygon for baseline comparison. | ||
| fn create_simple_polygon(size: usize) -> Polygon<f64> { | ||
| let n = size; | ||
| let mut coords = Vec::with_capacity(n + 1); | ||
| for i in 0..n { | ||
| let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64); | ||
| coords.push(coord! { x: angle.cos() * 100.0, y: angle.sin() * 100.0 }); | ||
| } | ||
| coords.push(coords[0]); // Close the ring | ||
| Polygon::new(LineString::new(coords), vec![]) | ||
| } | ||
|
|
||
| fn validation_benchmark(c: &mut Criterion) { | ||
| let mut group = c.benchmark_group("polygon_validation"); | ||
|
|
||
| // Benchmark simple polygons (baseline - no holes) | ||
| for size in [10, 100, 1000] { | ||
| let simple = create_simple_polygon(size); | ||
| group.bench_with_input( | ||
| BenchmarkId::new("simple_polygon", size), | ||
| &simple, | ||
| |b, poly| b.iter(|| criterion::black_box(poly.is_valid())), | ||
| ); | ||
| } | ||
|
|
||
| // Benchmark checkerboard patterns (many holes touching at vertices) | ||
| // These stress-test the simply-connected interior detection | ||
| for level in 0..=3 { | ||
| let checkerboard = create_checkerboard_polygon(level); | ||
| let num_holes = checkerboard.interiors().len(); | ||
| let label = format!("level_{}_{}holes", level, num_holes); | ||
| group.bench_with_input( | ||
| BenchmarkId::new("checkerboard", &label), | ||
| &checkerboard, | ||
| |b, poly| b.iter(|| criterion::black_box(poly.is_valid())), | ||
| ); | ||
| } | ||
|
|
||
| // Benchmark complex geometries from fixture file | ||
| let fixture_polys = load_benchmark_geometries(); | ||
| for (name, poly) in fixture_polys.iter().take(15) { | ||
| group.bench_with_input(BenchmarkId::new("fixture", name), poly, |b, p| { | ||
| b.iter(|| criterion::black_box(p.is_valid())) | ||
| }); | ||
| } | ||
|
|
||
| group.finish(); | ||
| } | ||
|
|
||
| criterion_group!(benches, validation_benchmark); | ||
| criterion_main!(benches); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| //! Checkerboard polygon generator for testing simply-connected interior validation. | ||
| //! | ||
| //! The checkerboard pattern is useful for testing cycle detection: each pair of | ||
| //! adjacent holes shares exactly one vertex, and these single-touch connections | ||
| //! form cycles in the ring adjacency graph, disconnecting the interior. | ||
| //! | ||
| //! # Pattern Structure | ||
| //! | ||
| //! ```text | ||
| //! 0 1 2 3 4 5 6 7 | ||
| //! 7 ┌───────────────────────────┐ | ||
| //! │ │ | ||
| //! 6 │ ┌───┐ ┌───┐ ┌───┐ │ | ||
| //! │ │ ░ │ │ ░ │ │ ░ │ │ | ||
| //! 5 │ └───●───●───●───●───┘ │ | ||
| //! │ │ ░ │ │ ░ │ │ | ||
| //! 4 │ ┌───●───●───●───●───┐ │ | ||
| //! │ │ ░ │ │ ░ │ │ ░ │ │ ░ = holes | ||
| //! 3 │ └───●───●───●───●───┘ │ ● = shared vertices | ||
| //! │ │ ░ │ │ ░ │ │ | ||
| //! 2 │ ┌───●───●───●───●───┐ │ | ||
| //! │ │ ░ │ │ ░ │ │ ░ │ │ | ||
| //! 1 │ └───┘ └───┘ └───┘ │ | ||
| //! │ │ | ||
| //! 0 └───────────────────────────┘ | ||
| //! ``` | ||
| use geo_types::{coord, LineString, Polygon}; | ||
|
|
||
| /// Create a box as a closed LineString (for use as exterior or hole). | ||
| pub fn box_ring(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> LineString<f64> { | ||
| LineString::new(vec![ | ||
| coord! { x: min_x, y: min_y }, | ||
| coord! { x: max_x, y: min_y }, | ||
| coord! { x: max_x, y: max_y }, | ||
| coord! { x: min_x, y: max_y }, | ||
| coord! { x: min_x, y: min_y }, | ||
| ]) | ||
| } | ||
|
|
||
| /// Generate checkerboard hole positions for a given level. | ||
| /// | ||
| /// The pattern creates holes that share vertices at their corners. | ||
| /// Level 0 uses unit size 1, level 1 uses unit size 7, etc. | ||
| pub fn checkerboard_holes_at_level(level: usize) -> Vec<LineString<f64>> { | ||
| let base_sz: f64 = 7.0; | ||
| let sz = base_sz.powi(level as i32); | ||
|
|
||
| let mut holes = Vec::new(); | ||
|
|
||
| // Diagonal holes: (i, i) for i in 1..6 | ||
| for i in 1..6 { | ||
| let fi = i as f64; | ||
| holes.push(box_ring(fi * sz, fi * sz, (fi + 1.0) * sz, (fi + 1.0) * sz)); | ||
| } | ||
|
|
||
| // Off-diagonal holes above the diagonal | ||
| for i in 1..4 { | ||
| let fi = i as f64; | ||
| holes.push(box_ring( | ||
| fi * sz, | ||
| (fi + 2.0) * sz, | ||
| (fi + 1.0) * sz, | ||
| (fi + 3.0) * sz, | ||
| )); | ||
| } | ||
| for i in 1..2 { | ||
| let fi = i as f64; | ||
| holes.push(box_ring( | ||
| fi * sz, | ||
| (fi + 4.0) * sz, | ||
| (fi + 1.0) * sz, | ||
| (fi + 5.0) * sz, | ||
| )); | ||
| } | ||
|
|
||
| // Off-diagonal holes below the diagonal | ||
| for i in 1..4 { | ||
| let fi = i as f64; | ||
| holes.push(box_ring( | ||
| (fi + 2.0) * sz, | ||
| fi * sz, | ||
| (fi + 3.0) * sz, | ||
| (fi + 1.0) * sz, | ||
| )); | ||
| } | ||
| for i in 1..2 { | ||
| let fi = i as f64; | ||
| holes.push(box_ring( | ||
| (fi + 4.0) * sz, | ||
| fi * sz, | ||
| (fi + 5.0) * sz, | ||
| (fi + 1.0) * sz, | ||
| )); | ||
| } | ||
|
|
||
| holes | ||
| } | ||
|
|
||
| /// Translate a ring by an offset. | ||
| pub fn translate_ring(ring: &LineString<f64>, dx: f64, dy: f64) -> LineString<f64> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See the |
||
| LineString::new( | ||
| ring.coords() | ||
| .map(|c| coord! { x: c.x + dx, y: c.y + dy }) | ||
| .collect(), | ||
| ) | ||
| } | ||
|
|
||
| /// Create a checkerboard polygon with the given nesting level. | ||
| /// | ||
| /// - Level 0: Simple 7x7 checkerboard with 13 holes | ||
| /// - Level 1: 49x49 with level-0 checkerboard nested inside one of the "solid" squares | ||
| /// - Level 2: 343x343 with level-1 nested inside, etc. | ||
| /// | ||
| /// The nested checkerboards are placed at offset (2*sz, 3*sz) where | ||
| /// sz is the size of the outer checkerboard's unit cell. This places the nested pattern | ||
| /// inside the solid square at grid position (2,3). | ||
| /// | ||
| /// Returns the polygon and its expected area. | ||
| pub fn create_checkerboard(level: usize) -> (Polygon<f64>, f64) { | ||
| let base_sz: f64 = 7.0; | ||
| let sz = base_sz.powi((level + 1) as i32); | ||
|
|
||
| let exterior = box_ring(0.0, 0.0, sz, sz); | ||
| let exterior_area = sz * sz; | ||
|
|
||
| let mut all_holes: Vec<LineString<f64>> = Vec::new(); | ||
| let mut total_hole_area = 0.0; | ||
|
|
||
| // Start with level 0 holes at the outermost scale | ||
| // Then recursively add smaller checkerboards inside one of the "solid" squares | ||
| fn add_holes_recursive( | ||
| all_holes: &mut Vec<LineString<f64>>, | ||
| total_hole_area: &mut f64, | ||
| current_level: usize, | ||
| max_level: usize, | ||
| offset_x: f64, | ||
| offset_y: f64, | ||
| base_sz: f64, | ||
| ) { | ||
| // Size of unit cell at this level | ||
| let unit_sz = base_sz.powi((max_level - current_level) as i32); | ||
|
|
||
| // Add the 13 holes for this checkerboard level | ||
| let holes = checkerboard_holes_at_level(max_level - current_level); | ||
| let hole_area = unit_sz * unit_sz; | ||
|
|
||
| for hole in holes { | ||
| let translated = translate_ring(&hole, offset_x, offset_y); | ||
| all_holes.push(translated); | ||
| *total_hole_area += hole_area; | ||
| } | ||
|
|
||
| // If not at the innermost level, recurse into one of the solid squares | ||
| // The solid square at position (2, 3) in the 7x7 grid | ||
| if current_level < max_level { | ||
| let next_offset_x = offset_x + 2.0 * unit_sz; | ||
| let next_offset_y = offset_y + 3.0 * unit_sz; | ||
| add_holes_recursive( | ||
| all_holes, | ||
| total_hole_area, | ||
| current_level + 1, | ||
| max_level, | ||
| next_offset_x, | ||
| next_offset_y, | ||
| base_sz, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| add_holes_recursive( | ||
| &mut all_holes, | ||
| &mut total_hole_area, | ||
| 0, | ||
| level, | ||
| 0.0, | ||
| 0.0, | ||
| base_sz, | ||
| ); | ||
|
|
||
| let polygon = Polygon::new(exterior, all_holes); | ||
| let expected_area = exterior_area - total_hole_area; | ||
|
|
||
| (polygon, expected_area) | ||
| } | ||
|
|
||
| /// Create a checkerboard polygon without computing expected area. | ||
| /// | ||
| /// This is a convenience wrapper for benchmarking where only the polygon is needed. | ||
| pub fn create_checkerboard_polygon(level: usize) -> Polygon<f64> { | ||
| create_checkerboard(level).0 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ | |
|
|
||
| ## 0.32.0 - 2025-12-05 | ||
|
|
||
| - 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. | ||
| - Performance: Polygon validation now uses `PreparedGeometry` to cache R-tree structures, improving validation speed by 26-45% for polygons with many holes. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| - Move `PreparedGeometry` into a new `indexed` module intended to provide index-backed geometries. `relate::PreparedGeometry` has been deprecated. | ||
| - Use an interval tree for faster (Multi)Point in MultiPolygon checks | ||
| - LOF algorithm efficiency improvements due to caching kth distance | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the providence of
fixtures/rust-geo-booleanop-fixtures/benchmarks/validate.geojsonIt's seems like ultimately we just want a vector of polygons. If it's an arbitrary test fixture generated for this test, can you simplify the test data rather than complicating the parsing code?