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 @@ -169,3 +169,8 @@ harness = false
name = "voronoi"
path = "src/voronoi.rs"
harness = false

[[bench]]
name = "validation"
path = "src/validation.rs"
harness = false
66 changes: 66 additions & 0 deletions geo-benches/src/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//! Benchmarks for polygon validation, focusing on:
//! - Checkerboard patterns (stress test simply-connected interior detection)
//! - Simple and real-world polygon geometries

use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use geo::algorithm::Validation;
use geo::coord;
use geo::geometry::{LineString, Polygon};
use geo_test_fixtures::checkerboard::create_checkerboard_polygon;

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]);
Polygon::new(LineString::new(coords), vec![])
}

fn validation_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("polygon_validation");

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())),
);
}

for level in 0..=3 {
let checkerboard = create_checkerboard_polygon(level);
let num_holes = checkerboard.interiors().len();
let label = format!("level_{level}_{num_holes}holes");
group.bench_with_input(
BenchmarkId::new("checkerboard", &label),
&checkerboard,
|b, poly| b.iter(|| criterion::black_box(poly.is_valid())),
);
}

let east_baton_rouge: Polygon<f64> = geo_test_fixtures::east_baton_rouge();
group.bench_with_input(
BenchmarkId::new("fixture", "east_baton_rouge"),
&east_baton_rouge,
|b, poly| b.iter(|| criterion::black_box(poly.is_valid())),
);

let nl_zones = geo_test_fixtures::nl_zones::<f64>();
group.bench_with_input(BenchmarkId::new("fixture", "nl_zones"), &nl_zones, |b, mp| {
b.iter(|| criterion::black_box(mp.is_valid()))
});

let nl_plots = geo_test_fixtures::nl_plots_wgs84::<f64>();
group.bench_with_input(BenchmarkId::new("fixture", "nl_plots"), &nl_plots, |b, mp| {
b.iter(|| criterion::black_box(mp.is_valid()))
});

group.finish();
}

criterion_group!(benches, validation_benchmark);
criterion_main!(benches);
1 change: 1 addition & 0 deletions geo-test-fixtures/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ publish = false
[dependencies]
wkt = { version = "0.14.0" }
geo-types = { path = "../geo-types" }
geo = { path = "../geo" }
177 changes: 177 additions & 0 deletions geo-test-fixtures/src/checkerboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! 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
//! | | # | | # | | # | | * = shared vertices
//! 3 | +---*---*---*---*---+ |
//! | | # | | # | |
//! 2 | +---*---*---*---*---+ |
//! | | # | | # | | # | |
//! 1 | +---+ +---+ +---+ |
//! | |
//! 0 +-------------------------------+
//! ```

use geo::Translate;
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
}

/// 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;

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,
) {
let unit_sz = base_sz.powi((max_level - current_level) as i32);

let holes = checkerboard_holes_at_level(max_level - current_level);
let hole_area = unit_sz * unit_sz;

for hole in holes {
all_holes.push(hole.translate(offset_x, offset_y));
*total_hole_area += hole_area;
}

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.
///
/// 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, MultiPoint, MultiPolygon, Point, Polygon};
use wkt::{TryFromWkt, WktFloat};

pub mod checkerboard;

pub fn louisiana<T>() -> LineString<T>
where
T: WktFloat + Default + FromStr,
Expand Down
6 changes: 6 additions & 0 deletions geo/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- 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.
- <https://github.com/georust/geo/pull/1472>
- Polygon validation now uses `PreparedGeometry` to cache R-tree structures for interior/exterior containment checks, improving validation speed for polygons with many holes.
- <https://github.com/georust/geo/pull/1501>
- Update `i_overlay` to 4.4 and enable OGC-compliant polygon extraction for all boolean operations, fixing cases where holes sharing vertices produced invalid geometry.
- <https://github.com/georust/geo/pull/1500>
- Fix `CoordinatePosition` for `LineString` to handle dimensionally collapsed input e.g. `LINESTRING(0 0)` is treated like `POINT(0 0)`.
- <https://github.com/georust/geo/pull/1483>
- Fix `CoordinatePosition` for `Triangle` to correctly return `CoordPos::OnBoundary` for coordinate within vertical segment.
Expand Down
2 changes: 1 addition & 1 deletion geo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ proj = { version = "0.31.0", optional = true }
robust = "1.1.0"
rstar = "0.12.0"
serde = { version = "1.0", optional = true, features = ["derive"] }
i_overlay = { version = "4.0.0, < 4.1.0", default-features = false }
i_overlay = { version = "4.4.0, < 4.5.0", default-features = false }
sif-itree = "0.4.0"

[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies]
Expand Down
14 changes: 11 additions & 3 deletions geo/src/algorithm/bool_ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub use i_overlay::core::fill_rule::FillRule;
use i_overlay::core::overlay_rule::OverlayRule;
use i_overlay::float::clip::FloatClip;
use i_overlay::float::overlay::FloatOverlay;
use i_overlay::float::single::SingleFloatOverlay;
use i_overlay::float::overlay::OverlayOptions;
use i_overlay::string::clip::ClipRule;

/// Boolean Operations on geometry.
Expand Down Expand Up @@ -96,7 +96,13 @@ pub trait BooleanOps {
) -> MultiPolygon<Self::Scalar> {
let subject = self.rings().map(ring_to_shape_path).collect::<Vec<_>>();
let clip = other.rings().map(ring_to_shape_path).collect::<Vec<_>>();
let shapes = subject.overlay(&clip, op.into(), fill_rule);
let shapes = FloatOverlay::with_subj_and_clip_custom(
&subject,
&clip,
OverlayOptions::ogc(),
Default::default(),
)
.overlay(op.into(), fill_rule);
multi_polygon_from_shapes(shapes)
}

Expand Down Expand Up @@ -275,7 +281,9 @@ pub fn unary_union<'a, B: BooleanOps + 'a>(
FillRule::Negative
};

let shapes = FloatOverlay::with_subj(&subject).overlay(OverlayRule::Subject, fill_rule);
let shapes =
FloatOverlay::with_subj_custom(&subject, OverlayOptions::ogc(), Default::default())
.overlay(OverlayRule::Subject, fill_rule);
multi_polygon_from_shapes(shapes)
}

Expand Down
Loading