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