Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions geo-benches/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,8 @@ harness = false
name = "kmeans"
path = "src/kmeans.rs"
harness = false

[[bench]]
name = "validation"
path = "src/validation.rs"
harness = false

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions geo-benches/src/validation.rs
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");
Copy link
Member

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.geojson

It'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?


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);
192 changes: 192 additions & 0 deletions geo-test-fixtures/src/checkerboard.rs
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> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the Translate::translate / Translate::translate_mut

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
}
2 changes: 2 additions & 0 deletions geo-test-fixtures/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use std::{path::PathBuf, str::FromStr};
use geo_types::{LineString, MultiPolygon, Point, Polygon};
use wkt::{TryFromWkt, WktFloat};

pub mod checkerboard;

pub fn louisiana<T>() -> LineString<T>
where
T: WktFloat + Default + FromStr,
Expand Down
2 changes: 2 additions & 0 deletions geo/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PreparedGeometry is just used for this newly added validation right? So there's no world in which people validating before would see a speedup, right?

- 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
Expand Down
Loading