diff --git a/figures/fig_01_grid_block.svg b/figures/fig_01_grid_block.svg index 21c2eea..7e0bc42 100644 --- a/figures/fig_01_grid_block.svg +++ b/figures/fig_01_grid_block.svg @@ -18,116 +18,116 @@ - + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/figures/fig_02_curved_road.svg b/figures/fig_02_curved_road.svg index 0405358..1bc25e5 100644 --- a/figures/fig_02_curved_road.svg +++ b/figures/fig_02_curved_road.svg @@ -15,156 +15,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + diff --git a/figures/fig_04_y_intersection.svg b/figures/fig_04_y_intersection.svg index 7ded416..ae7ff40 100644 --- a/figures/fig_04_y_intersection.svg +++ b/figures/fig_04_y_intersection.svg @@ -10,201 +10,86 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/figures/fig_06a_road_edit_before.svg b/figures/fig_06a_road_edit_before.svg index 21c2eea..7e0bc42 100644 --- a/figures/fig_06a_road_edit_before.svg +++ b/figures/fig_06a_road_edit_before.svg @@ -18,116 +18,116 @@ - + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/figures/fig_06b_road_edit_after.svg b/figures/fig_06b_road_edit_after.svg index ac38df3..31cc691 100644 --- a/figures/fig_06b_road_edit_after.svg +++ b/figures/fig_06b_road_edit_after.svg @@ -13,121 +13,121 @@ - - - + + + - + + - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/figures/fig_07_regularity_slider_rho_0_0.svg b/figures/fig_07_regularity_slider_rho_0_0.svg index 51d7f3c..9f55259 100644 --- a/figures/fig_07_regularity_slider_rho_0_0.svg +++ b/figures/fig_07_regularity_slider_rho_0_0.svg @@ -18,111 +18,113 @@ - + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/figures/fig_07_regularity_slider_rho_0_5.svg b/figures/fig_07_regularity_slider_rho_0_5.svg index 51d7f3c..9f55259 100644 --- a/figures/fig_07_regularity_slider_rho_0_5.svg +++ b/figures/fig_07_regularity_slider_rho_0_5.svg @@ -18,111 +18,113 @@ - + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/figures/fig_07_regularity_slider_rho_1_0.svg b/figures/fig_07_regularity_slider_rho_1_0.svg index 51d7f3c..9f55259 100644 --- a/figures/fig_07_regularity_slider_rho_1_0.svg +++ b/figures/fig_07_regularity_slider_rho_1_0.svg @@ -18,111 +18,113 @@ - + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/road_parceling/Cargo.lock b/road_parceling/Cargo.lock index ee7d66b..dd40567 100644 --- a/road_parceling/Cargo.lock +++ b/road_parceling/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" @@ -29,6 +35,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -56,6 +71,12 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cast" version = "0.3.0" @@ -143,7 +164,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools", + "itertools 0.10.5", "num-traits", "once_cell", "oorandom", @@ -162,7 +183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", ] [[package]] @@ -171,6 +192,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + [[package]] name = "either" version = "1.15.0" @@ -205,6 +236,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "fnv" version = "1.0.7" @@ -217,6 +254,50 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "geo" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f811f663912a69249fa620dcd2a005db7254529da2d8a0b23942e81f47084501" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "log", + "num-traits", + "robust", + "rstar", + "spade", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "rstar", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -274,13 +355,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -289,6 +390,16 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -352,6 +463,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -370,6 +490,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -401,6 +527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -591,6 +718,7 @@ name = "road_parceling" version = "0.1.0" dependencies = [ "criterion", + "geo", "glam", "insta", "proptest", @@ -602,6 +730,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + [[package]] name = "rustix" version = "1.1.4" @@ -701,6 +846,30 @@ dependencies = [ "version_check", ] +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "svg" version = "0.18.0" diff --git a/road_parceling/Cargo.toml b/road_parceling/Cargo.toml index c729785..c0c67bf 100644 --- a/road_parceling/Cargo.toml +++ b/road_parceling/Cargo.toml @@ -17,6 +17,7 @@ serde = ["dep:serde", "glam/serde", "slotmap/serde"] [dependencies] glam = { version = "0.29", features = ["mint"] } +geo = "0.28" slotmap = "1" thiserror = "2" rand = "0.8" diff --git a/road_parceling/src/parcel/subdivide.rs b/road_parceling/src/parcel/subdivide.rs index cd7a4a0..a44afca 100644 --- a/road_parceling/src/parcel/subdivide.rs +++ b/road_parceling/src/parcel/subdivide.rs @@ -460,9 +460,217 @@ pub(crate) fn subdivide_block( } } + let out = cleanup_block_parcel_overlaps(graph, out, params); Ok(out) } +/// Polygon-difference cleanup: iterate generated parcels in placement +/// order, subtract previously-claimed territory from each. Guarantees +/// strict pairwise non-overlap (I3) regardless of upstream depth-cap +/// quirks. Order: corner parcels are generated before regular parcels, +/// so they win territory ties. +/// +/// `geo`'s boolean ops occasionally panic in the internal sweep +/// algorithm on near-coincident edges. We snap inputs to a 1\,mm +/// grid (well below parcel-relevant precision) and wrap each +/// difference/union in `catch_unwind` so a failed cleanup falls +/// back to keeping the parcel as-is rather than crashing the +/// subdivision. +fn cleanup_block_parcel_overlaps( + graph: &RoadGraph, + parcels: Vec, + params: &SubdivisionParams, +) -> Vec { + use geo::Area; + use geo::BooleanOps; + if parcels.len() < 2 { + return parcels; + } + let mut result: Vec = Vec::with_capacity(parcels.len()); + let mut claimed: Option> = None; + for parcel in parcels { + let pgon = to_geo_polygon(&parcel.polygon); + let pgon_mp = geo::MultiPolygon::new(vec![pgon]); + let remaining = match claimed.as_ref() { + Some(c) => { + let safe_diff = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + pgon_mp.difference(c) + })); + match safe_diff { + Ok(r) => r, + Err(_) => { + // Boolean op crashed; keep the parcel as-is. + result.push(parcel); + continue; + } + } + } + None => pgon_mp, + }; + let mut largest_idx: Option = None; + let mut largest_area = 0.0; + for (i, p) in remaining.0.iter().enumerate() { + let a = p.unsigned_area(); + if a > largest_area { + largest_area = a; + largest_idx = Some(i); + } + } + let Some(idx) = largest_idx else { + continue; + }; + if largest_area < params.min_area { + continue; + } + let geo_poly = remaining.0[idx].clone(); + let Some(new_poly) = polygon_from_geo(&geo_poly) else { + // Conversion back failed (collinear/degenerate); keep + // the parcel as-is. Rare; happens when boolean output + // is heavily fragmented by fp noise. + result.push(parcel); + continue; + }; + let road = parcel.frontage_road; + let Some((a, b)) = graph.road_nodes(road) else { + continue; + }; + let (Some(pos_a), Some(pos_b)) = (graph.node_position(a), graph.node_position(b)) else { + continue; + }; + let frontage_idx = match find_frontage_edge_after_clip(&new_poly, pos_a, pos_b) { + Some(i) => i, + None => continue, + }; + let frontage_len = { + let v = new_poly.vertices(); + (v[(frontage_idx + 1) % v.len()] - v[frontage_idx]).length() + }; + if frontage_len < params.min_frontage { + continue; + } + let edge_kinds = classify_edges(new_poly.len(), frontage_idx); + let updated = Parcel { + polygon: new_poly, + vertex_ids: Vec::new(), + edge_kinds, + frontage_road: parcel.frontage_road, + frontage_edge_index: frontage_idx, + block: parcel.block, + building: parcel.building, + }; + let kept_mp = geo::MultiPolygon::new(vec![geo_poly]); + let safe_union = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + match claimed.as_ref() { + Some(c) => c.union(&kept_mp), + None => kept_mp, + } + })); + match safe_union { + Ok(u) => claimed = Some(u), + Err(_) => { /* leave claimed unchanged on union panic */ } + } + result.push(updated); + } + result +} + +fn to_geo_polygon(p: &Polygon) -> geo::Polygon { + // Snap to a 1\,mm grid before handing to `geo`. This is well + // below any parcel-relevant precision and helps the sweep-line + // boolean algorithm avoid near-coincident edges that trip its + // internal invariants. + let scale = 1000.0_f64; + let snapped: Vec = p + .vertices() + .iter() + .map(|v| DVec2::new((v.x * scale).round() / scale, (v.y * scale).round() / scale)) + .collect(); + let cleaned = strip_collinear_and_short(snapped); + let mut coords: Vec> = cleaned + .iter() + .map(|v| geo::Coord { x: v.x, y: v.y }) + .collect(); + if let Some(first) = coords.first().copied() { + coords.push(first); + } + geo::Polygon::new(geo::LineString::from(coords), vec![]) +} + +fn polygon_from_geo(p: &geo::Polygon) -> Option { + let exterior = p.exterior(); + let mut verts: Vec = exterior + .points() + .map(|pt| DVec2::new(pt.x(), pt.y())) + .collect(); + if verts.len() >= 2 { + let first = verts[0]; + let last = *verts.last().expect("len >= 2"); + if (first - last).length_squared() < EPS_GEOM * EPS_GEOM { + verts.pop(); + } + } + let cleaned = strip_collinear_and_short(verts); + if cleaned.len() < 3 { + return None; + } + // Use strict `Polygon::new` so the polygon satisfies I1. + Polygon::new(cleaned).ok() +} + +/// Remove collinear-triple vertices and near-zero-length edges from +/// a polygon ring. Boolean-op output frequently includes both kinds +/// of artifact: tiny fp-noise edges along a straight cut, and +/// collinear interpolation points where two pieces of the same line +/// were stitched together. Strict [`Polygon::new`] (I1) rejects +/// both, so we clean them up before reconstructing the polygon. +fn strip_collinear_and_short(verts: Vec) -> Vec { + if verts.len() < 3 { + return verts; + } + // Pass 1: drop near-zero-length edges. + let mut pass1: Vec = Vec::with_capacity(verts.len()); + for v in verts { + if let Some(&last) = pass1.last() { + if (v - last).length_squared() < EPS_GEOM * EPS_GEOM { + continue; + } + } + pass1.push(v); + } + if pass1.len() >= 2 { + let first = pass1[0]; + let last = *pass1.last().expect("len >= 2"); + if (first - last).length_squared() < EPS_GEOM * EPS_GEOM { + pass1.pop(); + } + } + if pass1.len() < 3 { + return pass1; + } + // Pass 2: drop collinear triples (in unit-tangent cross sense). + let mut pass2: Vec = Vec::with_capacity(pass1.len()); + let n = pass1.len(); + for i in 0..n { + let prev = pass1[(i + n - 1) % n]; + let curr = pass1[i]; + let next = pass1[(i + 1) % n]; + let in_dir = (curr - prev).normalize_or_zero(); + let out_dir = (next - curr).normalize_or_zero(); + if in_dir.length_squared() < EPS_GEOM * EPS_GEOM + || out_dir.length_squared() < EPS_GEOM * EPS_GEOM + { + continue; + } + let cross = in_dir.x * out_dir.y - in_dir.y * out_dir.x; + // Collinear iff cross ~ 0 and they point the same way. + if cross.abs() < 1e-3 && in_dir.dot(out_dir) > 0.0 { + continue; + } + pass2.push(curr); + } + pass2 +} + /// True iff vertex `i` of the block is a "real corner" — graph degree /// ≥3, or degree 2 with a bend sharper than 150°. (D11.) fn is_real_corner(graph: &RoadGraph, block: &Block, i: usize, _params: &SubdivisionParams) -> bool { diff --git a/road_parceling/tests/degenerate.rs b/road_parceling/tests/degenerate.rs index a896172..579169d 100644 --- a/road_parceling/tests/degenerate.rs +++ b/road_parceling/tests/degenerate.rs @@ -716,10 +716,71 @@ fn shared_vertex_no_drift_under_repeated_edits() { let _ = target_pos; } +/// Convert a road_parceling Polygon into a geo::Polygon for boolean +/// ops. Ensures the ring is closed (geo wants the first vertex +/// repeated at the end) and CCW. +fn to_geo_polygon(p: &road_parceling::geometry::Polygon) -> geo::Polygon { + let mut coords: Vec> = p + .vertices() + .iter() + .map(|v| geo::Coord { x: v.x, y: v.y }) + .collect(); + if let Some(first) = coords.first().copied() { + coords.push(first); + } + geo::Polygon::new(geo::LineString::from(coords), vec![]) +} + +/// Compute the overlap area between two parcels via rigorous +/// polygon-polygon intersection (geo crate). 0.0 means no overlap. +fn parcel_overlap_area( + a: &road_parceling::geometry::Polygon, + b: &road_parceling::geometry::Polygon, +) -> f64 { + use geo::Area; + use geo::BooleanOps; + let ga = to_geo_polygon(a); + let gb = to_geo_polygon(b); + let inter = ga.intersection(&gb); + inter.unsigned_area() +} + +/// Stronger I3 check: pairwise polygon-polygon intersection area +/// must be zero (within tolerance) for every pair of distinct +/// parcels in the same block. Replaces the M0.3 centroid-only +/// check, which let real overlaps slip through (the Y intersection +/// figure is the canonical case). +fn assert_no_overlapping_parcels(parcels: &road_parceling::ParcelSet) { + let parcels_vec: Vec<_> = parcels.iter().collect(); + let tol = 1e-6_f64; // larger than EPS_AREA to ride out boolean-op fp noise + for i in 0..parcels_vec.len() { + let pi = parcels_vec[i].1.polygon(); + for j in (i + 1)..parcels_vec.len() { + let pj = parcels_vec[j].1.polygon(); + let area = parcel_overlap_area(pi, pj); + assert!( + area <= tol, + "I3 violation: parcels {} and {} overlap by area {} (tol {})", + i, j, area, tol, + ); + } + } +} + +#[test] +fn rectangle_no_overlaps_rigorous() { + let g = rectangle_graph(200.0, 100.0); + let params = SubdivisionParams::default(); + let parcels = subdivide_all(&g, ¶ms).unwrap(); + assert_no_overlapping_parcels(&parcels); +} + #[test] fn y_intersection_no_overlaps() { - // Programmatic I3 check: no parcel's centroid is contained inside - // any *other* parcel's polygon. + // Programmatic I3 check: rigorous polygon-polygon intersection + // (M0.5; replaces M0.3's centroid-only check). Every pair of + // distinct parcels must have intersection area zero (within + // boolean-op fp tolerance). let mut g = RoadGraph::new(); let center = g.add_node(DVec2::new(0.0, 0.0)); let r = 100.0; @@ -736,22 +797,5 @@ fn y_intersection_no_overlaps() { g.rebuild_topology().unwrap(); let params = SubdivisionParams::default(); let parcels = subdivide_all(&g, ¶ms).unwrap(); - - let parcels_vec: Vec<_> = parcels.iter().collect(); - for (i, (_, pi)) in parcels_vec.iter().enumerate() { - let centroid_i = pi.polygon().centroid(); - for (j, (_, pj_pair)) in parcels_vec.iter().enumerate() { - if i == j { - continue; - } - let pj = pj_pair.polygon(); - assert!( - !pj.contains(centroid_i), - "I3 violation: centroid of parcel {} ({:?}) is inside parcel {}", - i, - centroid_i, - j - ); - } - } + assert_no_overlapping_parcels(&parcels); }