From 64d5bf1e30537f54792bbde3964db5b2ce228cab Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Fri, 17 Apr 2026 13:10:13 -0400 Subject: [PATCH 01/12] fix issue with ring assignment --- src/graph.rs | 17 +++++++++++------ src/tests.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/graph.rs b/src/graph.rs index 529a653..a03cadf 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -384,7 +384,12 @@ impl PolygonizerGraph { let (valid_holes, valid_shells): (Vec<_>, Vec<_>) = valid_rings.into_iter().partition(|ring| ring.is_ccw()); - MultiPolygon(assign_shells_to_holes(valid_shells, valid_holes)) + MultiPolygon( + assign_shells_to_holes(valid_shells, valid_holes) + .into_iter() + .filter(|polygon| polygon.is_valid()) + .collect(), + ) } } @@ -474,11 +479,6 @@ fn assign_shells_to_holes( } } - let assigned_hole_indices: BTreeSet = assignments - .values() - .flat_map(|hole_indices| hole_indices.iter().copied()) - .collect(); - let mut polygons: Vec> = shells .into_iter() .enumerate() @@ -497,6 +497,11 @@ fn assign_shells_to_holes( }) .collect(); + let assigned_hole_indices: BTreeSet = assignments + .values() + .flat_map(|hole_indices| hole_indices.iter().copied()) + .collect(); + for hole_index in assigned_hole_indices { let standalone_hole_polygon = Polygon::new(holes[hole_index].clone(), vec![]).orient(Direction::Default); diff --git a/src/tests.rs b/src/tests.rs index fcab7d1..6a3be51 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use geo::{Line, LinesIter, MultiPolygon, Polygon}; +use geo::{Line, LinesIter, MultiPolygon, Polygon, Validation}; use geojson::GeometryValue; use super::*; @@ -382,3 +382,49 @@ fn polygonize_venn_overlaps_split_into_distinct_regions() { "output_nodify", ); } + +#[test] +fn debug_invalid_polygon() { + let lines = load_input_lines("buggy_outlines"); + let polygons = polygonize(lines.into_iter()); + assert!(polygons.0.iter().all(|poly| poly.is_valid())); +} + +#[test] +fn polygonize_filters_invalid_polygon_from_touching_hole_assignment() { + fn ring_lines(points: &[(f64, f64)]) -> Vec> { + points + .windows(2) + .map(|segment| { + let start = geo::Coord { + x: segment[0].0, + y: segment[0].1, + }; + let end = geo::Coord { + x: segment[1].0, + y: segment[1].1, + }; + Line::new(start, end) + }) + .collect() + } + + let mut lines = Vec::new(); + lines.extend(ring_lines(&[ + (0.0, 0.0), + (10.0, 0.0), + (10.0, 10.0), + (0.0, 10.0), + (0.0, 0.0), + ])); + lines.extend(ring_lines(&[ + (0.0, 4.0), + (3.0, 4.0), + (3.0, 6.0), + (0.0, 6.0), + (0.0, 4.0), + ])); + + let polygons = polygonize(lines.into_iter()); + assert!(polygons.0.iter().all(|polygon| polygon.is_valid())); +} From b1f16755f11e2b61ba3bf4144c16cf3370a177d0 Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Fri, 17 Apr 2026 18:08:42 -0400 Subject: [PATCH 02/12] fix more issues with hole/shell assignment --- .../nested_shell_overlap_minimal.geojson | 1 + src/graph.rs | 106 +++++++++++++++++- src/tests.rs | 27 ++++- 3 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 fixtures/polygonizer/input/nested_shell_overlap_minimal.geojson diff --git a/fixtures/polygonizer/input/nested_shell_overlap_minimal.geojson b/fixtures/polygonizer/input/nested_shell_overlap_minimal.geojson new file mode 100644 index 0000000..a04e600 --- /dev/null +++ b/fixtures/polygonizer/input/nested_shell_overlap_minimal.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3645294,37.23227291],[131.4036901,37.07943623],[131.6754399,36.86381675],[131.866667,36.83353824],[132.0578941,36.86381675],[132.3296439,37.07943623],[132.3688046,37.23227291],[132.3315267,37.38542004],[132.059777,37.60253859],[131.866667,37.63312759],[131.673557,37.60253859],[131.4018073,37.38542004],[131.3645294,37.23227291]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.6159497978044,37.24040921335645],[131.6789834344638,37.426338419556394],[131.79570674559747,37.494760171748474],[131.99086117128556,37.481714926898256],[132.1246261181188,37.33971272991575],[132.1340414978442,37.21699385927595],[132.04409187602525,37.05216214177735],[131.88096982742374,36.990452460251525],[131.6924993976829,37.063692228175476],[131.6159497978044,37.24040921335645]]},"properties":null}]} diff --git a/src/graph.rs b/src/graph.rs index a03cadf..5801039 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -5,7 +5,8 @@ use std::iter::{FilterMap, Flatten, Map}; use geo::Winding; use geo::orient::Direction; use geo::{ - GeoFloat, Line, LineString, LinesIter, MultiPolygon, Orient, Polygon, Validation, coord, + Contains, GeoFloat, InteriorPoint, Line, LineString, LinesIter, MultiPolygon, Orient, + Polygon, Validation, coord, }; use rstar::{Envelope, RTreeObject}; @@ -384,13 +385,105 @@ impl PolygonizerGraph { let (valid_holes, valid_shells): (Vec<_>, Vec<_>) = valid_rings.into_iter().partition(|ring| ring.is_ccw()); - MultiPolygon( - assign_shells_to_holes(valid_shells, valid_holes) - .into_iter() - .filter(|polygon| polygon.is_valid()) - .collect(), + let valid_polygons: Vec<_> = assign_shells_to_holes(valid_shells, valid_holes) + .into_iter() + .filter(|polygon| polygon.is_valid()) + .collect(); + + MultiPolygon(infer_parent_holes_when_output_has_no_holes(valid_polygons)) + } +} + +fn infer_parent_holes_when_output_has_no_holes( + polygons: Vec>, +) -> Vec> { + if polygons.iter().any(|polygon| !polygon.interiors().is_empty()) { + return polygons; + } + + let polygon_envelopes: Vec<_> = polygons.iter().map(|polygon| polygon.envelope()).collect(); + + let mut parent_polygon_index_by_polygon_index: Vec> = vec![None; polygons.len()]; + for child_polygon_index in 0..polygons.len() { + let child_envelope = polygon_envelopes[child_polygon_index]; + let mut best_parent: Option<(usize, T)> = None; + + for parent_polygon_index in 0..polygons.len() { + if child_polygon_index == parent_polygon_index { + continue; + } + + let parent_envelope = polygon_envelopes[parent_polygon_index]; + if parent_envelope == child_envelope || !parent_envelope.contains_envelope(&child_envelope) + { + continue; + } + + let child_interior_point = match polygons[child_polygon_index].interior_point() { + Some(point) => point, + None => continue, + }; + + if !polygons[parent_polygon_index].contains(&child_interior_point) { + continue; + } + + let parent_envelope_area = parent_envelope.area(); + if let Some((_, best_area)) = best_parent { + if parent_envelope_area >= best_area { + continue; + } + } + best_parent = Some((parent_polygon_index, parent_envelope_area)); + } + + parent_polygon_index_by_polygon_index[child_polygon_index] = + best_parent.map(|(parent_polygon_index, _)| parent_polygon_index); + } + + let mut inferred_holes_by_parent_polygon_index: BTreeMap>> = + BTreeMap::new(); + for (child_polygon_index, parent_polygon_index) in + parent_polygon_index_by_polygon_index.iter().enumerate() + { + let parent_polygon_index = match parent_polygon_index { + Some(parent_polygon_index) => *parent_polygon_index, + None => continue, + }; + + let mut candidate_holes = inferred_holes_by_parent_polygon_index + .get(&parent_polygon_index) + .cloned() + .unwrap_or_default(); + candidate_holes.push(polygons[child_polygon_index].exterior().clone()); + + let candidate_polygon = Polygon::new( + polygons[parent_polygon_index].exterior().clone(), + candidate_holes.clone(), ) + .orient(Direction::Default); + if !candidate_polygon.is_valid() { + continue; + } + + inferred_holes_by_parent_polygon_index.insert(parent_polygon_index, candidate_holes); } + + polygons + .into_iter() + .enumerate() + .map(|(polygon_index, polygon)| { + let mut polygon_holes = polygon.interiors().to_vec(); + polygon_holes.extend( + inferred_holes_by_parent_polygon_index + .get(&polygon_index) + .cloned() + .unwrap_or_default(), + ); + + Polygon::new(polygon.exterior().clone(), polygon_holes).orient(Direction::Default) + }) + .collect() } type EdgeToLine<'a, T> = fn(&'a Edge) -> Option>; @@ -512,3 +605,4 @@ fn assign_shells_to_holes( polygons } + diff --git a/src/tests.rs b/src/tests.rs index 6a3be51..f07a39c 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use geo::{Line, LinesIter, MultiPolygon, Polygon, Validation}; +use geo::{Contains, Line, LinesIter, MultiPolygon, Polygon, Validation, point}; use geojson::GeometryValue; use super::*; @@ -383,11 +383,16 @@ fn polygonize_venn_overlaps_split_into_distinct_regions() { ); } +#[ignore] #[test] -fn debug_invalid_polygon() { - let lines = load_input_lines("buggy_outlines"); +fn debug_missing_hole() { + let lines = load_input_lines("failed_hole"); let polygons = polygonize(lines.into_iter()); - assert!(polygons.0.iter().all(|poly| poly.is_valid())); + + let test_point = point! { x:131.85, y: 37.25 }; + let num_polygons_containing_point = polygons.iter().filter(|poly| poly.contains(&test_point)).count(); + + assert_eq!(num_polygons_containing_point, 1); } #[test] @@ -428,3 +433,17 @@ fn polygonize_filters_invalid_polygon_from_touching_hole_assignment() { let polygons = polygonize(lines.into_iter()); assert!(polygons.0.iter().all(|polygon| polygon.is_valid())); } + +#[test] +fn polygonize_handles_nested_shell_overlap_without_double_containment() { + let lines = load_input_lines("nested_shell_overlap_minimal"); + let polygons = polygonize(lines.into_iter()); + let test_point = point! { x: 131.85, y: 37.25 }; + let containing_count = polygons + .0 + .iter() + .filter(|polygon| polygon.contains(&test_point)) + .count(); + + assert_eq!(containing_count, 1); +} From 8faca0fea20509c54f2642d4de937dce2410e9f2 Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Fri, 17 Apr 2026 18:45:38 -0400 Subject: [PATCH 03/12] more hole fixing --- .../polygonizer/input/failed_hole.geojson | 1 + src/graph.rs | 119 ++++++++++-------- src/tests.rs | 1 - 3 files changed, 68 insertions(+), 53 deletions(-) create mode 100644 fixtures/polygonizer/input/failed_hole.geojson diff --git a/fixtures/polygonizer/input/failed_hole.geojson b/fixtures/polygonizer/input/failed_hole.geojson new file mode 100644 index 0000000..d8ce77f --- /dev/null +++ b/fixtures/polygonizer/input/failed_hole.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3645294,37.23227291],[131.3666873,37.27146897]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3645294,37.23227291],[131.3672068,37.19309722]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3666873,37.27146897],[131.3736674,37.31030766]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3672068,37.19309722],[131.3746863,37.15431884]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3736674,37.31030766],[131.3854095,37.34841414]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3746863,37.15431884],[131.3868887,37.11631032]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3854095,37.34841414],[131.4018073,37.38542004]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.3868887,37.11631032],[131.4036901,37.07943623]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4018073,37.38542004],[131.4227089,37.4209671]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4036901,37.07943623],[131.4249229,37.04404979]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4227089,37.4209671],[131.4479179,37.45471069]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4249229,37.04404979],[131.4503779,37.01048946]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4479179,37.45471069],[131.4771952,37.48632319]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4503779,37.01048946],[131.4798068,36.9790758]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4771952,37.48632319],[131.5102612,37.51549728]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.4798068,36.9790758],[131.5129239,36.9501085]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.5102612,37.51549728],[131.5467982,37.541949]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.5129239,36.9501085],[131.5494099,36.92386359]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.5467982,37.541949],[131.5864539,37.56542057]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.5494099,36.92386359],[131.588914,36.90059087]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.5864539,37.56542057],[131.628844,37.58568304]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.588914,36.90059087],[131.6310581,36.88051166]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.6159497978044,37.24040921335645],[131.6789834344638,37.426338419556394]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.6159497978044,37.24040921335645],[131.6924993976829,37.063692228175476]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.628844,37.58568304],[131.673557,37.60253859]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.6310581,36.88051166],[131.6754399,36.86381675]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.673557,37.60253859],[131.7201573,37.61582252]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.6754399,36.86381675],[131.7216367,36.85066465]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.6789834344638,37.426338419556394],[131.79570674559747,37.494760171748474]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.6924993976829,37.063692228175476],[131.88096982742374,36.990452460251525]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.7201573,37.61582252],[131.7681902,37.62540497]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.7216367,36.85066465],[131.7692093,36.84118017]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.7681902,37.62540497],[131.8171865,37.6311922]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.7692093,36.84118017],[131.817706,36.83545326]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.79570674559747,37.494760171748474],[131.99086117128556,37.481714926898256]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.8171865,37.6311922],[131.866667,37.63312759]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.817706,36.83545326],[131.866667,36.83353824]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.866667,36.83353824],[131.9156279,36.83545326]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.866667,37.63312759],[131.9161475,37.6311922]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.88096982742374,36.990452460251525],[132.04409187602525,37.05216214177735]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.9156279,36.83545326],[131.9641247,36.84118017]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.9161475,37.6311922],[131.9651438,37.62540497]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.9641247,36.84118017],[132.0116973,36.85066465]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.9651438,37.62540497],[132.0131767,37.61582252]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[131.99086117128556,37.481714926898256],[132.1246261181188,37.33971272991575]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.0116973,36.85066465],[132.0578941,36.86381675]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.0131767,37.61582252],[132.059777,37.60253859]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.04409187602525,37.05216214177735],[132.1340414978442,37.21699385927595]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.0578941,36.86381675],[132.1022759,36.88051166]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.059777,37.60253859],[132.1044899,37.58568304]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1022759,36.88051166],[132.14442,36.90059087]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1044899,37.58568304],[132.1468801,37.56542057]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1246261181188,37.33971272991575],[132.1340414978442,37.21699385927595]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.14442,36.90059087],[132.1839241,36.92386359]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1468801,37.56542057],[132.1865358,37.541949]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1839241,36.92386359],[132.22041,36.9501085]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1865358,37.541949],[132.2230728,37.51549728]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1992415537936,37.140295005641654],[132.2802151948092,37.29316541576363]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.1992415537936,37.140295005641654],[132.32812844805989,37.0769105881086]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.22041,36.9501085],[132.2535272,36.9790758]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.2230728,37.51549728],[132.2561388,37.48632319]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.2535272,36.9790758],[132.282956,37.01048946]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.2561388,37.48632319],[132.2854161,37.45471069]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.2802151948092,37.29316541576363],[132.36806856583578,37.24564160289258]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.282956,37.01048946],[132.3084111,37.04404979]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.2854161,37.45471069],[132.3106251,37.4209671]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3084111,37.04404979],[132.32812844805989,37.0769105881086]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3106251,37.4209671],[132.3315267,37.38542004]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.32812844805989,37.0769105881086],[132.3296439,37.07943623]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.32812844805989,37.0769105881086],[132.37221729698305,37.05522843008466]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3296439,37.07943623],[132.3464452,37.11631032]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3315267,37.38542004],[132.3479245,37.34841414]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3464452,37.11631032],[132.3586477,37.15431884]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3479245,37.34841414],[132.3596666,37.31030766]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3586477,37.15431884],[132.3661272,37.19309722]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3596666,37.31030766],[132.3666466,37.27146897]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3661272,37.19309722],[132.3688046,37.23227291]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.3666466,37.27146897],[132.36806856583578,37.24564160289258]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.36806856583578,37.24564160289258],[132.3688046,37.23227291]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.36806856583578,37.24564160289258],[132.46744946519559,37.19188203500189]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[132.37221729698305,37.05522843008466],[132.46744946519559,37.19188203500189]]},"properties":null}]} \ No newline at end of file diff --git a/src/graph.rs b/src/graph.rs index 5801039..b3d128d 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -105,27 +105,79 @@ pub(crate) struct PolygonizerGraph { } impl PolygonizerGraph { - /// For each directed edge (u -> v), returns the next directed edge along - /// the face on its left side when traversing the planar graph. - fn get_edge_to_next_left_face_edge_map(&self) -> BTreeMap, Edge> { - let mut next_left_face_edge_by_edge = BTreeMap::new(); + fn get_edges_with_index_map(&self) -> (Vec>, BTreeMap, usize>) { + let mut edge_to_index: BTreeMap, usize> = BTreeMap::new(); + let mut edges_by_index: Vec> = Vec::new(); + + for outbound_edges_at_node in self.nodes_to_outbound_edges.values() { + for edge in outbound_edges_at_node { + if edge_to_index.contains_key(edge) { + continue; + } + + let edge_index = edges_by_index.len(); + edges_by_index.push(*edge); + edge_to_index.insert(*edge, edge_index); + } + } + + (edges_by_index, edge_to_index) + } + + fn get_next_left_face_edge_index_by_edge_index( + &self, + edge_to_index: &BTreeMap, usize>, + edge_count: usize, + ) -> Vec> { + let mut next_left_face_edge_index_by_edge_index: Vec> = vec![None; edge_count]; for outbound_edges_at_node in self.nodes_to_outbound_edges.values() { - let outbound_edges: Vec<_> = outbound_edges_at_node.iter().collect(); - let edge_count = outbound_edges.len(); - if edge_count == 0 { + let ordered_outgoing_edges: Vec<_> = outbound_edges_at_node.iter().collect(); + let ordered_edge_count = ordered_outgoing_edges.len(); + if ordered_edge_count == 0 { continue; } - for edge_index in 0..edge_count { - let reverse_edge = *outbound_edges[edge_index]; + for edge_index in 0..ordered_edge_count { + let reverse_edge = *ordered_outgoing_edges[edge_index]; let incoming_edge = reverse_edge.get_symmetrical(); let next_outgoing_edge = - *outbound_edges[(edge_index + edge_count - 1) % edge_count]; - next_left_face_edge_by_edge.insert(incoming_edge, next_outgoing_edge); + *ordered_outgoing_edges[(edge_index + ordered_edge_count - 1) % ordered_edge_count]; + + let incoming_edge_index = *edge_to_index + .get(&incoming_edge) + .expect("incoming edge index should exist"); + let next_outgoing_edge_index = *edge_to_index + .get(&next_outgoing_edge) + .expect("next outgoing edge index should exist"); + + next_left_face_edge_index_by_edge_index[incoming_edge_index] = + Some(next_outgoing_edge_index); } } + next_left_face_edge_index_by_edge_index + } + + /// For each directed edge (u -> v), returns the next directed edge along + /// the face on its left side when traversing the planar graph. + fn get_edge_to_next_left_face_edge_map(&self) -> BTreeMap, Edge> { + let (edges_by_index, edge_to_index) = self.get_edges_with_index_map(); + let next_left_face_edge_index_by_edge_index = + self.get_next_left_face_edge_index_by_edge_index(&edge_to_index, edges_by_index.len()); + + let mut next_left_face_edge_by_edge = BTreeMap::new(); + for (edge_index, next_edge_index) in next_left_face_edge_index_by_edge_index + .iter() + .copied() + .enumerate() + { + let Some(next_edge_index) = next_edge_index else { + continue; + }; + next_left_face_edge_by_edge.insert(edges_by_index[edge_index], edges_by_index[next_edge_index]); + } + next_left_face_edge_by_edge } @@ -238,46 +290,9 @@ impl PolygonizerGraph { } pub(crate) fn delete_cut_edges(&mut self) { - let mut edge_to_index: BTreeMap, usize> = BTreeMap::new(); - let mut edges_by_index: Vec> = Vec::new(); - - for outbound_edges_at_node in self.nodes_to_outbound_edges.values() { - for edge in outbound_edges_at_node { - if !edge_to_index.contains_key(edge) { - let edge_index = edges_by_index.len(); - edges_by_index.push(*edge); - edge_to_index.insert(*edge, edge_index); - } - } - } - - let mut next_left_face_edge_index_by_edge_index: Vec> = - vec![None; edges_by_index.len()]; - - for outbound_edges_at_node in self.nodes_to_outbound_edges.values() { - let ordered_outgoing_edges: Vec<_> = outbound_edges_at_node.iter().collect(); - let edge_count = ordered_outgoing_edges.len(); - if edge_count == 0 { - continue; - } - - for edge_index in 0..edge_count { - let reverse_edge = *ordered_outgoing_edges[edge_index]; - let incoming_edge = reverse_edge.get_symmetrical(); - let next_outgoing_edge = - *ordered_outgoing_edges[(edge_index + edge_count - 1) % edge_count]; - - let incoming_edge_index = *edge_to_index - .get(&incoming_edge) - .expect("incoming edge index should exist"); - let next_outgoing_edge_index = *edge_to_index - .get(&next_outgoing_edge) - .expect("next outgoing edge index should exist"); - - next_left_face_edge_index_by_edge_index[incoming_edge_index] = - Some(next_outgoing_edge_index); - } - } + let (edges_by_index, edge_to_index) = self.get_edges_with_index_map(); + let next_left_face_edge_index_by_edge_index = + self.get_next_left_face_edge_index_by_edge_index(&edge_to_index, edges_by_index.len()); let mut face_label_by_edge_index: Vec> = vec![None; edges_by_index.len()]; let mut next_face_label = 0usize; @@ -376,7 +391,6 @@ impl PolygonizerGraph { return None; } - // get the linestring back out to return it let (linestring, _) = polygon.into_inner(); Some(linestring) }) @@ -606,3 +620,4 @@ fn assign_shells_to_holes( polygons } + diff --git a/src/tests.rs b/src/tests.rs index f07a39c..7add794 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -383,7 +383,6 @@ fn polygonize_venn_overlaps_split_into_distinct_regions() { ); } -#[ignore] #[test] fn debug_missing_hole() { let lines = load_input_lines("failed_hole"); From 6ddcd032047eb130c255a4ef515d1295c78867ca Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Fri, 17 Apr 2026 20:26:35 -0400 Subject: [PATCH 04/12] work in progress --- src/graph.rs | 96 +++++++++++++++++--- src/tests.rs | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 11 deletions(-) diff --git a/src/graph.rs b/src/graph.rs index b3d128d..b54c5d6 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -5,7 +5,7 @@ use std::iter::{FilterMap, Flatten, Map}; use geo::Winding; use geo::orient::Direction; use geo::{ - Contains, GeoFloat, InteriorPoint, Line, LineString, LinesIter, MultiPolygon, Orient, + Area, Contains, GeoFloat, InteriorPoint, Line, LineString, LinesIter, MultiPolygon, Orient, Polygon, Validation, coord, }; use rstar::{Envelope, RTreeObject}; @@ -404,17 +404,47 @@ impl PolygonizerGraph { .filter(|polygon| polygon.is_valid()) .collect(); - MultiPolygon(infer_parent_holes_when_output_has_no_holes(valid_polygons)) + MultiPolygon(remove_redundant_overlapping_standalone_polygons( + infer_parent_holes_when_output_has_no_holes(valid_polygons), + )) } } -fn infer_parent_holes_when_output_has_no_holes( +fn remove_redundant_overlapping_standalone_polygons( polygons: Vec>, ) -> Vec> { - if polygons.iter().any(|polygon| !polygon.interiors().is_empty()) { - return polygons; - } + polygons + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if !polygon.interiors().is_empty() { + return Some(polygon.clone()); + } + + let interior_point = match polygon.interior_point() { + Some(point) => point, + None => return Some(polygon.clone()), + }; + let is_redundant_overlap = polygons.iter().enumerate().any(|(other_index, other)| { + other_index != polygon_index + && other.exterior() != polygon.exterior() + && !other.interiors().is_empty() + && other.contains(&interior_point) + }); + + if is_redundant_overlap { + None + } else { + Some(polygon.clone()) + } + }) + .collect() +} + +fn infer_parent_holes_when_output_has_no_holes( + polygons: Vec>, +) -> Vec> { let polygon_envelopes: Vec<_> = polygons.iter().map(|polygon| polygon.envelope()).collect(); let mut parent_polygon_index_by_polygon_index: Vec> = vec![None; polygons.len()]; @@ -465,6 +495,19 @@ fn infer_parent_holes_when_output_has_no_holes( None => continue, }; + let parent_exterior = polygons[parent_polygon_index].exterior(); + let has_same_exterior_polygon_with_explicit_holes = polygons + .iter() + .enumerate() + .any(|(polygon_index, polygon)| { + polygon_index != parent_polygon_index + && polygon.exterior() == parent_exterior + && !polygon.interiors().is_empty() + }); + if has_same_exterior_polygon_with_explicit_holes { + continue; + } + let mut candidate_holes = inferred_holes_by_parent_polygon_index .get(&parent_polygon_index) .cloned() @@ -550,6 +593,12 @@ fn assign_shells_to_holes( shells: Vec>, holes: Vec>, ) -> Vec> { + let shell_polygons: Vec<_> = shells + .iter() + .cloned() + .map(|shell| Polygon::new(shell, vec![])) + .collect(); + let shell_containers = shells .iter() .enumerate() @@ -563,19 +612,24 @@ fn assign_shells_to_holes( let mut assignments: BTreeMap> = BTreeMap::new(); for (hole_index, hole) in holes.iter().enumerate() { + let hole_interior_point = match Polygon::new(hole.clone(), vec![]).interior_point() { + Some(point) => point, + None => continue, + }; + let hole_envelope = hole.envelope(); let mut matching_shells: Vec<_> = shell_tree .locate_in_envelope_intersecting(&hole.envelope()) .filter(|container| { container.envelope.contains_envelope(&hole_envelope) && container.envelope != hole_envelope + && shell_polygons[container.idx].contains(&hole_interior_point) }) .collect(); matching_shells.sort_by(|left_shell, right_shell| { - left_shell - .envelope - .area() - .total_cmp(&right_shell.envelope.area()) + shell_polygons[left_shell.idx] + .unsigned_area() + .total_cmp(&shell_polygons[right_shell.idx].unsigned_area()) }); if let Some(container) = matching_shells.first() { @@ -612,7 +666,27 @@ fn assign_shells_to_holes( for hole_index in assigned_hole_indices { let standalone_hole_polygon = Polygon::new(holes[hole_index].clone(), vec![]).orient(Direction::Default); - if !polygons.contains(&standalone_hole_polygon) { + + let overlaps_different_exterior_holed_polygon = polygons.iter().any(|polygon| { + polygon.exterior() != standalone_hole_polygon.exterior() + && !polygon.interiors().is_empty() + && standalone_hole_polygon + .exterior() + .points() + .any(|point| polygon.contains(&point)) + }); + + let max_holes_with_same_exterior = polygons + .iter() + .filter(|polygon| polygon.exterior() == standalone_hole_polygon.exterior()) + .map(|polygon| polygon.interiors().len()) + .max() + .unwrap_or(0); + + if !overlaps_different_exterior_holed_polygon + && max_holes_with_same_exterior <= 1 + && !polygons.contains(&standalone_hole_polygon) + { polygons.push(standalone_hole_polygon); } } diff --git a/src/tests.rs b/src/tests.rs index 7add794..3e5ccb1 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -240,6 +240,203 @@ fn canonicalize_multipolygon( polygons } +fn has_self_intersection(ring: &geo::LineString, epsilon: f64) -> bool { + fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { + (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) + } + + fn between(value: f64, left: f64, right: f64, epsilon: f64) -> bool { + let min_value = left.min(right) - epsilon; + let max_value = left.max(right) + epsilon; + value >= min_value && value <= max_value + } + + fn on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64), epsilon: f64) -> bool { + between(p.0, a.0, b.0, epsilon) && between(p.1, a.1, b.1, epsilon) + } + + fn segments_intersect( + a1: (f64, f64), + a2: (f64, f64), + b1: (f64, f64), + b2: (f64, f64), + epsilon: f64, + ) -> bool { + let o1 = orient(a1, a2, b1); + let o2 = orient(a1, a2, b2); + let o3 = orient(b1, b2, a1); + let o4 = orient(b1, b2, a2); + + let proper = (o1 > epsilon && o2 < -epsilon || o1 < -epsilon && o2 > epsilon) + && (o3 > epsilon && o4 < -epsilon || o3 < -epsilon && o4 > epsilon); + if proper { + return true; + } + + (o1.abs() <= epsilon && on_segment(a1, a2, b1, epsilon)) + || (o2.abs() <= epsilon && on_segment(a1, a2, b2, epsilon)) + || (o3.abs() <= epsilon && on_segment(b1, b2, a1, epsilon)) + || (o4.abs() <= epsilon && on_segment(b1, b2, a2, epsilon)) + } + + let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); + if coordinates.len() < 4 { + return false; + } + + if coordinates.first() == coordinates.last() { + coordinates.pop(); + } + + let segment_count = coordinates.len(); + if segment_count < 3 { + return false; + } + + for first_segment_index in 0..segment_count { + let first_start = coordinates[first_segment_index]; + let first_end = coordinates[(first_segment_index + 1) % segment_count]; + + for second_segment_index in (first_segment_index + 1)..segment_count { + if second_segment_index == first_segment_index { + continue; + } + + let first_next_index = (first_segment_index + 1) % segment_count; + let second_next_index = (second_segment_index + 1) % segment_count; + let are_adjacent = second_segment_index == first_next_index + || first_segment_index == second_next_index; + if are_adjacent { + continue; + } + + if first_segment_index == 0 && second_next_index == 0 { + continue; + } + + let second_start = coordinates[second_segment_index]; + let second_end = coordinates[second_next_index]; + + if segments_intersect(first_start, first_end, second_start, second_end, epsilon) { + return true; + } + } + } + + false +} + +fn ring_has_boundary_contact( + ring: &geo::LineString, + other_ring: &geo::LineString, + epsilon: f64, +) -> bool { + fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { + (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) + } + + fn between(value: f64, left: f64, right: f64, epsilon: f64) -> bool { + let min_value = left.min(right) - epsilon; + let max_value = left.max(right) + epsilon; + value >= min_value && value <= max_value + } + + fn on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64), epsilon: f64) -> bool { + between(p.0, a.0, b.0, epsilon) && between(p.1, a.1, b.1, epsilon) + } + + fn segments_contact( + a1: (f64, f64), + a2: (f64, f64), + b1: (f64, f64), + b2: (f64, f64), + epsilon: f64, + ) -> bool { + let o1 = orient(a1, a2, b1); + let o2 = orient(a1, a2, b2); + let o3 = orient(b1, b2, a1); + let o4 = orient(b1, b2, a2); + + let proper = (o1 > epsilon && o2 < -epsilon || o1 < -epsilon && o2 > epsilon) + && (o3 > epsilon && o4 < -epsilon || o3 < -epsilon && o4 > epsilon); + if proper { + return true; + } + + (o1.abs() <= epsilon && on_segment(a1, a2, b1, epsilon)) + || (o2.abs() <= epsilon && on_segment(a1, a2, b2, epsilon)) + || (o3.abs() <= epsilon && on_segment(b1, b2, a1, epsilon)) + || (o4.abs() <= epsilon && on_segment(b1, b2, a2, epsilon)) + } + + let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); + let mut other_coordinates: Vec<(f64, f64)> = other_ring + .points() + .map(|point| (point.x(), point.y())) + .collect(); + + if coordinates.first() == coordinates.last() { + coordinates.pop(); + } + if other_coordinates.first() == other_coordinates.last() { + other_coordinates.pop(); + } + + if coordinates.len() < 3 || other_coordinates.len() < 3 { + return false; + } + + for first_segment_index in 0..coordinates.len() { + let first_start = coordinates[first_segment_index]; + let first_end = coordinates[(first_segment_index + 1) % coordinates.len()]; + + for second_segment_index in 0..other_coordinates.len() { + let second_start = other_coordinates[second_segment_index]; + let second_end = other_coordinates[(second_segment_index + 1) % other_coordinates.len()]; + + if segments_contact(first_start, first_end, second_start, second_end, epsilon) { + return true; + } + } + } + + false +} + +fn polygon_has_boundary_contact(polygon: &Polygon, epsilon: f64) -> bool { + if has_self_intersection(polygon.exterior(), epsilon) + || polygon + .interiors() + .iter() + .any(|ring| has_self_intersection(ring, epsilon)) + { + return true; + } + + let interiors = polygon.interiors(); + + if interiors + .iter() + .any(|interior| ring_has_boundary_contact(polygon.exterior(), interior, epsilon)) + { + return true; + } + + for first_hole_index in 0..interiors.len() { + for second_hole_index in (first_hole_index + 1)..interiors.len() { + if ring_has_boundary_contact( + &interiors[first_hole_index], + &interiors[second_hole_index], + epsilon, + ) { + return true; + } + } + } + + false +} + fn assert_polygonize_fixture(name: &str) { let input_lines = load_input_lines(name); let actual = polygonize(input_lines); @@ -394,6 +591,50 @@ fn debug_missing_hole() { assert_eq!(num_polygons_containing_point, 1); } +#[test] +fn debug_missing_hole_again() { + let lines = load_input_lines("debug_missing_hole_again"); + let polygons = polygonize(lines.into_iter()); + + let test_point = point! { x:5.9, y: 39.0 }; + let num_polygons_containing_point = polygons.iter().filter(|poly| poly.contains(&test_point)).count(); + + assert_eq!(num_polygons_containing_point, 1); + + let test_point2 = point! { x:1.25, y: 39.5 }; + let num_polygons_containing_point2 = polygons.iter().filter(|poly| poly.contains(&test_point2)).count(); + + assert_eq!(num_polygons_containing_point2, 1); +} + +#[test] +fn debug_missing_hole_again_again_minimal() { + let lines = load_input_lines("debug_missing_hole_again_again_minimal"); + let polygons = polygonize(lines.into_iter()); + + let test_point = point! { x:110.35, y: 20.2 }; + let num_polygons_containing_point = polygons.iter().filter(|poly| poly.contains(&test_point)).count(); + + assert_eq!(num_polygons_containing_point, 1); +} + +#[test] +fn debug_missing_hole_again_again() { + let lines = load_input_lines("produces_overlapping_polygons"); + let polygons = polygonize(lines.into_iter()); + + let test_point = point! { x:110.35, y: 20.2 }; + let polygons_containing_point: Vec<_> = polygons.iter().filter(|poly| poly.contains(&test_point)).collect(); + + assert_eq!(polygons_containing_point.len(), 1); + + let has_boundary_contact = polygon_has_boundary_contact(polygons_containing_point[0], 1e-10); + assert!( + has_boundary_contact, + "expected boundary contact (pinch/touch) in containing polygon" + ); +} + #[test] fn polygonize_filters_invalid_polygon_from_touching_hole_assignment() { fn ring_lines(points: &[(f64, f64)]) -> Vec> { From 0e4c194df0c6e061f42b445bcfa039bd37bc7d8a Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Fri, 17 Apr 2026 22:32:53 -0400 Subject: [PATCH 05/12] More hole updates --- .gitignore | 1 + src/graph.rs | 336 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/tests.rs | 242 +++++++++++++++++++++++++++---------- 3 files changed, 513 insertions(+), 66 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..0f84cc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/.vscode \ No newline at end of file diff --git a/src/graph.rs b/src/graph.rs index b54c5d6..262a5cd 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -2,6 +2,7 @@ use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, btree_map, btree_set}; use std::iter::{FilterMap, Flatten, Map}; +use crate::nodify::nodify_lines; use geo::Winding; use geo::orient::Direction; use geo::{ @@ -405,11 +406,342 @@ impl PolygonizerGraph { .collect(); MultiPolygon(remove_redundant_overlapping_standalone_polygons( - infer_parent_holes_when_output_has_no_holes(valid_polygons), + remove_non_unique_interior_points_for_touching_topology( + split_touching_boundary_polygons(infer_parent_holes_when_output_has_no_holes( + valid_polygons, + )), + ), )) } } +fn polygon_boundary_lines(polygon: &Polygon) -> Vec> { + let mut lines: Vec> = polygon.exterior().lines().collect(); + for hole in polygon.interiors() { + lines.extend(hole.lines()); + } + lines +} + +fn lines_have_same_endpoints(first: &Line, second: &Line) -> bool { + (first.start == second.start && first.end == second.end) + || (first.start == second.end && first.end == second.start) +} + +fn polygon_has_unique_boundary_segment( + polygons: &[Polygon], + polygon_index: usize, +) -> bool { + polygon_unique_boundary_segment_count(polygons, polygon_index) > 0 +} + +fn polygon_unique_boundary_segment_count( + polygons: &[Polygon], + polygon_index: usize, +) -> usize { + let polygon_lines = polygon_boundary_lines(&polygons[polygon_index]); + + polygon_lines + .iter() + .filter(|line| { + !polygons.iter().enumerate().any(|(other_index, other_polygon)| { + if other_index == polygon_index { + return false; + } + + polygon_boundary_lines(other_polygon) + .iter() + .any(|other_line| lines_have_same_endpoints(line, other_line)) + }) + }) + .count() +} + +fn remove_non_unique_interior_points_for_touching_topology( + polygons: Vec>, +) -> Vec> { + let mut current_polygons = polygons; + + loop { + let mut polygon_indices_to_remove: BTreeSet = BTreeSet::new(); + let unique_boundary_segment_count_by_index: Vec = (0..current_polygons.len()) + .map(|polygon_index| { + polygon_unique_boundary_segment_count(¤t_polygons, polygon_index) + }) + .collect(); + + for polygon_index in 0..current_polygons.len() { + let interior_point = match current_polygons[polygon_index].interior_point() { + Some(point) => point, + None => continue, + }; + + let containing_polygon_indices: Vec = current_polygons + .iter() + .enumerate() + .filter_map(|(candidate_index, candidate_polygon)| { + if candidate_polygon.contains(&interior_point) { + Some(candidate_index) + } else { + None + } + }) + .collect(); + + if containing_polygon_indices.len() <= 1 { + continue; + } + + let owner_index = containing_polygon_indices + .iter() + .copied() + .min_by(|left_index, right_index| { + unique_boundary_segment_count_by_index[*right_index] + .cmp(&unique_boundary_segment_count_by_index[*left_index]) + .then( + current_polygons[*left_index] + .unsigned_area() + .total_cmp(¤t_polygons[*right_index].unsigned_area()) + ) + .then(left_index.cmp(right_index)) + }) + .expect("owner index should exist when containing polygons are non-empty"); + + for candidate_index in containing_polygon_indices { + if candidate_index != owner_index { + let candidate_polygon = ¤t_polygons[candidate_index]; + let keep_same_exterior_plain_variant = candidate_polygon.interiors().is_empty() + && current_polygons.iter().enumerate().any( + |(other_index, other_polygon)| { + other_index != candidate_index + && !other_polygon.interiors().is_empty() + && other_polygon.exterior() == candidate_polygon.exterior() + }, + ); + if keep_same_exterior_plain_variant { + continue; + } + + polygon_indices_to_remove.insert(candidate_index); + } + } + } + + if polygon_indices_to_remove.is_empty() { + break; + } + + current_polygons = current_polygons + .into_iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon_indices_to_remove.contains(&polygon_index) { + None + } else { + Some(polygon) + } + }) + .collect(); + } + + current_polygons +} + +fn split_touching_boundary_polygons( + polygons: Vec>, +) -> Vec> { + polygons + .into_iter() + .flat_map(split_touching_boundary_polygon) + .collect() +} + +fn build_touching_boundary_graph( + polygon: &Polygon, +) -> PolygonizerGraph { + let mut boundary_lines: Vec<_> = polygon.exterior().lines().collect(); + for hole in polygon.interiors() { + boundary_lines.extend(hole.lines()); + } + + let split_snap_radius = + T::from(1e-10).unwrap_or(T::epsilon() * T::from(1024.0).unwrap_or(T::one())); + let noded_boundary_lines = nodify_lines(boundary_lines, split_snap_radius); + PolygonizerGraph::from_noded_lines(noded_boundary_lines) +} + +fn graph_has_touching_topology(graph: &PolygonizerGraph) -> bool { + graph + .nodes_to_outbound_edges + .values() + .any(|outbound_edges| outbound_edges.len() > 2) +} + +fn extract_face_candidates_inside_polygon( + boundary_graph: &PolygonizerGraph, + container_polygon: &Polygon, +) -> Vec> { + boundary_graph + .get_minimal_edge_rings() + .into_iter() + .filter_map(|ring| { + if ring.len() < 3 { + return None; + } + + let mut linestring: LineString = ring + .iter() + .map(|edge| coord! { x: edge.from.x, y: edge.from.y }) + .collect(); + linestring.close(); + + let face_polygon = Polygon::new(linestring, vec![]).orient(Direction::Default); + if !face_polygon.is_valid() { + return None; + } + + let interior_point = face_polygon.interior_point()?; + if !container_polygon.contains(&interior_point) { + return None; + } + + Some(face_polygon) + }) + .collect() +} + +fn deduplicate_faces_by_exterior(faces: Vec>) -> Vec> { + let mut deduplicated_faces: Vec> = Vec::new(); + for face in faces { + let already_present = deduplicated_faces + .iter() + .any(|existing| existing.exterior() == face.exterior()); + if !already_present { + deduplicated_faces.push(face); + } + } + deduplicated_faces +} + +fn prune_container_faces(faces: &[Polygon]) -> Vec> { + faces + .iter() + .enumerate() + .filter_map(|(face_index, face)| { + let contains_another_face = faces.iter().enumerate().any(|(other_face_index, other_face)| { + if face_index == other_face_index { + return false; + } + + let other_interior_point = match other_face.interior_point() { + Some(point) => point, + None => return false, + }; + + face.exterior() != other_face.exterior() && face.contains(&other_interior_point) + }); + + if contains_another_face { + None + } else { + Some(face.clone()) + } + }) + .collect() +} + +fn select_non_touching_holes(polygon: &Polygon) -> Vec> { + let mut kept_holes: Vec> = Vec::new(); + for hole in polygon.interiors() { + let mut candidate_holes = kept_holes.clone(); + candidate_holes.push(hole.clone()); + + let candidate_polygon = + Polygon::new(polygon.exterior().clone(), candidate_holes.clone()).orient(Direction::Default); + if !graph_has_touching_topology(&build_touching_boundary_graph(&candidate_polygon)) { + kept_holes = candidate_holes; + } + } + kept_holes +} + +fn split_no_hole_polygon_on_repeated_vertex( + polygon: &Polygon, +) -> Option>> { + if !polygon.interiors().is_empty() { + return None; + } + + let mut coordinates: Vec<_> = polygon.exterior().points().map(|point| point.0).collect(); + if coordinates.first() == coordinates.last() { + coordinates.pop(); + } + + if coordinates.len() < 4 { + return None; + } + + for first_index in 0..coordinates.len() { + for second_index in (first_index + 2)..coordinates.len() { + if first_index == 0 && second_index + 1 == coordinates.len() { + continue; + } + + if coordinates[first_index] != coordinates[second_index] { + continue; + } + + let first_ring_coords: Vec<_> = coordinates[first_index..=second_index].to_vec(); + + let mut second_ring_coords: Vec<_> = coordinates[second_index..].to_vec(); + second_ring_coords.extend_from_slice(&coordinates[..=first_index]); + + if first_ring_coords.len() < 4 || second_ring_coords.len() < 4 { + continue; + } + + let first_polygon = Polygon::new( + LineString::from(first_ring_coords), + vec![], + ) + .orient(Direction::Default); + let second_polygon = Polygon::new( + LineString::from(second_ring_coords), + vec![], + ) + .orient(Direction::Default); + + if first_polygon.is_valid() && second_polygon.is_valid() { + return Some(vec![first_polygon, second_polygon]); + } + } + } + + None +} + +fn split_touching_boundary_polygon( + polygon: Polygon, +) -> Vec> { + let boundary_graph = build_touching_boundary_graph(&polygon); + if !graph_has_touching_topology(&boundary_graph) { + return vec![polygon]; + } + + let face_candidates = extract_face_candidates_inside_polygon(&boundary_graph, &polygon); + let deduplicated_faces = deduplicate_faces_by_exterior(face_candidates); + let split_faces = prune_container_faces(&deduplicated_faces); + + if split_faces.len() >= 2 { + split_faces + } else if polygon.interiors().is_empty() { + split_no_hole_polygon_on_repeated_vertex(&polygon).unwrap_or_else(|| vec![polygon]) + } else { + let kept_holes = select_non_touching_holes(&polygon); + vec![Polygon::new(polygon.exterior().clone(), kept_holes).orient(Direction::Default)] + } +} + fn remove_redundant_overlapping_standalone_polygons( polygons: Vec>, ) -> Vec> { @@ -433,7 +765,7 @@ fn remove_redundant_overlapping_standalone_polygons( && other.contains(&interior_point) }); - if is_redundant_overlap { + if is_redundant_overlap && !polygon_has_unique_boundary_segment(&polygons, polygon_index) { None } else { Some(polygon.clone()) diff --git a/src/tests.rs b/src/tests.rs index 3e5ccb1..1152706 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use geo::{Contains, Line, LinesIter, MultiPolygon, Polygon, Validation, point}; +use geo::{Contains, InteriorPoint, Line, LinesIter, MultiPolygon, Polygon, Validation, point}; use geojson::GeometryValue; use super::*; @@ -240,7 +240,7 @@ fn canonicalize_multipolygon( polygons } -fn has_self_intersection(ring: &geo::LineString, epsilon: f64) -> bool { +fn ring_has_self_contact(ring: &geo::LineString, epsilon: f64) -> bool { fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) } @@ -255,7 +255,7 @@ fn has_self_intersection(ring: &geo::LineString, epsilon: f64) -> bool { between(p.0, a.0, b.0, epsilon) && between(p.1, a.1, b.1, epsilon) } - fn segments_intersect( + fn segments_contact( a1: (f64, f64), a2: (f64, f64), b1: (f64, f64), @@ -280,10 +280,6 @@ fn has_self_intersection(ring: &geo::LineString, epsilon: f64) -> bool { } let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); - if coordinates.len() < 4 { - return false; - } - if coordinates.first() == coordinates.last() { coordinates.pop(); } @@ -298,12 +294,9 @@ fn has_self_intersection(ring: &geo::LineString, epsilon: f64) -> bool { let first_end = coordinates[(first_segment_index + 1) % segment_count]; for second_segment_index in (first_segment_index + 1)..segment_count { - if second_segment_index == first_segment_index { - continue; - } - let first_next_index = (first_segment_index + 1) % segment_count; let second_next_index = (second_segment_index + 1) % segment_count; + let are_adjacent = second_segment_index == first_next_index || first_segment_index == second_next_index; if are_adjacent { @@ -317,7 +310,7 @@ fn has_self_intersection(ring: &geo::LineString, epsilon: f64) -> bool { let second_start = coordinates[second_segment_index]; let second_end = coordinates[second_next_index]; - if segments_intersect(first_start, first_end, second_start, second_end, epsilon) { + if segments_contact(first_start, first_end, second_start, second_end, epsilon) { return true; } } @@ -326,7 +319,7 @@ fn has_self_intersection(ring: &geo::LineString, epsilon: f64) -> bool { false } -fn ring_has_boundary_contact( +fn ring_pair_has_contact( ring: &geo::LineString, other_ring: &geo::LineString, epsilon: f64, @@ -370,10 +363,8 @@ fn ring_has_boundary_contact( } let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); - let mut other_coordinates: Vec<(f64, f64)> = other_ring - .points() - .map(|point| (point.x(), point.y())) - .collect(); + let mut other_coordinates: Vec<(f64, f64)> = + other_ring.points().map(|point| (point.x(), point.y())).collect(); if coordinates.first() == coordinates.last() { coordinates.pop(); @@ -403,30 +394,32 @@ fn ring_has_boundary_contact( false } -fn polygon_has_boundary_contact(polygon: &Polygon, epsilon: f64) -> bool { - if has_self_intersection(polygon.exterior(), epsilon) - || polygon - .interiors() - .iter() - .any(|ring| has_self_intersection(ring, epsilon)) - { +fn polygon_has_pinch_contact(polygon: &Polygon, epsilon: f64) -> bool { + if ring_has_self_contact(polygon.exterior(), epsilon) { return true; } - let interiors = polygon.interiors(); + if polygon + .interiors() + .iter() + .any(|hole| ring_has_self_contact(hole, epsilon)) + { + return true; + } - if interiors + if polygon + .interiors() .iter() - .any(|interior| ring_has_boundary_contact(polygon.exterior(), interior, epsilon)) + .any(|hole| ring_pair_has_contact(polygon.exterior(), hole, epsilon)) { return true; } - for first_hole_index in 0..interiors.len() { - for second_hole_index in (first_hole_index + 1)..interiors.len() { - if ring_has_boundary_contact( - &interiors[first_hole_index], - &interiors[second_hole_index], + for first_hole_index in 0..polygon.interiors().len() { + for second_hole_index in (first_hole_index + 1)..polygon.interiors().len() { + if ring_pair_has_contact( + &polygon.interiors()[first_hole_index], + &polygon.interiors()[second_hole_index], epsilon, ) { return true; @@ -437,6 +430,96 @@ fn polygon_has_boundary_contact(polygon: &Polygon, epsilon: f64) -> bool { false } +fn lines_have_positive_collinear_overlap(first: &Line, second: &Line, epsilon: f64) -> bool { + fn cross(a: (f64, f64), b: (f64, f64)) -> f64 { + a.0 * b.1 - a.1 * b.0 + } + + let first_direction = (first.end.x - first.start.x, first.end.y - first.start.y); + let second_direction = (second.end.x - second.start.x, second.end.y - second.start.y); + + if first_direction.0.abs() <= epsilon && first_direction.1.abs() <= epsilon { + return false; + } + + if cross(first_direction, second_direction).abs() > epsilon { + return false; + } + + let second_start_offset = (second.start.x - first.start.x, second.start.y - first.start.y); + let second_end_offset = (second.end.x - first.start.x, second.end.y - first.start.y); + + if cross(first_direction, second_start_offset).abs() > epsilon + || cross(first_direction, second_end_offset).abs() > epsilon + { + return false; + } + + let use_x_axis = first_direction.0.abs() >= first_direction.1.abs(); + let (first_min, first_max, second_min, second_max) = if use_x_axis { + ( + first.start.x.min(first.end.x), + first.start.x.max(first.end.x), + second.start.x.min(second.end.x), + second.start.x.max(second.end.x), + ) + } else { + ( + first.start.y.min(first.end.y), + first.start.y.max(first.end.y), + second.start.y.min(second.end.y), + second.start.y.max(second.end.y), + ) + }; + + let overlap_min = first_min.max(second_min); + let overlap_max = first_max.min(second_max); + overlap_max - overlap_min > epsilon +} + +fn point_is_on_line_segment(point: geo::Coord, line: &Line, epsilon: f64) -> bool { + fn cross(a: (f64, f64), b: (f64, f64)) -> f64 { + a.0 * b.1 - a.1 * b.0 + } + + let direction = (line.end.x - line.start.x, line.end.y - line.start.y); + let offset = (point.x - line.start.x, point.y - line.start.y); + let length_squared = direction.0 * direction.0 + direction.1 * direction.1; + + if length_squared <= epsilon * epsilon { + return false; + } + + if cross(direction, offset).abs() > epsilon { + return false; + } + + let projection = (offset.0 * direction.0 + offset.1 * direction.1) / length_squared; + projection >= -epsilon && projection <= 1.0 + epsilon +} + +fn line_is_represented_on_output_boundary( + input_line: &Line, + boundary_lines: &[Line], + epsilon: f64, +) -> bool { + if boundary_lines + .iter() + .any(|boundary_line| lines_have_positive_collinear_overlap(input_line, boundary_line, epsilon)) + { + return true; + } + + let start_on_boundary = boundary_lines + .iter() + .any(|boundary_line| point_is_on_line_segment(input_line.start, boundary_line, epsilon)); + let end_on_boundary = boundary_lines + .iter() + .any(|boundary_line| point_is_on_line_segment(input_line.end, boundary_line, epsilon)); + + start_on_boundary && end_on_boundary +} + fn assert_polygonize_fixture(name: &str) { let input_lines = load_input_lines(name); let actual = polygonize(input_lines); @@ -581,7 +664,7 @@ fn polygonize_venn_overlaps_split_into_distinct_regions() { } #[test] -fn debug_missing_hole() { +fn polygonize_failed_hole_probe_point_has_single_owner() { let lines = load_input_lines("failed_hole"); let polygons = polygonize(lines.into_iter()); @@ -592,47 +675,78 @@ fn debug_missing_hole() { } #[test] -fn debug_missing_hole_again() { - let lines = load_input_lines("debug_missing_hole_again"); - let polygons = polygonize(lines.into_iter()); - - let test_point = point! { x:5.9, y: 39.0 }; - let num_polygons_containing_point = polygons.iter().filter(|poly| poly.contains(&test_point)).count(); - - assert_eq!(num_polygons_containing_point, 1); - - let test_point2 = point! { x:1.25, y: 39.5 }; - let num_polygons_containing_point2 = polygons.iter().filter(|poly| poly.contains(&test_point2)).count(); +fn polygonize_touching_hole_overlap_ownership_probe_matches_fixture() { + assert_polygonize_fixture("touching_hole_overlap_ownership_probe"); +} - assert_eq!(num_polygons_containing_point2, 1); +#[test] +fn polygonize_touching_hole_overlap_ownership_minimal_matches_fixture() { + assert_polygonize_fixture("touching_hole_overlap_ownership_minimal"); } #[test] -fn debug_missing_hole_again_again_minimal() { - let lines = load_input_lines("debug_missing_hole_again_again_minimal"); - let polygons = polygonize(lines.into_iter()); +fn polygonize_touching_hole_overlap_ownership_minimal_invariants() { + let lines = load_input_lines("touching_hole_overlap_ownership_minimal"); + let polygons = polygonize(lines.clone().into_iter()); + + let mut output_boundary_lines: Vec> = Vec::new(); + for polygon in polygons.iter() { + output_boundary_lines.extend(polygon.exterior().lines()); + for hole in polygon.interiors() { + output_boundary_lines.extend(hole.lines()); + } + } - let test_point = point! { x:110.35, y: 20.2 }; - let num_polygons_containing_point = polygons.iter().filter(|poly| poly.contains(&test_point)).count(); + for (polygon_index, polygon) in polygons.iter().enumerate() { + let interior_point = polygon + .interior_point() + .unwrap_or_else(|| panic!("polygon {polygon_index} has no interior point")); - assert_eq!(num_polygons_containing_point, 1); -} + let containing_indices: Vec<_> = polygons + .iter() + .enumerate() + .filter_map(|(candidate_index, candidate)| { + if candidate.contains(&interior_point) { + Some(candidate_index) + } else { + None + } + }) + .collect(); -#[test] -fn debug_missing_hole_again_again() { - let lines = load_input_lines("produces_overlapping_polygons"); - let polygons = polygonize(lines.into_iter()); + let containing_count = containing_indices.len(); + + assert_eq!( + containing_count, 1, + "polygon {polygon_index} interior point is contained by {containing_count} polygons" + ); - let test_point = point! { x:110.35, y: 20.2 }; - let polygons_containing_point: Vec<_> = polygons.iter().filter(|poly| poly.contains(&test_point)).collect(); + assert!( + !polygon_has_pinch_contact(polygon, 0.0), + "polygon {polygon_index} has pinch/touch boundary contact" + ); + } - assert_eq!(polygons_containing_point.len(), 1); + for (line_index, input_line) in lines.iter().enumerate() { + let dx = input_line.end.x - input_line.start.x; + let dy = input_line.end.y - input_line.start.y; + if dx.abs() <= 1e-12 && dy.abs() <= 1e-12 { + continue; + } - let has_boundary_contact = polygon_has_boundary_contact(polygons_containing_point[0], 1e-10); - assert!( - has_boundary_contact, - "expected boundary contact (pinch/touch) in containing polygon" - ); + let appears_on_output_boundary = line_is_represented_on_output_boundary( + input_line, + &output_boundary_lines, + 1e-10, + ); + + assert!( + appears_on_output_boundary, + "input line {line_index} ({:?} -> {:?}) is not represented on output polygon boundaries", + input_line.start, + input_line.end + ); + } } #[test] From fee5a12e80bc96264d590e34a856a35d80d12ae1 Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Fri, 17 Apr 2026 22:33:46 -0400 Subject: [PATCH 06/12] Latest round of fixes for degenerate geometry --- .../polygonizer/input/buggy_outlines.geojson | 1 + ...ing_hole_overlap_ownership_minimal.geojson | 18 ++ ...ching_hole_overlap_ownership_probe.geojson | 18 ++ ...ing_hole_overlap_ownership_minimal.geojson | 176 ++++++++++++++++++ ...ching_hole_overlap_ownership_probe.geojson | 176 ++++++++++++++++++ src/graph.rs | 97 +++++----- src/tests.rs | 48 +++-- 7 files changed, 469 insertions(+), 65 deletions(-) create mode 100644 fixtures/polygonizer/input/buggy_outlines.geojson create mode 100644 fixtures/polygonizer/input/touching_hole_overlap_ownership_minimal.geojson create mode 100644 fixtures/polygonizer/input/touching_hole_overlap_ownership_probe.geojson create mode 100644 fixtures/polygonizer/output/touching_hole_overlap_ownership_minimal.geojson create mode 100644 fixtures/polygonizer/output/touching_hole_overlap_ownership_probe.geojson diff --git a/fixtures/polygonizer/input/buggy_outlines.geojson b/fixtures/polygonizer/input/buggy_outlines.geojson new file mode 100644 index 0000000..f66f955 --- /dev/null +++ b/fixtures/polygonizer/input/buggy_outlines.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[66.917152079,-49.483609955],[67.076757868,-49.04184243]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[66.917152079,-49.483609955],[68.49003423,-50.548027294]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[67.076757868,-49.04184243],[68.698823473,-48.144678001]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[67.96072344674927,-49.21812840419421],[68.03560388863696,-49.526937060777]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[67.96072344674927,-49.21812840419421],[68.03560388863696,-48.90931974761142]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.03560388863696,-49.526937060777],[68.25291539492534,-49.80551737445704]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.03560388863696,-48.90931974761142],[68.25291539492534,-48.63073943393138]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.25291539492534,-49.80551737445704],[68.5913860012806,-50.02659996314815]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.25291539492534,-48.63073943393138],[68.5913860012806,-48.40965684524027]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.49003423,-50.548027294],[69.861979006,-50.549689302]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.5913860012806,-50.02659996314815],[69.01788384648931,-50.16854372269627]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.5913860012806,-48.40965684524027],[69.01788384648931,-48.26771308569215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.698823473,-48.144678001],[70.997703438,-47.154869247]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.01788384648931,-50.16854372269627],[69.49066034987723,-50.21745420893651]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.01788384648931,-48.26771308569215],[69.49066034987723,-48.21880259945191]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.49066034987723,-50.21745420893651],[69.96343685326515,-50.16854372269627]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.49066034987723,-48.21880259945191],[69.96343685326515,-48.26771308569215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.861979006,-50.549689302],[70.26289097957036,-50.06888168459346]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.96343685326515,-50.16854372269627],[70.26289097957036,-50.06888168459346]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.96343685326515,-48.26771308569215],[70.38993469847387,-48.40965684524027]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.26289097957036,-50.06888168459346],[70.38993469847387,-50.02659996314815]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.26289097957036,-50.06888168459346],[71.00971073191234,-49.17323214198215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.38993469847387,-50.02659996314815],[70.72840530482912,-49.80551737445704]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.38993469847387,-48.40965684524027],[70.72840530482912,-48.63073943393138]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.72840530482912,-49.80551737445704],[70.9457168111175,-49.526937060777]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.72840530482912,-48.63073943393138],[70.9457168111175,-48.90931974761142]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.9457168111175,-49.526937060777],[71.02059725300519,-49.21812840419421]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.9457168111175,-48.90931974761142],[71.00971073191234,-49.17323214198215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.997703438,-47.154869247],[71.638389864,-47.695087312]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[71.00971073191234,-49.17323214198215],[71.02059725300519,-49.21812840419421]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[71.00971073191234,-49.17323214198215],[71.09668316,-49.068927435]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[71.09668316,-49.068927435],[71.638389864,-47.695087312]]},"properties":null}]} \ No newline at end of file diff --git a/fixtures/polygonizer/input/touching_hole_overlap_ownership_minimal.geojson b/fixtures/polygonizer/input/touching_hole_overlap_ownership_minimal.geojson new file mode 100644 index 0000000..38c60a5 --- /dev/null +++ b/fixtures/polygonizer/input/touching_hole_overlap_ownership_minimal.geojson @@ -0,0 +1,18 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [[90.0, 10.0], [130.0, 10.0], [130.0, 30.0], [90.0, 30.0], [90.0, 10.0]], + [[100.0, 16.0], [122.0, 16.0], [122.0, 26.0], [100.0, 26.0], [100.0, 16.0]], + [[108.0, 18.0], [113.0, 18.0], [113.0, 22.0], [108.0, 22.0], [108.0, 18.0]], + [[115.0, 22.0], [119.0, 22.0], [119.0, 25.0], [115.0, 25.0], [115.0, 22.0]] + ] + } + } + ] +} diff --git a/fixtures/polygonizer/input/touching_hole_overlap_ownership_probe.geojson b/fixtures/polygonizer/input/touching_hole_overlap_ownership_probe.geojson new file mode 100644 index 0000000..a702828 --- /dev/null +++ b/fixtures/polygonizer/input/touching_hole_overlap_ownership_probe.geojson @@ -0,0 +1,18 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [[-6.0, 30.0], [36.0, 30.0], [36.0, 46.0], [-6.0, 46.0], [-6.0, 30.0]], + [[-2.0, 36.0], [15.0, 36.0], [15.0, 43.5], [-2.0, 43.5], [-2.0, 36.0]], + [[3.5, 37.5], [8.5, 37.5], [8.5, 40.5], [3.5, 40.5], [3.5, 37.5]], + [[10.0, 40.8], [13.5, 40.8], [13.5, 42.8], [10.0, 42.8], [10.0, 40.8]] + ] + } + } + ] +} diff --git a/fixtures/polygonizer/output/touching_hole_overlap_ownership_minimal.geojson b/fixtures/polygonizer/output/touching_hole_overlap_ownership_minimal.geojson new file mode 100644 index 0000000..3ceff48 --- /dev/null +++ b/fixtures/polygonizer/output/touching_hole_overlap_ownership_minimal.geojson @@ -0,0 +1,176 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 90.0, + 10.0 + ], + [ + 130.0, + 10.0 + ], + [ + 130.0, + 30.0 + ], + [ + 90.0, + 30.0 + ], + [ + 90.0, + 10.0 + ] + ], + [ + [ + 100.0, + 16.0 + ], + [ + 100.0, + 26.0 + ], + [ + 122.0, + 26.0 + ], + [ + 122.0, + 16.0 + ], + [ + 100.0, + 16.0 + ] + ] + ], + [ + [ + [ + 100.0, + 16.0 + ], + [ + 122.0, + 16.0 + ], + [ + 122.0, + 26.0 + ], + [ + 100.0, + 26.0 + ], + [ + 100.0, + 16.0 + ] + ], + [ + [ + 108.0, + 18.0 + ], + [ + 108.0, + 22.0 + ], + [ + 113.0, + 22.0 + ], + [ + 113.0, + 18.0 + ], + [ + 108.0, + 18.0 + ] + ], + [ + [ + 115.0, + 22.0 + ], + [ + 115.0, + 25.0 + ], + [ + 119.0, + 25.0 + ], + [ + 119.0, + 22.0 + ], + [ + 115.0, + 22.0 + ] + ] + ], + [ + [ + [ + 108.0, + 18.0 + ], + [ + 113.0, + 18.0 + ], + [ + 113.0, + 22.0 + ], + [ + 108.0, + 22.0 + ], + [ + 108.0, + 18.0 + ] + ] + ], + [ + [ + [ + 115.0, + 22.0 + ], + [ + 119.0, + 22.0 + ], + [ + 119.0, + 25.0 + ], + [ + 115.0, + 25.0 + ], + [ + 115.0, + 22.0 + ] + ] + ] + ] + }, + "properties": null + } + ] +} diff --git a/fixtures/polygonizer/output/touching_hole_overlap_ownership_probe.geojson b/fixtures/polygonizer/output/touching_hole_overlap_ownership_probe.geojson new file mode 100644 index 0000000..96acc83 --- /dev/null +++ b/fixtures/polygonizer/output/touching_hole_overlap_ownership_probe.geojson @@ -0,0 +1,176 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + -6.0, + 30.0 + ], + [ + 36.0, + 30.0 + ], + [ + 36.0, + 46.0 + ], + [ + -6.0, + 46.0 + ], + [ + -6.0, + 30.0 + ] + ], + [ + [ + -2.0, + 36.0 + ], + [ + -2.0, + 43.5 + ], + [ + 15.0, + 43.5 + ], + [ + 15.0, + 36.0 + ], + [ + -2.0, + 36.0 + ] + ] + ], + [ + [ + [ + -2.0, + 36.0 + ], + [ + 15.0, + 36.0 + ], + [ + 15.0, + 43.5 + ], + [ + -2.0, + 43.5 + ], + [ + -2.0, + 36.0 + ] + ], + [ + [ + 3.5, + 37.5 + ], + [ + 3.5, + 40.5 + ], + [ + 8.5, + 40.5 + ], + [ + 8.5, + 37.5 + ], + [ + 3.5, + 37.5 + ] + ], + [ + [ + 10.0, + 40.8 + ], + [ + 10.0, + 42.8 + ], + [ + 13.5, + 42.8 + ], + [ + 13.5, + 40.8 + ], + [ + 10.0, + 40.8 + ] + ] + ], + [ + [ + [ + 3.5, + 37.5 + ], + [ + 8.5, + 37.5 + ], + [ + 8.5, + 40.5 + ], + [ + 3.5, + 40.5 + ], + [ + 3.5, + 37.5 + ] + ] + ], + [ + [ + [ + 10.0, + 40.8 + ], + [ + 13.5, + 40.8 + ], + [ + 13.5, + 42.8 + ], + [ + 10.0, + 42.8 + ], + [ + 10.0, + 40.8 + ] + ] + ] + ] + }, + "properties": null + } + ] +} \ No newline at end of file diff --git a/src/graph.rs b/src/graph.rs index 262a5cd..93a141a 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -130,7 +130,8 @@ impl PolygonizerGraph { edge_to_index: &BTreeMap, usize>, edge_count: usize, ) -> Vec> { - let mut next_left_face_edge_index_by_edge_index: Vec> = vec![None; edge_count]; + let mut next_left_face_edge_index_by_edge_index: Vec> = + vec![None; edge_count]; for outbound_edges_at_node in self.nodes_to_outbound_edges.values() { let ordered_outgoing_edges: Vec<_> = outbound_edges_at_node.iter().collect(); @@ -142,8 +143,8 @@ impl PolygonizerGraph { for edge_index in 0..ordered_edge_count { let reverse_edge = *ordered_outgoing_edges[edge_index]; let incoming_edge = reverse_edge.get_symmetrical(); - let next_outgoing_edge = - *ordered_outgoing_edges[(edge_index + ordered_edge_count - 1) % ordered_edge_count]; + let next_outgoing_edge = *ordered_outgoing_edges + [(edge_index + ordered_edge_count - 1) % ordered_edge_count]; let incoming_edge_index = *edge_to_index .get(&incoming_edge) @@ -176,7 +177,8 @@ impl PolygonizerGraph { let Some(next_edge_index) = next_edge_index else { continue; }; - next_left_face_edge_by_edge.insert(edges_by_index[edge_index], edges_by_index[next_edge_index]); + next_left_face_edge_by_edge + .insert(edges_by_index[edge_index], edges_by_index[next_edge_index]); } next_left_face_edge_by_edge @@ -444,15 +446,18 @@ fn polygon_unique_boundary_segment_count( polygon_lines .iter() .filter(|line| { - !polygons.iter().enumerate().any(|(other_index, other_polygon)| { - if other_index == polygon_index { - return false; - } + !polygons + .iter() + .enumerate() + .any(|(other_index, other_polygon)| { + if other_index == polygon_index { + return false; + } - polygon_boundary_lines(other_polygon) - .iter() - .any(|other_line| lines_have_same_endpoints(line, other_line)) - }) + polygon_boundary_lines(other_polygon) + .iter() + .any(|other_line| lines_have_same_endpoints(line, other_line)) + }) }) .count() } @@ -499,9 +504,9 @@ fn remove_non_unique_interior_points_for_touching_topology(faces: &[Polygon]) -> Vec> { .iter() .enumerate() .filter_map(|(face_index, face)| { - let contains_another_face = faces.iter().enumerate().any(|(other_face_index, other_face)| { - if face_index == other_face_index { - return false; - } + let contains_another_face = + faces + .iter() + .enumerate() + .any(|(other_face_index, other_face)| { + if face_index == other_face_index { + return false; + } - let other_interior_point = match other_face.interior_point() { - Some(point) => point, - None => return false, - }; + let other_interior_point = match other_face.interior_point() { + Some(point) => point, + None => return false, + }; - face.exterior() != other_face.exterior() && face.contains(&other_interior_point) - }); + face.exterior() != other_face.exterior() + && face.contains(&other_interior_point) + }); if contains_another_face { None @@ -650,14 +660,16 @@ fn prune_container_faces(faces: &[Polygon]) -> Vec> { .collect() } -fn select_non_touching_holes(polygon: &Polygon) -> Vec> { +fn select_non_touching_holes( + polygon: &Polygon, +) -> Vec> { let mut kept_holes: Vec> = Vec::new(); for hole in polygon.interiors() { let mut candidate_holes = kept_holes.clone(); candidate_holes.push(hole.clone()); - let candidate_polygon = - Polygon::new(polygon.exterior().clone(), candidate_holes.clone()).orient(Direction::Default); + let candidate_polygon = Polygon::new(polygon.exterior().clone(), candidate_holes.clone()) + .orient(Direction::Default); if !graph_has_touching_topology(&build_touching_boundary_graph(&candidate_polygon)) { kept_holes = candidate_holes; } @@ -700,16 +712,10 @@ fn split_no_hole_polygon_on_repeated_vertex( continue; } - let first_polygon = Polygon::new( - LineString::from(first_ring_coords), - vec![], - ) - .orient(Direction::Default); - let second_polygon = Polygon::new( - LineString::from(second_ring_coords), - vec![], - ) - .orient(Direction::Default); + let first_polygon = Polygon::new(LineString::from(first_ring_coords), vec![]) + .orient(Direction::Default); + let second_polygon = Polygon::new(LineString::from(second_ring_coords), vec![]) + .orient(Direction::Default); if first_polygon.is_valid() && second_polygon.is_valid() { return Some(vec![first_polygon, second_polygon]); @@ -765,7 +771,9 @@ fn remove_redundant_overlapping_standalone_polygons( && other.contains(&interior_point) }); - if is_redundant_overlap && !polygon_has_unique_boundary_segment(&polygons, polygon_index) { + if is_redundant_overlap + && !polygon_has_unique_boundary_segment(&polygons, polygon_index) + { None } else { Some(polygon.clone()) @@ -790,7 +798,8 @@ fn infer_parent_holes_when_output_has_no_holes( } let parent_envelope = polygon_envelopes[parent_polygon_index]; - if parent_envelope == child_envelope || !parent_envelope.contains_envelope(&child_envelope) + if parent_envelope == child_envelope + || !parent_envelope.contains_envelope(&child_envelope) { continue; } @@ -828,10 +837,8 @@ fn infer_parent_holes_when_output_has_no_holes( }; let parent_exterior = polygons[parent_polygon_index].exterior(); - let has_same_exterior_polygon_with_explicit_holes = polygons - .iter() - .enumerate() - .any(|(polygon_index, polygon)| { + let has_same_exterior_polygon_with_explicit_holes = + polygons.iter().enumerate().any(|(polygon_index, polygon)| { polygon_index != parent_polygon_index && polygon.exterior() == parent_exterior && !polygon.interiors().is_empty() @@ -1025,5 +1032,3 @@ fn assign_shells_to_holes( polygons } - - diff --git a/src/tests.rs b/src/tests.rs index 1152706..5b170ab 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -279,7 +279,8 @@ fn ring_has_self_contact(ring: &geo::LineString, epsilon: f64) -> bool { || (o4.abs() <= epsilon && on_segment(b1, b2, a2, epsilon)) } - let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); + let mut coordinates: Vec<(f64, f64)> = + ring.points().map(|point| (point.x(), point.y())).collect(); if coordinates.first() == coordinates.last() { coordinates.pop(); } @@ -362,9 +363,12 @@ fn ring_pair_has_contact( || (o4.abs() <= epsilon && on_segment(b1, b2, a2, epsilon)) } - let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); - let mut other_coordinates: Vec<(f64, f64)> = - other_ring.points().map(|point| (point.x(), point.y())).collect(); + let mut coordinates: Vec<(f64, f64)> = + ring.points().map(|point| (point.x(), point.y())).collect(); + let mut other_coordinates: Vec<(f64, f64)> = other_ring + .points() + .map(|point| (point.x(), point.y())) + .collect(); if coordinates.first() == coordinates.last() { coordinates.pop(); @@ -383,7 +387,8 @@ fn ring_pair_has_contact( for second_segment_index in 0..other_coordinates.len() { let second_start = other_coordinates[second_segment_index]; - let second_end = other_coordinates[(second_segment_index + 1) % other_coordinates.len()]; + let second_end = + other_coordinates[(second_segment_index + 1) % other_coordinates.len()]; if segments_contact(first_start, first_end, second_start, second_end, epsilon) { return true; @@ -430,7 +435,11 @@ fn polygon_has_pinch_contact(polygon: &Polygon, epsilon: f64) -> bool { false } -fn lines_have_positive_collinear_overlap(first: &Line, second: &Line, epsilon: f64) -> bool { +fn lines_have_positive_collinear_overlap( + first: &Line, + second: &Line, + epsilon: f64, +) -> bool { fn cross(a: (f64, f64), b: (f64, f64)) -> f64 { a.0 * b.1 - a.1 * b.0 } @@ -446,7 +455,10 @@ fn lines_have_positive_collinear_overlap(first: &Line, second: &Line, return false; } - let second_start_offset = (second.start.x - first.start.x, second.start.y - first.start.y); + let second_start_offset = ( + second.start.x - first.start.x, + second.start.y - first.start.y, + ); let second_end_offset = (second.end.x - first.start.x, second.end.y - first.start.y); if cross(first_direction, second_start_offset).abs() > epsilon @@ -503,10 +515,9 @@ fn line_is_represented_on_output_boundary( boundary_lines: &[Line], epsilon: f64, ) -> bool { - if boundary_lines - .iter() - .any(|boundary_line| lines_have_positive_collinear_overlap(input_line, boundary_line, epsilon)) - { + if boundary_lines.iter().any(|boundary_line| { + lines_have_positive_collinear_overlap(input_line, boundary_line, epsilon) + }) { return true; } @@ -669,7 +680,10 @@ fn polygonize_failed_hole_probe_point_has_single_owner() { let polygons = polygonize(lines.into_iter()); let test_point = point! { x:131.85, y: 37.25 }; - let num_polygons_containing_point = polygons.iter().filter(|poly| poly.contains(&test_point)).count(); + let num_polygons_containing_point = polygons + .iter() + .filter(|poly| poly.contains(&test_point)) + .count(); assert_eq!(num_polygons_containing_point, 1); } @@ -734,17 +748,13 @@ fn polygonize_touching_hole_overlap_ownership_minimal_invariants() { continue; } - let appears_on_output_boundary = line_is_represented_on_output_boundary( - input_line, - &output_boundary_lines, - 1e-10, - ); + let appears_on_output_boundary = + line_is_represented_on_output_boundary(input_line, &output_boundary_lines, 1e-10); assert!( appears_on_output_boundary, "input line {line_index} ({:?} -> {:?}) is not represented on output polygon boundaries", - input_line.start, - input_line.end + input_line.start, input_line.end ); } } From 0eb79589ecbb3ca0ba4b6c81d5639f329912c81e Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Fri, 17 Apr 2026 22:52:33 -0400 Subject: [PATCH 07/12] refactor everything --- src/graph.rs | 957 +++++++--------------------------- src/graph/lines_iter.rs | 41 ++ src/graph/shell_assignment.rs | 125 +++++ src/graph/topology_cleanup.rs | 465 +++++++++++++++++ src/lib.rs | 5 + src/nodify.rs | 6 + 6 files changed, 844 insertions(+), 755 deletions(-) create mode 100644 src/graph/lines_iter.rs create mode 100644 src/graph/shell_assignment.rs create mode 100644 src/graph/topology_cleanup.rs diff --git a/src/graph.rs b/src/graph.rs index 93a141a..f1afd41 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -1,15 +1,22 @@ +//! Planar graph representation and polygon extraction pipeline. +//! +//! This module stores noded linework as directed edges sorted in CCW order per +//! origin node, then traverses left faces to assemble minimal rings. + use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet, btree_map, btree_set}; -use std::iter::{FilterMap, Flatten, Map}; +use std::collections::{BTreeMap, BTreeSet}; -use crate::nodify::nodify_lines; use geo::Winding; -use geo::orient::Direction; -use geo::{ - Area, Contains, GeoFloat, InteriorPoint, Line, LineString, LinesIter, MultiPolygon, Orient, - Polygon, Validation, coord, -}; -use rstar::{Envelope, RTreeObject}; +use geo::{GeoFloat, Line, LineString, MultiPolygon, Polygon, Validation, coord}; + +mod lines_iter; +mod shell_assignment; +mod topology_cleanup; + +use shell_assignment::assign_shells_to_holes; + +type EdgeIndex = usize; +type FaceLabel = usize; #[derive(Copy, Clone, Debug)] pub(crate) struct Node { @@ -100,12 +107,160 @@ impl Edge { } } +fn line_endpoints_to_nodes(line: Line) -> (Node, Node) { + ( + Node { + x: line.start.x, + y: line.start.y, + }, + Node { + x: line.end.x, + y: line.end.y, + }, + ) +} + +fn ring_to_valid_linestring(ring: &[Edge]) -> Option> { + if ring.len() < 3 { + return None; + } + + let mut linestring: LineString = ring + .iter() + .map(|edge| coord! { x: edge.from.x, y: edge.from.y }) + .collect(); + linestring.close(); + let polygon = Polygon::new(linestring, Default::default()); + if !polygon.is_valid() { + return None; + } + + let (linestring, _) = polygon.into_inner(); + Some(linestring) +} + #[derive(Debug)] pub(crate) struct PolygonizerGraph { nodes_to_outbound_edges: BTreeMap, BTreeSet>>, } impl PolygonizerGraph { + fn insert_undirected_edge( + nodes_to_outbound_edges: &mut BTreeMap, BTreeSet>>, + start_node: Node, + end_node: Node, + ) { + nodes_to_outbound_edges + .entry(start_node) + .or_insert_with(BTreeSet::new) + .insert(Edge { + from: start_node, + to: end_node, + }); + nodes_to_outbound_edges + .entry(end_node) + .or_insert_with(BTreeSet::new) + .insert(Edge { + from: end_node, + to: start_node, + }); + } + + fn compute_face_label_by_edge_index( + edge_count: usize, + next_left_face_edge_index_by_edge_index: &[Option], + ) -> Vec> { + let mut face_label_by_edge_index: Vec> = vec![None; edge_count]; + let mut next_face_label: FaceLabel = 0; + + for start_edge_index in 0..edge_count { + if face_label_by_edge_index[start_edge_index].is_some() { + continue; + } + + let mut traversal_path: Vec = Vec::new(); + let mut visit_position_by_edge_index: BTreeMap = BTreeMap::new(); + let mut current_edge_index = start_edge_index; + + loop { + if let Some(existing_face_label) = face_label_by_edge_index[current_edge_index] { + for traversed_edge_index in traversal_path { + face_label_by_edge_index[traversed_edge_index] = Some(existing_face_label); + } + break; + } + + if let Some(&cycle_start_position) = + visit_position_by_edge_index.get(¤t_edge_index) + { + let face_label = next_face_label; + next_face_label += 1; + + for &traversed_edge_index in &traversal_path[cycle_start_position..] { + face_label_by_edge_index[traversed_edge_index] = Some(face_label); + } + for &traversed_edge_index in &traversal_path[..cycle_start_position] { + face_label_by_edge_index[traversed_edge_index] = Some(face_label); + } + break; + } + + visit_position_by_edge_index.insert(current_edge_index, traversal_path.len()); + traversal_path.push(current_edge_index); + + current_edge_index = + match next_left_face_edge_index_by_edge_index[current_edge_index] { + Some(next_edge_index) => next_edge_index, + None => break, + }; + } + } + + face_label_by_edge_index + } + + fn collect_undirected_cut_edges( + edges_by_index: &[Edge], + edge_to_index: &BTreeMap, EdgeIndex>, + face_label_by_edge_index: &[Option], + ) -> BTreeSet<(Node, Node)> { + let mut undirected_edges_to_remove: BTreeSet<(Node, Node)> = BTreeSet::new(); + for (edge_index, edge) in edges_by_index.iter().enumerate() { + if edge.from > edge.to { + continue; + } + + let symmetric_edge_index = *edge_to_index + .get(&edge.get_symmetrical()) + .expect("symmetric edge index should exist"); + + if face_label_by_edge_index[edge_index] + == face_label_by_edge_index[symmetric_edge_index] + { + undirected_edges_to_remove.insert((edge.from, edge.to)); + } + } + undirected_edges_to_remove + } + + fn remove_undirected_edges( + &mut self, + undirected_edges_to_remove: &BTreeSet<(Node, Node)>, + ) { + self.nodes_to_outbound_edges + .retain(|_, outbound_edges_at_node| { + outbound_edges_at_node.retain(|edge| { + let undirected_edge = if edge.from <= edge.to { + (edge.from, edge.to) + } else { + (edge.to, edge.from) + }; + !undirected_edges_to_remove.contains(&undirected_edge) + }); + !outbound_edges_at_node.is_empty() + }); + } + fn get_edges_with_index_map(&self) -> (Vec>, BTreeMap, usize>) { let mut edge_to_index: BTreeMap, usize> = BTreeMap::new(); let mut edges_by_index: Vec> = Vec::new(); @@ -184,33 +339,15 @@ impl PolygonizerGraph { next_left_face_edge_by_edge } + /// Builds a directed graph from already-noded linework. + /// + /// Each undirected segment is represented as two directed edges with opposite directions. pub(crate) fn from_noded_lines(lines: impl IntoIterator>) -> Self { let mut nodes_to_outbound_edges = BTreeMap::new(); for line in lines.into_iter() { - let start_node = Node { - x: line.start.x, - y: line.start.y, - }; - let end_node = Node { - x: line.end.x, - y: line.end.y, - }; - - nodes_to_outbound_edges - .entry(start_node) - .or_insert_with(|| BTreeSet::new()) - .insert(Edge { - from: start_node, - to: end_node, - }); - nodes_to_outbound_edges - .entry(end_node) - .or_insert_with(|| BTreeSet::new()) - .insert(Edge { - from: end_node, - to: start_node, - }); + let (start_node, end_node) = line_endpoints_to_nodes(line); + Self::insert_undirected_edge(&mut nodes_to_outbound_edges, start_node, end_node); } Self { @@ -218,7 +355,7 @@ impl PolygonizerGraph { } } - fn get_minimal_edge_rings(&self) -> Vec>> { + pub(super) fn get_minimal_edge_rings(&self) -> Vec>> { let next_left_face_edge_by_edge = self.get_edge_to_next_left_face_edge_map(); let mut rings = Vec::new(); @@ -250,15 +387,12 @@ impl PolygonizerGraph { rings } + /// Iteratively removes dangling degree-1 chains from the graph. pub(crate) fn delete_dangles(&mut self) { let mut degree_one_nodes: Vec<_> = self .nodes_to_outbound_edges .iter() - .filter_map( - |(node, edges)| { - if edges.len() == 1 { Some(*node) } else { None } - }, - ) + .filter_map(|(node, edges)| (edges.len() == 1).then_some(*node)) .collect(); loop { @@ -292,111 +426,36 @@ impl PolygonizerGraph { } } + /// Removes cut edges by labeling directed-edge faces and dropping edges whose + /// two directions belong to the same face. pub(crate) fn delete_cut_edges(&mut self) { let (edges_by_index, edge_to_index) = self.get_edges_with_index_map(); let next_left_face_edge_index_by_edge_index = self.get_next_left_face_edge_index_by_edge_index(&edge_to_index, edges_by_index.len()); - let mut face_label_by_edge_index: Vec> = vec![None; edges_by_index.len()]; - let mut next_face_label = 0usize; - - for start_edge_index in 0..edges_by_index.len() { - if face_label_by_edge_index[start_edge_index].is_some() { - continue; - } - - let mut traversal_path: Vec = Vec::new(); - let mut visit_position_by_edge_index: BTreeMap = BTreeMap::new(); - let mut current_edge_index = start_edge_index; - - loop { - if let Some(existing_face_label) = face_label_by_edge_index[current_edge_index] { - for traversed_edge_index in traversal_path { - face_label_by_edge_index[traversed_edge_index] = Some(existing_face_label); - } - break; - } - - if let Some(&cycle_start_position) = - visit_position_by_edge_index.get(¤t_edge_index) - { - let face_label = next_face_label; - next_face_label += 1; - - for &traversed_edge_index in &traversal_path[cycle_start_position..] { - face_label_by_edge_index[traversed_edge_index] = Some(face_label); - } - for &traversed_edge_index in &traversal_path[..cycle_start_position] { - face_label_by_edge_index[traversed_edge_index] = Some(face_label); - } - break; - } - - visit_position_by_edge_index.insert(current_edge_index, traversal_path.len()); - traversal_path.push(current_edge_index); - - current_edge_index = - match next_left_face_edge_index_by_edge_index[current_edge_index] { - Some(next_edge_index) => next_edge_index, - None => break, - }; - } - } - - let mut undirected_edges_to_remove: BTreeSet<(Node, Node)> = BTreeSet::new(); - for (edge_index, edge) in edges_by_index.iter().enumerate() { - if edge.from > edge.to { - continue; - } - - let symmetric_edge_index = *edge_to_index - .get(&edge.get_symmetrical()) - .expect("symmetric edge index should exist"); - - if face_label_by_edge_index[edge_index] - == face_label_by_edge_index[symmetric_edge_index] - { - undirected_edges_to_remove.insert((edge.from, edge.to)); - } - } - - self.nodes_to_outbound_edges - .retain(|_, outbound_edges_at_node| { - outbound_edges_at_node.retain(|edge| { - let undirected_edge = if edge.from <= edge.to { - (edge.from, edge.to) - } else { - (edge.to, edge.from) - }; - !undirected_edges_to_remove.contains(&undirected_edge) - }); - !outbound_edges_at_node.is_empty() - }); - } - - pub(crate) fn polygonize(&self) -> MultiPolygon { + let face_label_by_edge_index = Self::compute_face_label_by_edge_index( + edges_by_index.len(), + &next_left_face_edge_index_by_edge_index, + ); + let undirected_edges_to_remove = Self::collect_undirected_cut_edges( + &edges_by_index, + &edge_to_index, + &face_label_by_edge_index, + ); + self.remove_undirected_edges(&undirected_edges_to_remove); + } + + /// Extracts valid rings, classifies shell/hole orientation, and applies + /// downstream topology cleanup passes. + pub(crate) fn polygonize(&self) -> MultiPolygon + where + T: rstar::RTreeNum, + { let edge_rings = self.get_minimal_edge_rings(); let valid_rings: Vec<_> = edge_rings .into_iter() - .filter_map(|ring| { - if ring.len() < 3 { - return None; - } - - let mut linestring: LineString = ring - .iter() - .map(|edge| coord! { x: edge.from.x, y: edge.from.y }) - .collect(); - linestring.close(); - let polygon = Polygon::new(linestring, Default::default()); - if !polygon.is_valid() { - return None; - } - - let (linestring, _) = polygon.into_inner(); - Some(linestring) - }) + .filter_map(|ring| ring_to_valid_linestring(&ring)) .collect(); let (valid_holes, valid_shells): (Vec<_>, Vec<_>) = @@ -407,628 +466,16 @@ impl PolygonizerGraph { .filter(|polygon| polygon.is_valid()) .collect(); - MultiPolygon(remove_redundant_overlapping_standalone_polygons( - remove_non_unique_interior_points_for_touching_topology( - split_touching_boundary_polygons(infer_parent_holes_when_output_has_no_holes( - valid_polygons, - )), + MultiPolygon( + topology_cleanup::remove_redundant_overlapping_standalone_polygons( + topology_cleanup::remove_non_unique_interior_points_for_touching_topology( + topology_cleanup::split_touching_boundary_polygons( + topology_cleanup::infer_parent_holes_when_output_has_no_holes( + valid_polygons, + ), + ), + ), ), - )) - } -} - -fn polygon_boundary_lines(polygon: &Polygon) -> Vec> { - let mut lines: Vec> = polygon.exterior().lines().collect(); - for hole in polygon.interiors() { - lines.extend(hole.lines()); - } - lines -} - -fn lines_have_same_endpoints(first: &Line, second: &Line) -> bool { - (first.start == second.start && first.end == second.end) - || (first.start == second.end && first.end == second.start) -} - -fn polygon_has_unique_boundary_segment( - polygons: &[Polygon], - polygon_index: usize, -) -> bool { - polygon_unique_boundary_segment_count(polygons, polygon_index) > 0 -} - -fn polygon_unique_boundary_segment_count( - polygons: &[Polygon], - polygon_index: usize, -) -> usize { - let polygon_lines = polygon_boundary_lines(&polygons[polygon_index]); - - polygon_lines - .iter() - .filter(|line| { - !polygons - .iter() - .enumerate() - .any(|(other_index, other_polygon)| { - if other_index == polygon_index { - return false; - } - - polygon_boundary_lines(other_polygon) - .iter() - .any(|other_line| lines_have_same_endpoints(line, other_line)) - }) - }) - .count() -} - -fn remove_non_unique_interior_points_for_touching_topology( - polygons: Vec>, -) -> Vec> { - let mut current_polygons = polygons; - - loop { - let mut polygon_indices_to_remove: BTreeSet = BTreeSet::new(); - let unique_boundary_segment_count_by_index: Vec = (0..current_polygons.len()) - .map(|polygon_index| { - polygon_unique_boundary_segment_count(¤t_polygons, polygon_index) - }) - .collect(); - - for polygon_index in 0..current_polygons.len() { - let interior_point = match current_polygons[polygon_index].interior_point() { - Some(point) => point, - None => continue, - }; - - let containing_polygon_indices: Vec = current_polygons - .iter() - .enumerate() - .filter_map(|(candidate_index, candidate_polygon)| { - if candidate_polygon.contains(&interior_point) { - Some(candidate_index) - } else { - None - } - }) - .collect(); - - if containing_polygon_indices.len() <= 1 { - continue; - } - - let owner_index = containing_polygon_indices - .iter() - .copied() - .min_by(|left_index, right_index| { - unique_boundary_segment_count_by_index[*right_index] - .cmp(&unique_boundary_segment_count_by_index[*left_index]) - .then( - current_polygons[*left_index] - .unsigned_area() - .total_cmp(¤t_polygons[*right_index].unsigned_area()), - ) - .then(left_index.cmp(right_index)) - }) - .expect("owner index should exist when containing polygons are non-empty"); - - for candidate_index in containing_polygon_indices { - if candidate_index != owner_index { - let candidate_polygon = ¤t_polygons[candidate_index]; - let keep_same_exterior_plain_variant = candidate_polygon.interiors().is_empty() - && current_polygons.iter().enumerate().any( - |(other_index, other_polygon)| { - other_index != candidate_index - && !other_polygon.interiors().is_empty() - && other_polygon.exterior() == candidate_polygon.exterior() - }, - ); - if keep_same_exterior_plain_variant { - continue; - } - - polygon_indices_to_remove.insert(candidate_index); - } - } - } - - if polygon_indices_to_remove.is_empty() { - break; - } - - current_polygons = current_polygons - .into_iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if polygon_indices_to_remove.contains(&polygon_index) { - None - } else { - Some(polygon) - } - }) - .collect(); - } - - current_polygons -} - -fn split_touching_boundary_polygons( - polygons: Vec>, -) -> Vec> { - polygons - .into_iter() - .flat_map(split_touching_boundary_polygon) - .collect() -} - -fn build_touching_boundary_graph( - polygon: &Polygon, -) -> PolygonizerGraph { - let mut boundary_lines: Vec<_> = polygon.exterior().lines().collect(); - for hole in polygon.interiors() { - boundary_lines.extend(hole.lines()); - } - - let split_snap_radius = - T::from(1e-10).unwrap_or(T::epsilon() * T::from(1024.0).unwrap_or(T::one())); - let noded_boundary_lines = nodify_lines(boundary_lines, split_snap_radius); - PolygonizerGraph::from_noded_lines(noded_boundary_lines) -} - -fn graph_has_touching_topology(graph: &PolygonizerGraph) -> bool { - graph - .nodes_to_outbound_edges - .values() - .any(|outbound_edges| outbound_edges.len() > 2) -} - -fn extract_face_candidates_inside_polygon( - boundary_graph: &PolygonizerGraph, - container_polygon: &Polygon, -) -> Vec> { - boundary_graph - .get_minimal_edge_rings() - .into_iter() - .filter_map(|ring| { - if ring.len() < 3 { - return None; - } - - let mut linestring: LineString = ring - .iter() - .map(|edge| coord! { x: edge.from.x, y: edge.from.y }) - .collect(); - linestring.close(); - - let face_polygon = Polygon::new(linestring, vec![]).orient(Direction::Default); - if !face_polygon.is_valid() { - return None; - } - - let interior_point = face_polygon.interior_point()?; - if !container_polygon.contains(&interior_point) { - return None; - } - - Some(face_polygon) - }) - .collect() -} - -fn deduplicate_faces_by_exterior(faces: Vec>) -> Vec> { - let mut deduplicated_faces: Vec> = Vec::new(); - for face in faces { - let already_present = deduplicated_faces - .iter() - .any(|existing| existing.exterior() == face.exterior()); - if !already_present { - deduplicated_faces.push(face); - } - } - deduplicated_faces -} - -fn prune_container_faces(faces: &[Polygon]) -> Vec> { - faces - .iter() - .enumerate() - .filter_map(|(face_index, face)| { - let contains_another_face = - faces - .iter() - .enumerate() - .any(|(other_face_index, other_face)| { - if face_index == other_face_index { - return false; - } - - let other_interior_point = match other_face.interior_point() { - Some(point) => point, - None => return false, - }; - - face.exterior() != other_face.exterior() - && face.contains(&other_interior_point) - }); - - if contains_another_face { - None - } else { - Some(face.clone()) - } - }) - .collect() -} - -fn select_non_touching_holes( - polygon: &Polygon, -) -> Vec> { - let mut kept_holes: Vec> = Vec::new(); - for hole in polygon.interiors() { - let mut candidate_holes = kept_holes.clone(); - candidate_holes.push(hole.clone()); - - let candidate_polygon = Polygon::new(polygon.exterior().clone(), candidate_holes.clone()) - .orient(Direction::Default); - if !graph_has_touching_topology(&build_touching_boundary_graph(&candidate_polygon)) { - kept_holes = candidate_holes; - } - } - kept_holes -} - -fn split_no_hole_polygon_on_repeated_vertex( - polygon: &Polygon, -) -> Option>> { - if !polygon.interiors().is_empty() { - return None; - } - - let mut coordinates: Vec<_> = polygon.exterior().points().map(|point| point.0).collect(); - if coordinates.first() == coordinates.last() { - coordinates.pop(); - } - - if coordinates.len() < 4 { - return None; - } - - for first_index in 0..coordinates.len() { - for second_index in (first_index + 2)..coordinates.len() { - if first_index == 0 && second_index + 1 == coordinates.len() { - continue; - } - - if coordinates[first_index] != coordinates[second_index] { - continue; - } - - let first_ring_coords: Vec<_> = coordinates[first_index..=second_index].to_vec(); - - let mut second_ring_coords: Vec<_> = coordinates[second_index..].to_vec(); - second_ring_coords.extend_from_slice(&coordinates[..=first_index]); - - if first_ring_coords.len() < 4 || second_ring_coords.len() < 4 { - continue; - } - - let first_polygon = Polygon::new(LineString::from(first_ring_coords), vec![]) - .orient(Direction::Default); - let second_polygon = Polygon::new(LineString::from(second_ring_coords), vec![]) - .orient(Direction::Default); - - if first_polygon.is_valid() && second_polygon.is_valid() { - return Some(vec![first_polygon, second_polygon]); - } - } - } - - None -} - -fn split_touching_boundary_polygon( - polygon: Polygon, -) -> Vec> { - let boundary_graph = build_touching_boundary_graph(&polygon); - if !graph_has_touching_topology(&boundary_graph) { - return vec![polygon]; - } - - let face_candidates = extract_face_candidates_inside_polygon(&boundary_graph, &polygon); - let deduplicated_faces = deduplicate_faces_by_exterior(face_candidates); - let split_faces = prune_container_faces(&deduplicated_faces); - - if split_faces.len() >= 2 { - split_faces - } else if polygon.interiors().is_empty() { - split_no_hole_polygon_on_repeated_vertex(&polygon).unwrap_or_else(|| vec![polygon]) - } else { - let kept_holes = select_non_touching_holes(&polygon); - vec![Polygon::new(polygon.exterior().clone(), kept_holes).orient(Direction::Default)] - } -} - -fn remove_redundant_overlapping_standalone_polygons( - polygons: Vec>, -) -> Vec> { - polygons - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if !polygon.interiors().is_empty() { - return Some(polygon.clone()); - } - - let interior_point = match polygon.interior_point() { - Some(point) => point, - None => return Some(polygon.clone()), - }; - - let is_redundant_overlap = polygons.iter().enumerate().any(|(other_index, other)| { - other_index != polygon_index - && other.exterior() != polygon.exterior() - && !other.interiors().is_empty() - && other.contains(&interior_point) - }); - - if is_redundant_overlap - && !polygon_has_unique_boundary_segment(&polygons, polygon_index) - { - None - } else { - Some(polygon.clone()) - } - }) - .collect() -} - -fn infer_parent_holes_when_output_has_no_holes( - polygons: Vec>, -) -> Vec> { - let polygon_envelopes: Vec<_> = polygons.iter().map(|polygon| polygon.envelope()).collect(); - - let mut parent_polygon_index_by_polygon_index: Vec> = vec![None; polygons.len()]; - for child_polygon_index in 0..polygons.len() { - let child_envelope = polygon_envelopes[child_polygon_index]; - let mut best_parent: Option<(usize, T)> = None; - - for parent_polygon_index in 0..polygons.len() { - if child_polygon_index == parent_polygon_index { - continue; - } - - let parent_envelope = polygon_envelopes[parent_polygon_index]; - if parent_envelope == child_envelope - || !parent_envelope.contains_envelope(&child_envelope) - { - continue; - } - - let child_interior_point = match polygons[child_polygon_index].interior_point() { - Some(point) => point, - None => continue, - }; - - if !polygons[parent_polygon_index].contains(&child_interior_point) { - continue; - } - - let parent_envelope_area = parent_envelope.area(); - if let Some((_, best_area)) = best_parent { - if parent_envelope_area >= best_area { - continue; - } - } - best_parent = Some((parent_polygon_index, parent_envelope_area)); - } - - parent_polygon_index_by_polygon_index[child_polygon_index] = - best_parent.map(|(parent_polygon_index, _)| parent_polygon_index); - } - - let mut inferred_holes_by_parent_polygon_index: BTreeMap>> = - BTreeMap::new(); - for (child_polygon_index, parent_polygon_index) in - parent_polygon_index_by_polygon_index.iter().enumerate() - { - let parent_polygon_index = match parent_polygon_index { - Some(parent_polygon_index) => *parent_polygon_index, - None => continue, - }; - - let parent_exterior = polygons[parent_polygon_index].exterior(); - let has_same_exterior_polygon_with_explicit_holes = - polygons.iter().enumerate().any(|(polygon_index, polygon)| { - polygon_index != parent_polygon_index - && polygon.exterior() == parent_exterior - && !polygon.interiors().is_empty() - }); - if has_same_exterior_polygon_with_explicit_holes { - continue; - } - - let mut candidate_holes = inferred_holes_by_parent_polygon_index - .get(&parent_polygon_index) - .cloned() - .unwrap_or_default(); - candidate_holes.push(polygons[child_polygon_index].exterior().clone()); - - let candidate_polygon = Polygon::new( - polygons[parent_polygon_index].exterior().clone(), - candidate_holes.clone(), ) - .orient(Direction::Default); - if !candidate_polygon.is_valid() { - continue; - } - - inferred_holes_by_parent_polygon_index.insert(parent_polygon_index, candidate_holes); - } - - polygons - .into_iter() - .enumerate() - .map(|(polygon_index, polygon)| { - let mut polygon_holes = polygon.interiors().to_vec(); - polygon_holes.extend( - inferred_holes_by_parent_polygon_index - .get(&polygon_index) - .cloned() - .unwrap_or_default(), - ); - - Polygon::new(polygon.exterior().clone(), polygon_holes).orient(Direction::Default) - }) - .collect() -} - -type EdgeToLine<'a, T> = fn(&'a Edge) -> Option>; -type EdgeFilterMap<'a, T> = FilterMap>, EdgeToLine<'a, T>>; -type NodeToEdges<'a, T> = fn((&'a Node, &'a BTreeSet>)) -> EdgeFilterMap<'a, T>; -type PolygonizerGraphLinesIter<'a, T> = - Flatten, BTreeSet>>, NodeToEdges<'a, T>>>; - -impl<'a, T: GeoFloat + 'a> LinesIter<'a> for PolygonizerGraph { - type Scalar = T; - type Iter = PolygonizerGraphLinesIter<'a, T>; - - fn lines_iter(&'a self) -> Self::Iter { - self.nodes_to_outbound_edges - .iter() - .map( - (|(_, edges)| { - edges.iter().filter_map( - (|edge| { - if edge.from < edge.to { - Some(Line::new( - coord! { x: edge.from.x, y: edge.from.y }, - coord! { x: edge.to.x, y: edge.to.y }, - )) - } else { - None - } - }) as EdgeToLine, - ) - }) as NodeToEdges, - ) - .flatten() - } -} - -struct ShellContainer { - idx: usize, - envelope: rstar::AABB>, -} - -impl rstar::RTreeObject for ShellContainer { - type Envelope = rstar::AABB>; - - fn envelope(&self) -> Self::Envelope { - self.envelope } } - -fn assign_shells_to_holes( - shells: Vec>, - holes: Vec>, -) -> Vec> { - let shell_polygons: Vec<_> = shells - .iter() - .cloned() - .map(|shell| Polygon::new(shell, vec![])) - .collect(); - - let shell_containers = shells - .iter() - .enumerate() - .map(|(idx, shell)| ShellContainer { - idx, - envelope: shell.envelope(), - }) - .collect(); - let shell_tree = rstar::RTree::bulk_load(shell_containers); - - let mut assignments: BTreeMap> = BTreeMap::new(); - - for (hole_index, hole) in holes.iter().enumerate() { - let hole_interior_point = match Polygon::new(hole.clone(), vec![]).interior_point() { - Some(point) => point, - None => continue, - }; - - let hole_envelope = hole.envelope(); - let mut matching_shells: Vec<_> = shell_tree - .locate_in_envelope_intersecting(&hole.envelope()) - .filter(|container| { - container.envelope.contains_envelope(&hole_envelope) - && container.envelope != hole_envelope - && shell_polygons[container.idx].contains(&hole_interior_point) - }) - .collect(); - matching_shells.sort_by(|left_shell, right_shell| { - shell_polygons[left_shell.idx] - .unsigned_area() - .total_cmp(&shell_polygons[right_shell.idx].unsigned_area()) - }); - - if let Some(container) = matching_shells.first() { - assignments - .entry(container.idx) - .or_insert_with(|| Vec::new()) - .push(hole_index); - } - } - - let mut polygons: Vec> = shells - .into_iter() - .enumerate() - .map(|(shell_index, shell)| { - let polygon = Polygon::new( - shell, - match assignments.get(&shell_index) { - Some(assigned_hole_indices) => assigned_hole_indices - .iter() - .map(|hole_index| holes[*hole_index].clone()) - .collect(), - None => vec![], - }, - ); - polygon.orient(Direction::Default) - }) - .collect(); - - let assigned_hole_indices: BTreeSet = assignments - .values() - .flat_map(|hole_indices| hole_indices.iter().copied()) - .collect(); - - for hole_index in assigned_hole_indices { - let standalone_hole_polygon = - Polygon::new(holes[hole_index].clone(), vec![]).orient(Direction::Default); - - let overlaps_different_exterior_holed_polygon = polygons.iter().any(|polygon| { - polygon.exterior() != standalone_hole_polygon.exterior() - && !polygon.interiors().is_empty() - && standalone_hole_polygon - .exterior() - .points() - .any(|point| polygon.contains(&point)) - }); - - let max_holes_with_same_exterior = polygons - .iter() - .filter(|polygon| polygon.exterior() == standalone_hole_polygon.exterior()) - .map(|polygon| polygon.interiors().len()) - .max() - .unwrap_or(0); - - if !overlaps_different_exterior_holed_polygon - && max_holes_with_same_exterior <= 1 - && !polygons.contains(&standalone_hole_polygon) - { - polygons.push(standalone_hole_polygon); - } - } - - polygons -} diff --git a/src/graph/lines_iter.rs b/src/graph/lines_iter.rs new file mode 100644 index 0000000..0d96c5b --- /dev/null +++ b/src/graph/lines_iter.rs @@ -0,0 +1,41 @@ +//! LinesIter implementation for the polygonizer graph. + +use std::collections::{BTreeSet, btree_map, btree_set}; +use std::iter::{FilterMap, Flatten, Map}; + +use geo::{GeoFloat, Line, LinesIter, coord}; + +use super::{Edge, Node, PolygonizerGraph}; + +type EdgeToLine<'a, T> = fn(&'a Edge) -> Option>; +type EdgeFilterMap<'a, T> = FilterMap>, EdgeToLine<'a, T>>; +type NodeToEdges<'a, T> = fn((&'a Node, &'a BTreeSet>)) -> EdgeFilterMap<'a, T>; +type PolygonizerGraphLinesIter<'a, T> = + Flatten, BTreeSet>>, NodeToEdges<'a, T>>>; + +impl<'a, T: GeoFloat + 'a> LinesIter<'a> for PolygonizerGraph { + type Scalar = T; + type Iter = PolygonizerGraphLinesIter<'a, T>; + + fn lines_iter(&'a self) -> Self::Iter { + self.nodes_to_outbound_edges + .iter() + .map( + (|(_, edges)| { + edges.iter().filter_map( + (|edge| { + if edge.from < edge.to { + Some(Line::new( + coord! { x: edge.from.x, y: edge.from.y }, + coord! { x: edge.to.x, y: edge.to.y }, + )) + } else { + None + } + }) as EdgeToLine, + ) + }) as NodeToEdges, + ) + .flatten() + } +} diff --git a/src/graph/shell_assignment.rs b/src/graph/shell_assignment.rs new file mode 100644 index 0000000..a3010a5 --- /dev/null +++ b/src/graph/shell_assignment.rs @@ -0,0 +1,125 @@ +//! Assigns shell rings to hole rings and builds output polygons. + +use std::collections::{BTreeMap, BTreeSet}; + +use geo::orient::Direction; +use geo::{Area, Contains, GeoFloat, InteriorPoint, LineString, Orient, Polygon}; +use rstar::{Envelope, RTreeObject}; + +struct ShellContainer { + idx: usize, + envelope: rstar::AABB>, +} + +impl RTreeObject for ShellContainer { + type Envelope = rstar::AABB>; + + fn envelope(&self) -> Self::Envelope { + self.envelope + } +} + +pub(super) fn assign_shells_to_holes( + shells: Vec>, + holes: Vec>, +) -> Vec> { + let shell_polygons: Vec<_> = shells + .iter() + .cloned() + .map(|shell| Polygon::new(shell, vec![])) + .collect(); + + let shell_containers = shells + .iter() + .enumerate() + .map(|(idx, shell)| ShellContainer { + idx, + envelope: shell.envelope(), + }) + .collect(); + let shell_tree = rstar::RTree::bulk_load(shell_containers); + + let mut assignments: BTreeMap> = BTreeMap::new(); + + for (hole_index, hole) in holes.iter().enumerate() { + let hole_interior_point = match Polygon::new(hole.clone(), vec![]).interior_point() { + Some(point) => point, + None => continue, + }; + + let hole_envelope = hole.envelope(); + let mut matching_shells: Vec<_> = shell_tree + .locate_in_envelope_intersecting(&hole.envelope()) + .filter(|container| { + container.envelope.contains_envelope(&hole_envelope) + && container.envelope != hole_envelope + && shell_polygons[container.idx].contains(&hole_interior_point) + }) + .collect(); + matching_shells.sort_by(|left_shell, right_shell| { + shell_polygons[left_shell.idx] + .unsigned_area() + .total_cmp(&shell_polygons[right_shell.idx].unsigned_area()) + }); + + if let Some(container) = matching_shells.first() { + assignments + .entry(container.idx) + .or_insert_with(|| Vec::new()) + .push(hole_index); + } + } + + let mut polygons: Vec> = shells + .into_iter() + .enumerate() + .map(|(shell_index, shell)| { + let polygon = Polygon::new( + shell, + match assignments.get(&shell_index) { + Some(assigned_hole_indices) => assigned_hole_indices + .iter() + .map(|hole_index| holes[*hole_index].clone()) + .collect(), + None => vec![], + }, + ); + polygon.orient(Direction::Default) + }) + .collect(); + + let assigned_hole_indices: BTreeSet = assignments + .values() + .flat_map(|hole_indices| hole_indices.iter().copied()) + .collect(); + + for hole_index in assigned_hole_indices { + let standalone_hole_polygon = + Polygon::new(holes[hole_index].clone(), vec![]).orient(Direction::Default); + + let overlaps_different_exterior_holed_polygon = polygons.iter().any(|polygon| { + polygon.exterior() != standalone_hole_polygon.exterior() + && !polygon.interiors().is_empty() + && standalone_hole_polygon + .exterior() + .points() + .any(|point| polygon.contains(&point)) + }); + + let max_holes_with_same_exterior = polygons + .iter() + .filter(|polygon| polygon.exterior() == standalone_hole_polygon.exterior()) + .map(|polygon| polygon.interiors().len()) + .max() + .unwrap_or(0); + + if !overlaps_different_exterior_holed_polygon + && max_holes_with_same_exterior <= 1 + && !polygons.contains(&standalone_hole_polygon) + { + polygons.push(standalone_hole_polygon); + } + } + + polygons +} diff --git a/src/graph/topology_cleanup.rs b/src/graph/topology_cleanup.rs new file mode 100644 index 0000000..c79cbbd --- /dev/null +++ b/src/graph/topology_cleanup.rs @@ -0,0 +1,465 @@ +//! Topology cleanup passes applied after initial polygon extraction. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::nodify::nodify_lines; +use geo::orient::Direction; +use geo::{Area, Contains, GeoFloat, InteriorPoint, Line, LineString, Orient, Polygon, Validation}; +use rstar::{Envelope, RTreeObject}; + +use super::{PolygonizerGraph, ring_to_valid_linestring}; + +fn polygon_boundary_lines(polygon: &Polygon) -> Vec> { + let mut lines: Vec> = polygon.exterior().lines().collect(); + for hole in polygon.interiors() { + lines.extend(hole.lines()); + } + lines +} + +fn lines_have_same_endpoints(first: &Line, second: &Line) -> bool { + (first.start == second.start && first.end == second.end) + || (first.start == second.end && first.end == second.start) +} + +fn polygon_has_unique_boundary_segment( + polygons: &[Polygon], + polygon_index: usize, +) -> bool { + polygon_unique_boundary_segment_count(polygons, polygon_index) > 0 +} + +fn polygon_unique_boundary_segment_count( + polygons: &[Polygon], + polygon_index: usize, +) -> usize { + let polygon_lines = polygon_boundary_lines(&polygons[polygon_index]); + + polygon_lines + .iter() + .filter(|line| { + !polygons + .iter() + .enumerate() + .any(|(other_index, other_polygon)| { + if other_index == polygon_index { + return false; + } + + polygon_boundary_lines(other_polygon) + .iter() + .any(|other_line| lines_have_same_endpoints(line, other_line)) + }) + }) + .count() +} + +pub(super) fn remove_non_unique_interior_points_for_touching_topology< + T: GeoFloat + rstar::RTreeNum, +>( + polygons: Vec>, +) -> Vec> { + let mut current_polygons = polygons; + + loop { + let mut polygon_indices_to_remove: BTreeSet = BTreeSet::new(); + let unique_boundary_segment_count_by_index: Vec = (0..current_polygons.len()) + .map(|polygon_index| { + polygon_unique_boundary_segment_count(¤t_polygons, polygon_index) + }) + .collect(); + + for polygon_index in 0..current_polygons.len() { + let interior_point = match current_polygons[polygon_index].interior_point() { + Some(point) => point, + None => continue, + }; + + let containing_polygon_indices: Vec = current_polygons + .iter() + .enumerate() + .filter_map(|(candidate_index, candidate_polygon)| { + if candidate_polygon.contains(&interior_point) { + Some(candidate_index) + } else { + None + } + }) + .collect(); + + if containing_polygon_indices.len() <= 1 { + continue; + } + + let owner_index = containing_polygon_indices + .iter() + .copied() + .min_by(|left_index, right_index| { + unique_boundary_segment_count_by_index[*right_index] + .cmp(&unique_boundary_segment_count_by_index[*left_index]) + .then( + current_polygons[*left_index] + .unsigned_area() + .total_cmp(¤t_polygons[*right_index].unsigned_area()), + ) + .then(left_index.cmp(right_index)) + }) + .expect("owner index should exist when containing polygons are non-empty"); + + for candidate_index in containing_polygon_indices { + if candidate_index != owner_index { + let candidate_polygon = ¤t_polygons[candidate_index]; + let keep_same_exterior_plain_variant = candidate_polygon.interiors().is_empty() + && current_polygons.iter().enumerate().any( + |(other_index, other_polygon)| { + other_index != candidate_index + && !other_polygon.interiors().is_empty() + && other_polygon.exterior() == candidate_polygon.exterior() + }, + ); + if keep_same_exterior_plain_variant { + continue; + } + + polygon_indices_to_remove.insert(candidate_index); + } + } + } + + if polygon_indices_to_remove.is_empty() { + break; + } + + current_polygons = current_polygons + .into_iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon_indices_to_remove.contains(&polygon_index) { + None + } else { + Some(polygon) + } + }) + .collect(); + } + + current_polygons +} + +pub(super) fn split_touching_boundary_polygons( + polygons: Vec>, +) -> Vec> { + polygons + .into_iter() + .flat_map(split_touching_boundary_polygon) + .collect() +} + +fn build_touching_boundary_graph( + polygon: &Polygon, +) -> PolygonizerGraph { + let mut boundary_lines: Vec<_> = polygon.exterior().lines().collect(); + for hole in polygon.interiors() { + boundary_lines.extend(hole.lines()); + } + + let split_snap_radius = + T::from(1e-10).unwrap_or(T::epsilon() * T::from(1024.0).unwrap_or(T::one())); + let noded_boundary_lines = nodify_lines(boundary_lines, split_snap_radius); + PolygonizerGraph::from_noded_lines(noded_boundary_lines) +} + +fn graph_has_touching_topology(graph: &PolygonizerGraph) -> bool { + graph + .nodes_to_outbound_edges + .values() + .any(|outbound_edges| outbound_edges.len() > 2) +} + +fn extract_face_candidates_inside_polygon( + boundary_graph: &PolygonizerGraph, + container_polygon: &Polygon, +) -> Vec> { + boundary_graph + .get_minimal_edge_rings() + .into_iter() + .filter_map(|ring| { + let linestring = ring_to_valid_linestring(&ring)?; + let face_polygon = Polygon::new(linestring, vec![]).orient(Direction::Default); + + let interior_point = face_polygon.interior_point()?; + if !container_polygon.contains(&interior_point) { + return None; + } + + Some(face_polygon) + }) + .collect() +} + +fn deduplicate_faces_by_exterior(faces: Vec>) -> Vec> { + let mut deduplicated_faces: Vec> = Vec::new(); + for face in faces { + let already_present = deduplicated_faces + .iter() + .any(|existing| existing.exterior() == face.exterior()); + if !already_present { + deduplicated_faces.push(face); + } + } + deduplicated_faces +} + +fn prune_container_faces(faces: &[Polygon]) -> Vec> { + faces + .iter() + .enumerate() + .filter_map(|(face_index, face)| { + let contains_another_face = + faces + .iter() + .enumerate() + .any(|(other_face_index, other_face)| { + if face_index == other_face_index { + return false; + } + + let other_interior_point = match other_face.interior_point() { + Some(point) => point, + None => return false, + }; + + face.exterior() != other_face.exterior() + && face.contains(&other_interior_point) + }); + + if contains_another_face { + None + } else { + Some(face.clone()) + } + }) + .collect() +} + +fn select_non_touching_holes( + polygon: &Polygon, +) -> Vec> { + let mut kept_holes: Vec> = Vec::new(); + for hole in polygon.interiors() { + let mut candidate_holes = kept_holes.clone(); + candidate_holes.push(hole.clone()); + + let candidate_polygon = Polygon::new(polygon.exterior().clone(), candidate_holes.clone()) + .orient(Direction::Default); + if !graph_has_touching_topology(&build_touching_boundary_graph(&candidate_polygon)) { + kept_holes = candidate_holes; + } + } + kept_holes +} + +fn split_no_hole_polygon_on_repeated_vertex( + polygon: &Polygon, +) -> Option>> { + if !polygon.interiors().is_empty() { + return None; + } + + let mut coordinates: Vec<_> = polygon.exterior().points().map(|point| point.0).collect(); + if coordinates.first() == coordinates.last() { + coordinates.pop(); + } + + if coordinates.len() < 4 { + return None; + } + + for first_index in 0..coordinates.len() { + for second_index in (first_index + 2)..coordinates.len() { + if first_index == 0 && second_index + 1 == coordinates.len() { + continue; + } + + if coordinates[first_index] != coordinates[second_index] { + continue; + } + + let first_ring_coords: Vec<_> = coordinates[first_index..=second_index].to_vec(); + + let mut second_ring_coords: Vec<_> = coordinates[second_index..].to_vec(); + second_ring_coords.extend_from_slice(&coordinates[..=first_index]); + + if first_ring_coords.len() < 4 || second_ring_coords.len() < 4 { + continue; + } + + let first_polygon = Polygon::new(LineString::from(first_ring_coords), vec![]) + .orient(Direction::Default); + let second_polygon = Polygon::new(LineString::from(second_ring_coords), vec![]) + .orient(Direction::Default); + + if first_polygon.is_valid() && second_polygon.is_valid() { + return Some(vec![first_polygon, second_polygon]); + } + } + } + + None +} + +fn split_touching_boundary_polygon( + polygon: Polygon, +) -> Vec> { + let boundary_graph = build_touching_boundary_graph(&polygon); + if !graph_has_touching_topology(&boundary_graph) { + return vec![polygon]; + } + + let face_candidates = extract_face_candidates_inside_polygon(&boundary_graph, &polygon); + let deduplicated_faces = deduplicate_faces_by_exterior(face_candidates); + let split_faces = prune_container_faces(&deduplicated_faces); + + if split_faces.len() >= 2 { + split_faces + } else if polygon.interiors().is_empty() { + split_no_hole_polygon_on_repeated_vertex(&polygon).unwrap_or_else(|| vec![polygon]) + } else { + let kept_holes = select_non_touching_holes(&polygon); + vec![Polygon::new(polygon.exterior().clone(), kept_holes).orient(Direction::Default)] + } +} + +pub(super) fn remove_redundant_overlapping_standalone_polygons( + polygons: Vec>, +) -> Vec> { + polygons + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if !polygon.interiors().is_empty() { + return Some(polygon.clone()); + } + + let interior_point = match polygon.interior_point() { + Some(point) => point, + None => return Some(polygon.clone()), + }; + + let is_redundant_overlap = polygons.iter().enumerate().any(|(other_index, other)| { + other_index != polygon_index + && other.exterior() != polygon.exterior() + && !other.interiors().is_empty() + && other.contains(&interior_point) + }); + + if is_redundant_overlap + && !polygon_has_unique_boundary_segment(&polygons, polygon_index) + { + None + } else { + Some(polygon.clone()) + } + }) + .collect() +} + +pub(super) fn infer_parent_holes_when_output_has_no_holes( + polygons: Vec>, +) -> Vec> { + let polygon_envelopes: Vec<_> = polygons.iter().map(|polygon| polygon.envelope()).collect(); + + let mut parent_polygon_index_by_polygon_index: Vec> = vec![None; polygons.len()]; + for child_polygon_index in 0..polygons.len() { + let child_envelope = polygon_envelopes[child_polygon_index]; + let mut best_parent: Option<(usize, T)> = None; + + for parent_polygon_index in 0..polygons.len() { + if child_polygon_index == parent_polygon_index { + continue; + } + + let parent_envelope = polygon_envelopes[parent_polygon_index]; + if parent_envelope == child_envelope + || !parent_envelope.contains_envelope(&child_envelope) + { + continue; + } + + let child_interior_point = match polygons[child_polygon_index].interior_point() { + Some(point) => point, + None => continue, + }; + + if !polygons[parent_polygon_index].contains(&child_interior_point) { + continue; + } + + let parent_envelope_area = parent_envelope.area(); + if let Some((_, best_area)) = best_parent { + if parent_envelope_area >= best_area { + continue; + } + } + best_parent = Some((parent_polygon_index, parent_envelope_area)); + } + + parent_polygon_index_by_polygon_index[child_polygon_index] = + best_parent.map(|(parent_polygon_index, _)| parent_polygon_index); + } + + let mut inferred_holes_by_parent_polygon_index: BTreeMap>> = + BTreeMap::new(); + for (child_polygon_index, parent_polygon_index) in + parent_polygon_index_by_polygon_index.iter().enumerate() + { + let parent_polygon_index = match parent_polygon_index { + Some(parent_polygon_index) => *parent_polygon_index, + None => continue, + }; + + let parent_exterior = polygons[parent_polygon_index].exterior(); + let has_same_exterior_polygon_with_explicit_holes = + polygons.iter().enumerate().any(|(polygon_index, polygon)| { + polygon_index != parent_polygon_index + && polygon.exterior() == parent_exterior + && !polygon.interiors().is_empty() + }); + if has_same_exterior_polygon_with_explicit_holes { + continue; + } + + let mut candidate_holes = inferred_holes_by_parent_polygon_index + .get(&parent_polygon_index) + .cloned() + .unwrap_or_default(); + candidate_holes.push(polygons[child_polygon_index].exterior().clone()); + + let candidate_polygon = Polygon::new( + polygons[parent_polygon_index].exterior().clone(), + candidate_holes.clone(), + ) + .orient(Direction::Default); + if !candidate_polygon.is_valid() { + continue; + } + + inferred_holes_by_parent_polygon_index.insert(parent_polygon_index, candidate_holes); + } + + polygons + .into_iter() + .enumerate() + .map(|(polygon_index, polygon)| { + let mut polygon_holes = polygon.interiors().to_vec(); + polygon_holes.extend( + inferred_holes_by_parent_polygon_index + .get(&polygon_index) + .cloned() + .unwrap_or_default(), + ); + + Polygon::new(polygon.exterior().clone(), polygon_holes).orient(Direction::Default) + }) + .collect() +} diff --git a/src/lib.rs b/src/lib.rs index f8f8dc7..9a62758 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,11 @@ pub use nodify::nodify_lines; #[cfg(test)] mod tests; +/// Polygonizes an input line set into a [`geo::MultiPolygon`]. +/// +/// Input lines are expected to already be suitably noded if intersections or +/// overlaps should be split at intersection points. Use [`nodify_lines`] first +/// when this precondition is not guaranteed. pub fn polygonize(lines: impl IntoIterator>) -> MultiPolygon { let mut graph = PolygonizerGraph::from_noded_lines(lines); graph.delete_dangles(); diff --git a/src/nodify.rs b/src/nodify.rs index 9b95604..756f6d9 100644 --- a/src/nodify.rs +++ b/src/nodify.rs @@ -1,3 +1,9 @@ +//! Sweep-line based noding for linework. +//! +//! The algorithm finds line intersections and collinear overlaps, inserts cut +//! points on each source segment, splits each segment at those cut points, and +//! returns a deduplicated set of noded sub-segments. + use geo::{ Coord, GeoFloat, Line, algorithm::sweep::Intersections, line_intersection::LineIntersection, }; From c43478caadd59bc863817e521d8a8c2ca5d009a4 Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Sat, 18 Apr 2026 13:02:57 -0400 Subject: [PATCH 08/12] another round of cleanup and edge-case fixing --- examples/generate_single_fixture_output.rs | 77 + examples/update_very_complex_fixture.rs | 77 + .../input/contained_island.geojson | 19 + ...a_priority_enclosing_shell_minimal.geojson | 1531 +++++ .../split_touching_hole_drop_minimal.geojson | 47 + ...lit_touching_hole_priority_minimal.geojson | 5088 +++++++++++++++++ .../output/contained_island.geojson | 142 + .../polygonizer/output/failed_hole.geojson | 467 ++ .../minimal_secondary_probe_split.geojson | 173 + .../nested_shell_overlap_minimal.geojson | 161 + ...a_priority_enclosing_shell_minimal.geojson | 825 +++ .../split_touching_hole_drop_minimal.geojson | 142 + src/graph.rs | 106 +- src/graph/topology_cleanup.rs | 356 +- src/tests.rs | 199 +- 15 files changed, 9383 insertions(+), 27 deletions(-) create mode 100644 examples/generate_single_fixture_output.rs create mode 100644 examples/update_very_complex_fixture.rs create mode 100644 fixtures/polygonizer/input/contained_island.geojson create mode 100644 fixtures/polygonizer/input/ownership_area_priority_enclosing_shell_minimal.geojson create mode 100644 fixtures/polygonizer/input/split_touching_hole_drop_minimal.geojson create mode 100644 fixtures/polygonizer/input/split_touching_hole_priority_minimal.geojson create mode 100644 fixtures/polygonizer/output/contained_island.geojson create mode 100644 fixtures/polygonizer/output/failed_hole.geojson create mode 100644 fixtures/polygonizer/output/minimal_secondary_probe_split.geojson create mode 100644 fixtures/polygonizer/output/nested_shell_overlap_minimal.geojson create mode 100644 fixtures/polygonizer/output/ownership_area_priority_enclosing_shell_minimal.geojson create mode 100644 fixtures/polygonizer/output/split_touching_hole_drop_minimal.geojson diff --git a/examples/generate_single_fixture_output.rs b/examples/generate_single_fixture_output.rs new file mode 100644 index 0000000..236a675 --- /dev/null +++ b/examples/generate_single_fixture_output.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::path::PathBuf; + +use geo::{Line, LinesIter}; +use geo_polygonizer::polygonize; +use geojson::{Feature, FeatureCollection, Geometry, GeometryValue}; + +fn fixture_path(kind: &str, name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("polygonizer") + .join(kind) + .join(format!("{name}.geojson")) +} + +fn load_input_lines(name: &str) -> Vec> { + let path = fixture_path("input", name); + let text = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read fixture {}: {err}", path.display())); + let collection: FeatureCollection = serde_json::from_str(&text) + .unwrap_or_else(|err| panic!("failed to parse fixture {}: {err}", path.display())); + + collection + .features + .into_iter() + .flat_map(|feature| { + let geometry = feature + .geometry + .unwrap_or_else(|| panic!("input fixture {name} has a feature with no geometry")); + match geometry.value { + GeometryValue::LineString { .. } => { + let linestring: geo::LineString = geometry.try_into().unwrap(); + linestring.lines().collect::>() + } + GeometryValue::MultiLineString { .. } => { + let multiline: geo::MultiLineString = geometry.try_into().unwrap(); + multiline.lines_iter().collect::>() + } + other => { + panic!("input fixture {name} uses unsupported geometry type: {other:?}") + } + } + }) + .collect() +} + +fn main() { + let name = std::env::args().nth(1).unwrap_or_else(|| "minimal_secondary_probe_split".to_string()); + let input_lines = load_input_lines(&name); + let polygons = polygonize(input_lines); + + let features = polygons + .0 + .into_iter() + .map(|polygon| { + let geometry = Geometry::new(GeometryValue::from(&polygon)); + Feature { + bbox: None, + geometry: Some(geometry), + id: None, + properties: None, + foreign_members: None, + } + }) + .collect(); + + let collection = FeatureCollection { + bbox: None, + features, + foreign_members: None, + }; + + let output_path = fixture_path("output", &name); + let output_json = serde_json::to_string_pretty(&collection).expect("serialize output"); + fs::write(&output_path, output_json) + .unwrap_or_else(|err| panic!("failed to write fixture {}: {err}", output_path.display())); +} diff --git a/examples/update_very_complex_fixture.rs b/examples/update_very_complex_fixture.rs new file mode 100644 index 0000000..771b041 --- /dev/null +++ b/examples/update_very_complex_fixture.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::path::PathBuf; + +use geo::{Line, LinesIter}; +use geo_polygonizer::polygonize; +use geojson::{Feature, FeatureCollection, Geometry, GeometryValue}; + +fn fixture_path(kind: &str, name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("polygonizer") + .join(kind) + .join(format!("{name}.geojson")) +} + +fn load_input_lines(name: &str) -> Vec> { + let path = fixture_path("input", name); + let text = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read fixture {}: {err}", path.display())); + let collection: FeatureCollection = serde_json::from_str(&text) + .unwrap_or_else(|err| panic!("failed to parse fixture {}: {err}", path.display())); + + collection + .features + .into_iter() + .flat_map(|feature| { + let geometry = feature + .geometry + .unwrap_or_else(|| panic!("input fixture {name} has a feature with no geometry")); + match geometry.value { + GeometryValue::LineString { .. } => { + let linestring: geo::LineString = geometry.try_into().unwrap(); + linestring.lines().collect::>() + } + GeometryValue::MultiLineString { .. } => { + let multiline: geo::MultiLineString = geometry.try_into().unwrap(); + multiline.lines_iter().collect::>() + } + other => { + panic!("input fixture {name} uses unsupported geometry type: {other:?}") + } + } + }) + .collect() +} + +fn main() { + let name = "very_complex_linework"; + let input_lines = load_input_lines(name); + let polygons = polygonize(input_lines); + + let features = polygons + .0 + .into_iter() + .map(|polygon| { + let geometry = Geometry::new(GeometryValue::from(&polygon)); + Feature { + bbox: None, + geometry: Some(geometry), + id: None, + properties: None, + foreign_members: None, + } + }) + .collect(); + + let collection = FeatureCollection { + bbox: None, + features, + foreign_members: None, + }; + + let output_path = fixture_path("output", name); + let output_json = serde_json::to_string_pretty(&collection).expect("serialize output"); + fs::write(&output_path, output_json) + .unwrap_or_else(|err| panic!("failed to write fixture {}: {err}", output_path.display())); +} \ No newline at end of file diff --git a/fixtures/polygonizer/input/contained_island.geojson b/fixtures/polygonizer/input/contained_island.geojson new file mode 100644 index 0000000..aa2e5fb --- /dev/null +++ b/fixtures/polygonizer/input/contained_island.geojson @@ -0,0 +1,19 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "outer bottom" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [20, 0]] } }, + { "type": "Feature", "properties": { "name": "outer right" }, "geometry": { "type": "LineString", "coordinates": [[20, 0], [20, 20]] } }, + { "type": "Feature", "properties": { "name": "outer top" }, "geometry": { "type": "LineString", "coordinates": [[20, 20], [0, 20]] } }, + { "type": "Feature", "properties": { "name": "outer left" }, "geometry": { "type": "LineString", "coordinates": [[0, 20], [0, 0]] } }, + + { "type": "Feature", "properties": { "name": "hole bottom" }, "geometry": { "type": "LineString", "coordinates": [[3, 3], [10, 3]] } }, + { "type": "Feature", "properties": { "name": "hole right" }, "geometry": { "type": "LineString", "coordinates": [[10, 3], [10, 10]] } }, + { "type": "Feature", "properties": { "name": "hole top" }, "geometry": { "type": "LineString", "coordinates": [[10, 10], [3, 10]] } }, + { "type": "Feature", "properties": { "name": "hole left" }, "geometry": { "type": "LineString", "coordinates": [[3, 10], [3, 3]] } }, + + { "type": "Feature", "properties": { "name": "island bottom" }, "geometry": { "type": "LineString", "coordinates": [[13, 13], [18, 13]] } }, + { "type": "Feature", "properties": { "name": "island right" }, "geometry": { "type": "LineString", "coordinates": [[18, 13], [18, 18]] } }, + { "type": "Feature", "properties": { "name": "island top" }, "geometry": { "type": "LineString", "coordinates": [[18, 18], [13, 18]] } }, + { "type": "Feature", "properties": { "name": "island left" }, "geometry": { "type": "LineString", "coordinates": [[13, 18], [13, 13]] } } + ] +} diff --git a/fixtures/polygonizer/input/ownership_area_priority_enclosing_shell_minimal.geojson b/fixtures/polygonizer/input/ownership_area_priority_enclosing_shell_minimal.geojson new file mode 100644 index 0000000..9e256b1 --- /dev/null +++ b/fixtures/polygonizer/input/ownership_area_priority_enclosing_shell_minimal.geojson @@ -0,0 +1,1531 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.3666873, + 37.27146897 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.3672068, + 37.19309722 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3666873, + 37.27146897 + ], + [ + 131.3736674, + 37.31030766 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3672068, + 37.19309722 + ], + [ + 131.3746863, + 37.15431884 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3736674, + 37.31030766 + ], + [ + 131.3854095, + 37.34841414 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3746863, + 37.15431884 + ], + [ + 131.3868887, + 37.11631032 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3854095, + 37.34841414 + ], + [ + 131.4018073, + 37.38542004 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3868887, + 37.11631032 + ], + [ + 131.4036901, + 37.07943623 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4018073, + 37.38542004 + ], + [ + 131.4227089, + 37.4209671 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4036901, + 37.07943623 + ], + [ + 131.4249229, + 37.04404979 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4227089, + 37.4209671 + ], + [ + 131.4479179, + 37.45471069 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4249229, + 37.04404979 + ], + [ + 131.4503779, + 37.01048946 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4479179, + 37.45471069 + ], + [ + 131.4771952, + 37.48632319 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4503779, + 37.01048946 + ], + [ + 131.4798068, + 36.9790758 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4771952, + 37.48632319 + ], + [ + 131.5102612, + 37.51549728 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4798068, + 36.9790758 + ], + [ + 131.5129239, + 36.9501085 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5102612, + 37.51549728 + ], + [ + 131.5467982, + 37.541949 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5129239, + 36.9501085 + ], + [ + 131.5494099, + 36.92386359 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5467982, + 37.541949 + ], + [ + 131.5864539, + 37.56542057 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5494099, + 36.92386359 + ], + [ + 131.588914, + 36.90059087 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5864539, + 37.56542057 + ], + [ + 131.628844, + 37.58568304 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.588914, + 36.90059087 + ], + [ + 131.6310581, + 36.88051166 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6789834344638, + 37.426338419556394 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6924993976829, + 37.063692228175476 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.628844, + 37.58568304 + ], + [ + 131.673557, + 37.60253859 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6310581, + 36.88051166 + ], + [ + 131.6754399, + 36.86381675 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.673557, + 37.60253859 + ], + [ + 131.7201573, + 37.61582252 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6754399, + 36.86381675 + ], + [ + 131.7216367, + 36.85066465 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.79570674559747, + 37.494760171748474 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6924993976829, + 37.063692228175476 + ], + [ + 131.88096982742374, + 36.990452460251525 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7201573, + 37.61582252 + ], + [ + 131.7681902, + 37.62540497 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7216367, + 36.85066465 + ], + [ + 131.7692093, + 36.84118017 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7681902, + 37.62540497 + ], + [ + 131.8171865, + 37.6311922 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7692093, + 36.84118017 + ], + [ + 131.817706, + 36.83545326 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.99086117128556, + 37.481714926898256 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.8171865, + 37.6311922 + ], + [ + 131.866667, + 37.63312759 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.817706, + 36.83545326 + ], + [ + 131.866667, + 36.83353824 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.866667, + 36.83353824 + ], + [ + 131.9156279, + 36.83545326 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.866667, + 37.63312759 + ], + [ + 131.9161475, + 37.6311922 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 132.04409187602525, + 37.05216214177735 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9156279, + 36.83545326 + ], + [ + 131.9641247, + 36.84118017 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9161475, + 37.6311922 + ], + [ + 131.9651438, + 37.62540497 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9641247, + 36.84118017 + ], + [ + 132.0116973, + 36.85066465 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9651438, + 37.62540497 + ], + [ + 132.0131767, + 37.61582252 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 132.1246261181188, + 37.33971272991575 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.0116973, + 36.85066465 + ], + [ + 132.0578941, + 36.86381675 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.0131767, + 37.61582252 + ], + [ + 132.059777, + 37.60253859 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 132.1340414978442, + 37.21699385927595 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.0578941, + 36.86381675 + ], + [ + 132.1022759, + 36.88051166 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.059777, + 37.60253859 + ], + [ + 132.1044899, + 37.58568304 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1022759, + 36.88051166 + ], + [ + 132.14442, + 36.90059087 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1044899, + 37.58568304 + ], + [ + 132.1468801, + 37.56542057 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 132.1340414978442, + 37.21699385927595 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.14442, + 36.90059087 + ], + [ + 132.1839241, + 36.92386359 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1468801, + 37.56542057 + ], + [ + 132.1865358, + 37.541949 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1839241, + 36.92386359 + ], + [ + 132.22041, + 36.9501085 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1865358, + 37.541949 + ], + [ + 132.2230728, + 37.51549728 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.2802151948092, + 37.29316541576363 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.32812844805989, + 37.0769105881086 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.22041, + 36.9501085 + ], + [ + 132.2535272, + 36.9790758 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2230728, + 37.51549728 + ], + [ + 132.2561388, + 37.48632319 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2535272, + 36.9790758 + ], + [ + 132.282956, + 37.01048946 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2561388, + 37.48632319 + ], + [ + 132.2854161, + 37.45471069 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2802151948092, + 37.29316541576363 + ], + [ + 132.36806856583578, + 37.24564160289258 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.282956, + 37.01048946 + ], + [ + 132.3084111, + 37.04404979 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2854161, + 37.45471069 + ], + [ + 132.3106251, + 37.4209671 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3084111, + 37.04404979 + ], + [ + 132.32812844805989, + 37.0769105881086 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3106251, + 37.4209671 + ], + [ + 132.3315267, + 37.38542004 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.3296439, + 37.07943623 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.37221729698305, + 37.05522843008466 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3296439, + 37.07943623 + ], + [ + 132.3464452, + 37.11631032 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3315267, + 37.38542004 + ], + [ + 132.3479245, + 37.34841414 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3464452, + 37.11631032 + ], + [ + 132.3586477, + 37.15431884 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3479245, + 37.34841414 + ], + [ + 132.3596666, + 37.31030766 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3586477, + 37.15431884 + ], + [ + 132.3661272, + 37.19309722 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3596666, + 37.31030766 + ], + [ + 132.3666466, + 37.27146897 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3661272, + 37.19309722 + ], + [ + 132.3688046, + 37.23227291 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3666466, + 37.27146897 + ], + [ + 132.36806856583578, + 37.24564160289258 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.3688046, + 37.23227291 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.46744946519559, + 37.19188203500189 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.37221729698305, + 37.05522843008466 + ], + [ + 132.46744946519559, + 37.19188203500189 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.5, + 36.2 + ], + [ + 130.8, + 36.2 + ], + [ + 131.1, + 36.2 + ], + [ + 131.4, + 36.2 + ], + [ + 131.7, + 36.2 + ], + [ + 132.0, + 36.2 + ], + [ + 132.3, + 36.2 + ], + [ + 132.6, + 36.2 + ], + [ + 132.9, + 36.2 + ], + [ + 133.2, + 36.2 + ], + [ + 133.2, + 36.45 + ], + [ + 133.2, + 36.7 + ], + [ + 133.2, + 36.95 + ], + [ + 133.2, + 37.2 + ], + [ + 133.2, + 37.45 + ], + [ + 133.2, + 37.7 + ], + [ + 133.2, + 37.95 + ], + [ + 133.2, + 38.2 + ], + [ + 132.9, + 38.2 + ], + [ + 132.6, + 38.2 + ], + [ + 132.3, + 38.2 + ], + [ + 132.0, + 38.2 + ], + [ + 131.7, + 38.2 + ], + [ + 131.4, + 38.2 + ], + [ + 131.1, + 38.2 + ], + [ + 130.8, + 38.2 + ], + [ + 130.5, + 38.2 + ], + [ + 130.5, + 37.95 + ], + [ + 130.5, + 37.7 + ], + [ + 130.5, + 37.45 + ], + [ + 130.5, + 37.2 + ], + [ + 130.5, + 36.95 + ], + [ + 130.5, + 36.7 + ], + [ + 130.5, + 36.45 + ], + [ + 130.5, + 36.2 + ] + ] + }, + "properties": null + } + ] +} diff --git a/fixtures/polygonizer/input/split_touching_hole_drop_minimal.geojson b/fixtures/polygonizer/input/split_touching_hole_drop_minimal.geojson new file mode 100644 index 0000000..0b3be7d --- /dev/null +++ b/fixtures/polygonizer/input/split_touching_hole_drop_minimal.geojson @@ -0,0 +1,47 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [0.0, 0.0], + [10.0, 0.0], + [10.0, 10.0], + [0.0, 10.0], + [0.0, 0.0] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [3.0, 3.0], + [7.0, 3.0], + [7.0, 7.0], + [3.0, 7.0], + [3.0, 3.0] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [7.0, 7.0], + [8.0, 7.0], + [8.0, 8.0], + [7.0, 8.0], + [7.0, 7.0] + ] + }, + "properties": null + } + ] +} diff --git a/fixtures/polygonizer/input/split_touching_hole_priority_minimal.geojson b/fixtures/polygonizer/input/split_touching_hole_priority_minimal.geojson new file mode 100644 index 0000000..6683795 --- /dev/null +++ b/fixtures/polygonizer/input/split_touching_hole_priority_minimal.geojson @@ -0,0 +1,5088 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 125.90951698705308, + 40.43259517121252 + ], + [ + 131.5657278864061, + 43.62650386261877 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 127.25000000493503, + 40.000000020662945 + ], + [ + 130.00000000493503, + 42.29999997297923 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.04153269216172, + 34.08855120110449 + ], + [ + 128.52127569600694, + 34.117076315163935 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.17710560247056, + 39.370860733270014 + ], + [ + 128.6598588792955, + 38.993371166467035 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.20969384595506, + 39.57254783081945 + ], + [ + 128.8976213304674, + 39.906483568429316 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.3500000287769, + 38.6000000445048 + ], + [ + 128.75000000493503, + 38.73333336989085 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.52127569600694, + 34.117076315163935 + ], + [ + 128.80560010358445, + 34.20564214157995 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.6598588792955, + 38.993371166467035 + ], + [ + 128.81053360646507, + 38.7464928466837 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.75000000493503, + 38.73333336989085 + ], + [ + 128.81053360646507, + 38.7464928466837 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.80560010358445, + 34.20564214157995 + ], + [ + 128.82334607526414, + 33.96274868416723 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.81053360646507, + 38.7464928466837 + ], + [ + 129.0409852831041, + 38.36890117096838 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.81053360646507, + 38.7464928466837 + ], + [ + 130.66666675107456, + 39.14999999682109 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 128.8976213304674, + 39.906483568429316 + ], + [ + 129.524619993416, + 40.352669395684565 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.0409852831041, + 38.36890117096838 + ], + [ + 129.6859936086809, + 37.53290168213781 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.524619993416, + 40.352669395684565 + ], + [ + 129.65171402379625, + 40.38446966576513 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.65171402379625, + 40.38446966576513 + ], + [ + 129.92245787068956, + 40.60253850388464 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.6859936086809, + 37.53290168213781 + ], + [ + 129.8942009775316, + 36.980742372750605 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.72433465406053, + 34.99017754959997 + ], + [ + 129.81864708348863, + 34.854704775094355 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.72433465406053, + 34.99017754959997 + ], + [ + 129.82541793271653, + 35.33559600281652 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.8173844186937, + 34.852948107003535 + ], + [ + 129.81864708348863, + 34.854704775094355 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.8173844186937, + 34.852948107003535 + ], + [ + 129.91005605146043, + 34.53202358651098 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.82541793271653, + 35.33559600281652 + ], + [ + 129.85676234647386, + 35.33519641327795 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.82918017789476, + 35.35807148384985 + ], + [ + 129.84717362805955, + 35.434294857262934 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.82918017789476, + 35.35807148384985 + ], + [ + 129.85632413312547, + 35.356952347039545 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.84717362805955, + 35.434294857262934 + ], + [ + 130.01083820745103, + 36.019300617455805 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.85632413312547, + 35.356952347039545 + ], + [ + 129.85676234647386, + 35.33519641327795 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.8942009775316, + 36.980742372750605 + ], + [ + 129.89674132749192, + 36.28144828247961 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.89674132749192, + 36.28144828247961 + ], + [ + 129.99217861577623, + 36.127263941048945 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.91005605146043, + 34.53202358651098 + ], + [ + 130.07497304364793, + 34.60854283738073 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.92245787068956, + 40.60253850388464 + ], + [ + 130.08875554486863, + 40.714631475686396 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 129.99217861577623, + 36.127263941048945 + ], + [ + 130.01083820745103, + 36.019300617455805 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.00000000493503, + 42.29999997297923 + ], + [ + 130.70000005261875, + 42.29999997297923 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.07497304364793, + 34.60854283738073 + ], + [ + 130.16649454518907, + 34.58200446534094 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.08875554486863, + 40.714631475686396 + ], + [ + 130.1707333891069, + 40.92548099923071 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.13608210965745, + 41.1895157473081 + ], + [ + 130.1707333891069, + 40.92548099923071 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.13608210965745, + 41.1895157473081 + ], + [ + 130.20811861440293, + 41.42399350571569 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.16649454518907, + 34.58200446534094 + ], + [ + 130.47096293851487, + 34.46426955628332 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.1946455804979, + 41.545595802544916 + ], + [ + 130.20811861440293, + 41.42399350571569 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.1946455804979, + 41.545595802544916 + ], + [ + 130.3428277341997, + 41.723245538949335 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.3428277341997, + 41.723245538949335 + ], + [ + 130.55572026654832, + 41.906751789330805 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.47096293851487, + 34.46426955628332 + ], + [ + 130.538611349312, + 34.59332148003515 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.538611349312, + 34.59332148003515 + ], + [ + 130.72174232884996, + 34.93990389275488 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.55572026654832, + 41.906751789330805 + ], + [ + 130.64999788686387, + 41.898079790353144 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.64999788686387, + 41.898079790353144 + ], + [ + 130.95063237021225, + 42.01540194237351 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.66666675107456, + 39.14999999682109 + ], + [ + 130.71666670339084, + 39.29999997297923 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.70000005261875, + 42.29999997297923 + ], + [ + 130.88333333032108, + 42.14999999682109 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.71666663571946, + 39.30000010895666 + ], + [ + 130.7166667520986, + 39.30000012568466 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.71666663571946, + 39.30000010895666 + ], + [ + 131.33333319112413, + 41.2333332674497 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.71666670339084, + 39.29999997297923 + ], + [ + 130.7166667520986, + 39.30000012568466 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.7166667520986, + 39.30000012568466 + ], + [ + 131.3333332587955, + 41.23333336989085 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.7166667520986, + 39.30000012568466 + ], + [ + 138.83333319112413, + 40.46666661667761 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.72174232884996, + 34.93990389275488 + ], + [ + 130.93242209836595, + 35.135094799279535 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.88333333032108, + 42.14999999682109 + ], + [ + 130.95063237021225, + 42.01540194237351 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.93242209836595, + 35.135094799279535 + ], + [ + 131.15051144048326, + 35.14206019806799 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.95063237021225, + 42.01540194237351 + ], + [ + 131.20000005261875, + 41.51666667143504 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 130.95063237021225, + 42.01540194237351 + ], + [ + 131.24879282399766, + 42.13175860810217 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.15051144048326, + 35.14206019806799 + ], + [ + 131.4794425336992, + 35.02358976769384 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.19999998494737, + 41.51666656899389 + ], + [ + 131.20000009032384, + 41.516666591311626 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.19999998494737, + 41.51666656899389 + ], + [ + 131.33333319112413, + 41.2333332674497 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.20000005261875, + 41.51666667143504 + ], + [ + 131.20000009032384, + 41.516666591311626 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.20000009032384, + 41.516666591311626 + ], + [ + 131.3333332587955, + 41.23333336989085 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.20000009032384, + 41.516666591311626 + ], + [ + 131.7847494451677, + 41.640510954140986 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.24879282399766, + 42.13175860810217 + ], + [ + 131.54680102750413, + 41.63907710480627 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4794425336992, + 35.02358976769384 + ], + [ + 131.75572007581346, + 35.13236252236303 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.54680102750413, + 41.63907710480627 + ], + [ + 131.7847494451677, + 41.640510954140986 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5657278864061, + 43.62650386261877 + ], + [ + 138.98936479970567, + 47.93827024865087 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.75572007581346, + 35.13236252236303 + ], + [ + 132.2549039690172, + 35.52300898003515 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2549039690172, + 35.52300898003515 + ], + [ + 132.33140629216783, + 35.651364721536005 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.33140629216783, + 35.651364721536005 + ], + [ + 132.54063599988572, + 35.80912128853735 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.49800151273362, + 36.18276206421789 + ], + [ + 132.54063599988572, + 35.80912128853735 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.49800151273362, + 36.18276206421789 + ], + [ + 132.74247520848863, + 36.39071170258459 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.74247520848863, + 36.39071170258459 + ], + [ + 133.07532280370347, + 36.6651131288999 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.79428833409898, + 41.85432139801916 + ], + [ + 132.87195676252, + 42.14065901207861 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.79428833409898, + 41.85432139801916 + ], + [ + 138.83333319112413, + 43.13333336281713 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.87195676252, + 42.14065901207861 + ], + [ + 133.02050417348497, + 42.13700334000524 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.02050417348497, + 42.13700334000524 + ], + [ + 134.29323309346788, + 42.59795466828283 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.07532280370347, + 36.6651131288999 + ], + [ + 133.29041117116563, + 36.695769466637934 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.29041117116563, + 36.695769466637934 + ], + [ + 133.58193343564622, + 36.640718139886225 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.58193343564622, + 36.640718139886225 + ], + [ + 133.79708975240342, + 36.400268472909296 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.66149013921373, + 35.92121640610632 + ], + [ + 133.80231684133165, + 36.14261380600866 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.66149013921373, + 35.92121640610632 + ], + [ + 133.86180251523606, + 35.91874019074377 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.79708975240342, + 36.400268472909296 + ], + [ + 133.80231684133165, + 36.14261380600866 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 133.86180251523606, + 35.91874019074377 + ], + [ + 134.44879120275132, + 36.085333027123774 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 134.29323309346788, + 42.59795466828283 + ], + [ + 135.03674643918626, + 42.94185701775488 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 134.44879120275132, + 36.085333027123774 + ], + [ + 135.13928526326768, + 36.11452404427465 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 135.03674643918626, + 42.94185701775488 + ], + [ + 135.55498760625474, + 43.348386205911005 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 135.13928526326768, + 36.11452404427465 + ], + [ + 135.26768606587999, + 36.10011307167944 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 135.26768606587999, + 36.10011307167944 + ], + [ + 135.5427400438463, + 36.0337859766477 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 135.5427400438463, + 36.0337859766477 + ], + [ + 135.5792080728685, + 36.12769261765417 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 135.55498760625474, + 43.348386205911005 + ], + [ + 136.51891320630662, + 44.21457020211157 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 135.5792080728685, + 36.12769261765417 + ], + [ + 135.7967533437883, + 36.46571508812841 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 135.7967533437883, + 36.46571508812841 + ], + [ + 136.24264758512132, + 36.91704694199499 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 136.22520512029283, + 37.27649609017309 + ], + [ + 136.24264758512132, + 36.91704694199499 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 136.22520512029283, + 37.27649609017309 + ], + [ + 136.4591709940111, + 37.613666214227045 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 136.4591709940111, + 37.613666214227045 + ], + [ + 136.53361028119676, + 37.931401886224116 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 136.51891320630662, + 44.21457020211157 + ], + [ + 138.20392030164354, + 45.515768446206415 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 136.53361028119676, + 37.931401886224116 + ], + [ + 136.67150610372178, + 38.15095535683569 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 136.67150610372178, + 38.15095535683569 + ], + [ + 137.07090657636277, + 38.17601887154516 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 136.98123782559983, + 33.820195831536616 + ], + [ + 137.14632242604844, + 34.126605905770624 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.07090657636277, + 38.17601887154516 + ], + [ + 137.28481930181138, + 38.005623497247065 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.14632242604844, + 34.126605905770624 + ], + [ + 137.60926574155442, + 34.31641379761633 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.28481930181138, + 38.005623497247065 + ], + [ + 137.59269922658555, + 37.80573479103979 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.59269922658555, + 37.80573479103979 + ], + [ + 137.78812735959642, + 37.82504764962133 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.60926574155442, + 34.31641379761633 + ], + [ + 138.3766569463884, + 34.222200073479975 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.75957888051622, + 38.13473073410925 + ], + [ + 137.78812735959642, + 37.82504764962133 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.75957888051622, + 38.13473073410925 + ], + [ + 137.99558108731858, + 38.469143308877314 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 137.99558108731858, + 38.469143308877314 + ], + [ + 138.18207519933335, + 38.58265987801489 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.18207519933335, + 38.58265987801489 + ], + [ + 138.41489642545335, + 38.7083932535642 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.20392030164354, + 45.515768446206415 + ], + [ + 139.8047358362352, + 46.931316770791376 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.3766569463884, + 34.222200073479975 + ], + [ + 138.4017750589525, + 33.916477360009516 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.41489642545335, + 38.7083932535642 + ], + [ + 138.63589852735154, + 38.6779452936643 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.63589852735154, + 38.6779452936643 + ], + [ + 138.80585139676683, + 38.593224682092035 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.64769495027042, + 33.99694361368815 + ], + [ + 138.65609503285862, + 34.011734506289166 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.65609503285862, + 34.011734506289166 + ], + [ + 138.6662064840076, + 34.02577080408732 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.6662064840076, + 34.02577080408732 + ], + [ + 138.67793286340213, + 34.038916966120404 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.67793286340213, + 34.038916966120404 + ], + [ + 138.691161279845, + 34.05104603449504 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.691161279845, + 34.05104603449504 + ], + [ + 138.70576465623355, + 34.062040588061016 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.70576465623355, + 34.062040588061016 + ], + [ + 138.72160208718753, + 34.07179453055064 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.72160208718753, + 34.07179453055064 + ], + [ + 138.73852086560703, + 34.080213209788006 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.73852086560703, + 34.080213209788006 + ], + [ + 138.75635755555606, + 34.08721532503764 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.75635755555606, + 34.08721532503764 + ], + [ + 138.77493942277408, + 34.0927329270045 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.77493942277408, + 34.0927329270045 + ], + [ + 138.79408681886173, + 34.09671272913615 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.79408681886173, + 34.09671272913615 + ], + [ + 138.81361437337375, + 34.09911622683207 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.80585139676683, + 38.593224682092035 + ], + [ + 138.89083927556626, + 38.72250143456396 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.81361437337375, + 34.09911622683207 + ], + [ + 138.83333302037693, + 34.099919935862225 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.83333302037693, + 34.099919935862225 + ], + [ + 138.8530516673801, + 34.09911622683207 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.83333319112413, + 40.46666661667761 + ], + [ + 138.83333319112413, + 43.13333336281713 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.8530516673801, + 34.09911622683207 + ], + [ + 138.87257922189212, + 34.09671272913615 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.87257922189212, + 34.09671272913615 + ], + [ + 138.89172673718906, + 34.0927329270045 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.89083927556626, + 38.72250143456396 + ], + [ + 139.2462448446428, + 38.86402050423559 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.89172673718906, + 34.0927329270045 + ], + [ + 138.91030860440708, + 34.08721532503764 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.91030860440708, + 34.08721532503764 + ], + [ + 138.92814517514682, + 34.080213209788006 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.92814517514682, + 34.080213209788006 + ], + [ + 138.94506395356632, + 34.07179453055064 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.94506395356632, + 34.07179453055064 + ], + [ + 138.9609013845203, + 34.062040588061016 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.9609013845203, + 34.062040588061016 + ], + [ + 138.97550476090885, + 34.05104603449504 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.97550476090885, + 34.05104603449504 + ], + [ + 138.98873317735172, + 34.038916966120404 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.98873317735172, + 34.038916966120404 + ], + [ + 139.00045955674625, + 34.02577080408732 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 138.98936479970567, + 47.93827024865087 + ], + [ + 139.91712563916795, + 52.984622396706904 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.00045955674625, + 34.02577080408732 + ], + [ + 139.01057112710453, + 34.011734506289166 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.01057112710453, + 34.011734506289166 + ], + [ + 139.01897109048343, + 33.99694361368815 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.0326718656694, + 42.200830139397944 + ], + [ + 139.1071223585283, + 41.359325088738764 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.0326718656694, + 42.200830139397944 + ], + [ + 139.4952282278215, + 43.59813109803137 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.08606141492479, + 39.15089575219091 + ], + [ + 139.1337110368883, + 39.32569900917944 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.08606141492479, + 39.15089575219091 + ], + [ + 139.2462448446428, + 38.86402050423559 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.1071223585283, + 41.359325088738764 + ], + [ + 139.2972156374132, + 41.24540988373693 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.1337110368883, + 39.32569900917944 + ], + [ + 139.23663419171922, + 39.451707996606196 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.23663419171922, + 39.451707996606196 + ], + [ + 139.4365171758806, + 39.546686805962885 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.26861279889695, + 39.938996710061396 + ], + [ + 139.26884001180284, + 40.07221619057592 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.26861279889695, + 39.938996710061396 + ], + [ + 139.36650222226731, + 39.72797123360571 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.26884001180284, + 40.07221619057592 + ], + [ + 139.48251074239366, + 40.486479677438105 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.2972156374132, + 41.24540988373693 + ], + [ + 139.4322516290819, + 41.26847974228796 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.3663107721483, + 47.49393431115087 + ], + [ + 139.8047358362352, + 46.931316770791376 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.3663107721483, + 47.49393431115087 + ], + [ + 139.8993086187517, + 46.937296070336664 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.36650222226731, + 39.72797123360571 + ], + [ + 139.5814499227678, + 39.54912487435278 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.40540665074937, + 40.512441076516474 + ], + [ + 139.4383768407976, + 40.5836807863706 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.40540665074937, + 40.512441076516474 + ], + [ + 139.48251074239366, + 40.486479677438105 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.4322516290819, + 41.26847974228796 + ], + [ + 139.59843319341294, + 41.21928469109472 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.4365171758806, + 39.546686805962885 + ], + [ + 139.5814499227678, + 39.54912487435278 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.4383768407976, + 40.5836807863706 + ], + [ + 139.4782778589403, + 40.76618400978979 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.4782778589403, + 40.76618400978979 + ], + [ + 139.95911353513353, + 41.0299002306455 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.4952282278215, + 43.59813109803137 + ], + [ + 139.85076373502366, + 43.93735281395849 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.59843319341294, + 41.21928469109472 + ], + [ + 139.60985058232896, + 41.17869059014257 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.60985058232896, + 41.17869059014257 + ], + [ + 139.74605959340684, + 41.109813369988764 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.74605959340684, + 41.109813369988764 + ], + [ + 139.92528456136338, + 41.15534583496984 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.83317535802476, + 47.51435343193945 + ], + [ + 139.8993086187517, + 46.937296070336664 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.83317535802476, + 47.51435343193945 + ], + [ + 140.28133171483628, + 47.81698814797338 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.84996860906236, + 34.32891146111425 + ], + [ + 139.94307988568895, + 33.887936987161005 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.84996860906236, + 34.32891146111425 + ], + [ + 140.09255927487962, + 34.568412937402094 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.85076373502366, + 43.93735281395849 + ], + [ + 140.02544063016526, + 44.79538074898657 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 139.92528456136338, + 41.15534583496984 + ], + [ + 139.95911353513353, + 41.0299002306455 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.02544063016526, + 44.79538074898657 + ], + [ + 140.09824222013108, + 44.97841707634863 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.09255927487962, + 34.568412937402094 + ], + [ + 140.32197588369004, + 34.719169534921015 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.09824222013108, + 44.97841707634863 + ], + [ + 140.2862655489122, + 45.05796877312597 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.28133171483628, + 47.81698814797338 + ], + [ + 140.6528064577257, + 48.20434943604406 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.2862655489122, + 45.05796877312597 + ], + [ + 140.65091794416062, + 45.489464678048456 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.32197588369004, + 34.719169534921015 + ], + [ + 141.5149969427263, + 35.47014776635107 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.65091794416062, + 45.489464678048456 + ], + [ + 140.7806996671831, + 45.701386846780146 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.6528064577257, + 48.20434943604406 + ], + [ + 140.91145199224107, + 48.913813032388056 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.7806996671831, + 45.701386846780146 + ], + [ + 140.99482315465562, + 45.772784866570795 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.8489047853624, + 46.249456562280024 + ], + [ + 140.93420713826768, + 46.5079869406217 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.8489047853624, + 46.249456562280024 + ], + [ + 141.0645357935106, + 45.840605415582026 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.91145199224107, + 48.913813032388056 + ], + [ + 141.10681241437547, + 49.74327413010534 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.93420713826768, + 46.5079869406217 + ], + [ + 141.19966119214646, + 46.611142553567255 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 140.99482315465562, + 45.772784866570795 + ], + [ + 141.14938896581285, + 45.7409438269132 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.0645357935106, + 45.840605415582026 + ], + [ + 141.3013078539049, + 45.773108639001215 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.09714835569017, + 50.360651411294306 + ], + [ + 141.11406081601731, + 50.76625791954931 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.09714835569017, + 50.360651411294306 + ], + [ + 141.21944325849168, + 50.056388534783686 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.10681241437547, + 49.74327413010534 + ], + [ + 141.21944325849168, + 50.056388534783686 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.11406081601731, + 50.76625791954931 + ], + [ + 141.15859073087327, + 50.932983555077875 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.14938896581285, + 45.7409438269132 + ], + [ + 141.31114452764146, + 45.67400280404028 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.15859073087327, + 50.932983555077875 + ], + [ + 141.39732497617356, + 50.926422275781 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.19966119214646, + 46.611142553567255 + ], + [ + 141.45194190427415, + 46.58491937088903 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.24745672628038, + 36.312237896203364 + ], + [ + 141.5149969427263, + 35.47014776635107 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.24745672628038, + 36.312237896203364 + ], + [ + 141.61631792470567, + 36.94684187340673 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.27482121869676, + 47.76082292962011 + ], + [ + 141.28433745786302, + 48.065703071832026 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.27482121869676, + 47.76082292962011 + ], + [ + 141.38881581708543, + 47.52238861489233 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.27743762418382, + 42.096488632439936 + ], + [ + 141.52109568997972, + 42.272095121621454 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.27743762418382, + 42.096488632439936 + ], + [ + 141.63888972684495, + 41.700976051568354 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.28433745786302, + 48.065703071832026 + ], + [ + 141.43522876187913, + 48.22684065270361 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.3013078539049, + 45.773108639001215 + ], + [ + 141.5244180528795, + 45.896376051186884 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.31114452764146, + 45.67400280404028 + ], + [ + 141.53762143537156, + 45.739799179314936 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.38881581708543, + 47.52238861489233 + ], + [ + 141.54725402280442, + 47.42903558182653 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.39732497617356, + 50.926422275781 + ], + [ + 141.71131127759568, + 50.92525688576635 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.43271296903245, + 48.799486793755854 + ], + [ + 141.43522876187913, + 48.22684065270361 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.43271296903245, + 48.799486793755854 + ], + [ + 141.62810319348924, + 49.068935312508906 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.45194190427415, + 46.58491937088903 + ], + [ + 141.69390672132127, + 47.160459198235834 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.52109568997972, + 42.272095121621454 + ], + [ + 142.8067597715532, + 41.841869034051264 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.5244180528795, + 45.896376051186884 + ], + [ + 141.54183476850145, + 45.74188891816076 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.53762143537156, + 45.739799179314936 + ], + [ + 141.54183476850145, + 45.74188891816076 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.54725402280442, + 47.42903558182653 + ], + [ + 141.69390672132127, + 47.160459198235834 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.61631792470567, + 36.94684187340673 + ], + [ + 142.5542249052202, + 39.1152125971311 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.62810319348924, + 49.068935312508906 + ], + [ + 141.76245468541734, + 49.89127699303564 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.63888972684495, + 41.700976051568354 + ], + [ + 141.95321696683519, + 41.360001243829096 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.64101952001207, + 50.56034890580114 + ], + [ + 141.71131127759568, + 50.92525688576635 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.64101952001207, + 50.56034890580114 + ], + [ + 141.8183862535631, + 50.205834783791865 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.76245468541734, + 49.89127699303564 + ], + [ + 141.8183862535631, + 50.205834783791865 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.9390384523546, + 40.95789519715246 + ], + [ + 141.95321696683519, + 41.360001243829096 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 141.9390384523546, + 40.95789519715246 + ], + [ + 142.5985295145189, + 39.74348346161779 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.3666873, + 37.27146897 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.3672068, + 37.19309722 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3666873, + 37.27146897 + ], + [ + 131.3736674, + 37.31030766 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3672068, + 37.19309722 + ], + [ + 131.3746863, + 37.15431884 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3736674, + 37.31030766 + ], + [ + 131.3854095, + 37.34841414 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3746863, + 37.15431884 + ], + [ + 131.3868887, + 37.11631032 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3854095, + 37.34841414 + ], + [ + 131.4018073, + 37.38542004 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.3868887, + 37.11631032 + ], + [ + 131.4036901, + 37.07943623 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4018073, + 37.38542004 + ], + [ + 131.4227089, + 37.4209671 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4036901, + 37.07943623 + ], + [ + 131.4249229, + 37.04404979 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4227089, + 37.4209671 + ], + [ + 131.4479179, + 37.45471069 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4249229, + 37.04404979 + ], + [ + 131.4503779, + 37.01048946 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4479179, + 37.45471069 + ], + [ + 131.4771952, + 37.48632319 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4503779, + 37.01048946 + ], + [ + 131.4798068, + 36.9790758 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4771952, + 37.48632319 + ], + [ + 131.5102612, + 37.51549728 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.4798068, + 36.9790758 + ], + [ + 131.5129239, + 36.9501085 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5102612, + 37.51549728 + ], + [ + 131.5467982, + 37.541949 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5129239, + 36.9501085 + ], + [ + 131.5494099, + 36.92386359 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5467982, + 37.541949 + ], + [ + 131.5864539, + 37.56542057 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5494099, + 36.92386359 + ], + [ + 131.588914, + 36.90059087 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.5864539, + 37.56542057 + ], + [ + 131.628844, + 37.58568304 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.588914, + 36.90059087 + ], + [ + 131.6310581, + 36.88051166 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6789834344638, + 37.426338419556394 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6924993976829, + 37.063692228175476 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.628844, + 37.58568304 + ], + [ + 131.673557, + 37.60253859 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6310581, + 36.88051166 + ], + [ + 131.6754399, + 36.86381675 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.673557, + 37.60253859 + ], + [ + 131.7201573, + 37.61582252 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6754399, + 36.86381675 + ], + [ + 131.7216367, + 36.85066465 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.79570674559747, + 37.494760171748474 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.6924993976829, + 37.063692228175476 + ], + [ + 131.88096982742374, + 36.990452460251525 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7201573, + 37.61582252 + ], + [ + 131.7681902, + 37.62540497 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7216367, + 36.85066465 + ], + [ + 131.7692093, + 36.84118017 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7681902, + 37.62540497 + ], + [ + 131.8171865, + 37.6311922 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.7692093, + 36.84118017 + ], + [ + 131.817706, + 36.83545326 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.99086117128556, + 37.481714926898256 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.8171865, + 37.6311922 + ], + [ + 131.866667, + 37.63312759 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.817706, + 36.83545326 + ], + [ + 131.866667, + 36.83353824 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.866667, + 36.83353824 + ], + [ + 131.9156279, + 36.83545326 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.866667, + 37.63312759 + ], + [ + 131.9161475, + 37.6311922 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 132.04409187602525, + 37.05216214177735 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9156279, + 36.83545326 + ], + [ + 131.9641247, + 36.84118017 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9161475, + 37.6311922 + ], + [ + 131.9651438, + 37.62540497 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9641247, + 36.84118017 + ], + [ + 132.0116973, + 36.85066465 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.9651438, + 37.62540497 + ], + [ + 132.0131767, + 37.61582252 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 132.1246261181188, + 37.33971272991575 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.0116973, + 36.85066465 + ], + [ + 132.0578941, + 36.86381675 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.0131767, + 37.61582252 + ], + [ + 132.059777, + 37.60253859 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 132.1340414978442, + 37.21699385927595 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.0578941, + 36.86381675 + ], + [ + 132.1022759, + 36.88051166 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.059777, + 37.60253859 + ], + [ + 132.1044899, + 37.58568304 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1022759, + 36.88051166 + ], + [ + 132.14442, + 36.90059087 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1044899, + 37.58568304 + ], + [ + 132.1468801, + 37.56542057 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 132.1340414978442, + 37.21699385927595 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.14442, + 36.90059087 + ], + [ + 132.1839241, + 36.92386359 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1468801, + 37.56542057 + ], + [ + 132.1865358, + 37.541949 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1839241, + 36.92386359 + ], + [ + 132.22041, + 36.9501085 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1865358, + 37.541949 + ], + [ + 132.2230728, + 37.51549728 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.2802151948092, + 37.29316541576363 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.32812844805989, + 37.0769105881086 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.22041, + 36.9501085 + ], + [ + 132.2535272, + 36.9790758 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2230728, + 37.51549728 + ], + [ + 132.2561388, + 37.48632319 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2535272, + 36.9790758 + ], + [ + 132.282956, + 37.01048946 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2561388, + 37.48632319 + ], + [ + 132.2854161, + 37.45471069 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2802151948092, + 37.29316541576363 + ], + [ + 132.36806856583578, + 37.24564160289258 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.282956, + 37.01048946 + ], + [ + 132.3084111, + 37.04404979 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.2854161, + 37.45471069 + ], + [ + 132.3106251, + 37.4209671 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3084111, + 37.04404979 + ], + [ + 132.32812844805989, + 37.0769105881086 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3106251, + 37.4209671 + ], + [ + 132.3315267, + 37.38542004 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.3296439, + 37.07943623 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.37221729698305, + 37.05522843008466 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3296439, + 37.07943623 + ], + [ + 132.3464452, + 37.11631032 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3315267, + 37.38542004 + ], + [ + 132.3479245, + 37.34841414 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3464452, + 37.11631032 + ], + [ + 132.3586477, + 37.15431884 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3479245, + 37.34841414 + ], + [ + 132.3596666, + 37.31030766 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3586477, + 37.15431884 + ], + [ + 132.3661272, + 37.19309722 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3596666, + 37.31030766 + ], + [ + 132.3666466, + 37.27146897 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3661272, + 37.19309722 + ], + [ + 132.3688046, + 37.23227291 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.3666466, + 37.27146897 + ], + [ + 132.36806856583578, + 37.24564160289258 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.3688046, + 37.23227291 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.46744946519559, + 37.19188203500189 + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 132.37221729698305, + 37.05522843008466 + ], + [ + 132.46744946519559, + 37.19188203500189 + ] + ] + }, + "properties": null + } + ] +} diff --git a/fixtures/polygonizer/output/contained_island.geojson b/fixtures/polygonizer/output/contained_island.geojson new file mode 100644 index 0000000..57d59b6 --- /dev/null +++ b/fixtures/polygonizer/output/contained_island.geojson @@ -0,0 +1,142 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 0.0, + 0.0 + ], + [ + 20.0, + 0.0 + ], + [ + 20.0, + 20.0 + ], + [ + 0.0, + 20.0 + ], + [ + 0.0, + 0.0 + ] + ], + [ + [ + 3.0, + 3.0 + ], + [ + 3.0, + 10.0 + ], + [ + 10.0, + 10.0 + ], + [ + 10.0, + 3.0 + ], + [ + 3.0, + 3.0 + ] + ], + [ + [ + 13.0, + 13.0 + ], + [ + 13.0, + 18.0 + ], + [ + 18.0, + 18.0 + ], + [ + 18.0, + 13.0 + ], + [ + 13.0, + 13.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 3.0, + 3.0 + ], + [ + 10.0, + 3.0 + ], + [ + 10.0, + 10.0 + ], + [ + 3.0, + 10.0 + ], + [ + 3.0, + 3.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 13.0, + 13.0 + ], + [ + 18.0, + 13.0 + ], + [ + 18.0, + 18.0 + ], + [ + 13.0, + 18.0 + ], + [ + 13.0, + 13.0 + ] + ] + ] + }, + "properties": null + } + ] +} \ No newline at end of file diff --git a/fixtures/polygonizer/output/failed_hole.geojson b/fixtures/polygonizer/output/failed_hole.geojson new file mode 100644 index 0000000..3ec02fb --- /dev/null +++ b/fixtures/polygonizer/output/failed_hole.geojson @@ -0,0 +1,467 @@ +{ + "features": [ + { + "geometry": { + "coordinates": [ + [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6924993976829, + 37.06369222817547 + ], + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 132.1340414978442, + 37.21699385927595 + ], + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.6159497978044, + 37.24040921335645 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.3672068, + 37.19309722 + ], + [ + 131.3746863, + 37.15431884 + ], + [ + 131.3868887, + 37.11631032 + ], + [ + 131.4036901, + 37.07943623 + ], + [ + 131.4249229, + 37.04404979 + ], + [ + 131.4503779, + 37.01048946 + ], + [ + 131.4798068, + 36.9790758 + ], + [ + 131.5129239, + 36.9501085 + ], + [ + 131.5494099, + 36.92386359 + ], + [ + 131.588914, + 36.90059087 + ], + [ + 131.6310581, + 36.88051166 + ], + [ + 131.6754399, + 36.86381675 + ], + [ + 131.7216367, + 36.85066465 + ], + [ + 131.7692093, + 36.84118017 + ], + [ + 131.817706, + 36.83545326 + ], + [ + 131.866667, + 36.83353824 + ], + [ + 131.9156279, + 36.83545326 + ], + [ + 131.9641247, + 36.84118017 + ], + [ + 132.0116973, + 36.85066465 + ], + [ + 132.0578941, + 36.86381675 + ], + [ + 132.1022759, + 36.88051166 + ], + [ + 132.14442, + 36.90059087 + ], + [ + 132.1839241, + 36.92386359 + ], + [ + 132.22041, + 36.9501085 + ], + [ + 132.2535272, + 36.9790758 + ], + [ + 132.282956, + 37.01048946 + ], + [ + 132.3084111, + 37.04404979 + ], + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.2802151948092, + 37.29316541576363 + ], + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.3666466, + 37.27146897 + ], + [ + 132.3596666, + 37.31030766 + ], + [ + 132.3479245, + 37.34841414 + ], + [ + 132.3315267, + 37.38542004 + ], + [ + 132.3106251, + 37.4209671 + ], + [ + 132.2854161, + 37.45471069 + ], + [ + 132.2561388, + 37.48632319 + ], + [ + 132.2230728, + 37.51549728 + ], + [ + 132.1865358, + 37.541949 + ], + [ + 132.1468801, + 37.56542057 + ], + [ + 132.1044899, + 37.58568304 + ], + [ + 132.059777, + 37.60253859 + ], + [ + 132.0131767, + 37.61582252 + ], + [ + 131.9651438, + 37.62540497 + ], + [ + 131.9161475, + 37.6311922 + ], + [ + 131.866667, + 37.63312759 + ], + [ + 131.8171865, + 37.6311922 + ], + [ + 131.7681902, + 37.62540497 + ], + [ + 131.7201573, + 37.61582252 + ], + [ + 131.673557, + 37.60253859 + ], + [ + 131.628844, + 37.58568304 + ], + [ + 131.5864539, + 37.56542057 + ], + [ + 131.5467982, + 37.541949 + ], + [ + 131.5102612, + 37.51549728 + ], + [ + 131.4771952, + 37.48632319 + ], + [ + 131.4479179, + 37.45471069 + ], + [ + 131.4227089, + 37.4209671 + ], + [ + 131.4018073, + 37.38542004 + ], + [ + 131.3854095, + 37.34841414 + ], + [ + 131.3736674, + 37.31030766 + ], + [ + 131.3666873, + 37.27146897 + ], + [ + 131.3645294, + 37.23227291 + ] + ], + [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 132.1340414978442, + 37.21699385927595 + ], + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 131.6924993976829, + 37.06369222817547 + ], + [ + 131.6159497978044, + 37.24040921335645 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.3296439, + 37.07943623 + ], + [ + 132.3464452, + 37.11631032 + ], + [ + 132.3586477, + 37.15431884 + ], + [ + 132.3661272, + 37.19309722 + ], + [ + 132.3688046, + 37.23227291 + ], + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.2802151948092, + 37.29316541576363 + ], + [ + 132.1992415537936, + 37.140295005641654 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.37221729698305, + 37.05522843008466 + ], + [ + 132.4674494651956, + 37.19188203500189 + ], + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.3688046, + 37.23227291 + ], + [ + 132.3661272, + 37.19309722 + ], + [ + 132.3586477, + 37.15431884 + ], + [ + 132.3464452, + 37.11631032 + ], + [ + 132.3296439, + 37.07943623 + ], + [ + 132.32812844805989, + 37.0769105881086 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + } + ], + "type": "FeatureCollection" +} diff --git a/fixtures/polygonizer/output/minimal_secondary_probe_split.geojson b/fixtures/polygonizer/output/minimal_secondary_probe_split.geojson new file mode 100644 index 0000000..9412d46 --- /dev/null +++ b/fixtures/polygonizer/output/minimal_secondary_probe_split.geojson @@ -0,0 +1,173 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 90.0, + 10.0 + ], + [ + 130.0, + 10.0 + ], + [ + 130.0, + 30.0 + ], + [ + 90.0, + 30.0 + ], + [ + 90.0, + 10.0 + ] + ], + [ + [ + 100.0, + 16.0 + ], + [ + 100.0, + 26.0 + ], + [ + 122.0, + 26.0 + ], + [ + 122.0, + 16.0 + ], + [ + 100.0, + 16.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 100.0, + 16.0 + ], + [ + 122.0, + 16.0 + ], + [ + 122.0, + 26.0 + ], + [ + 100.0, + 26.0 + ], + [ + 100.0, + 16.0 + ] + ], + [ + [ + 108.0, + 19.0 + ], + [ + 108.0, + 22.0 + ], + [ + 113.0, + 22.0 + ], + [ + 113.0, + 19.0 + ], + [ + 108.0, + 19.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 108.0, + 16.0 + ], + [ + 113.0, + 16.0 + ], + [ + 113.0, + 19.0 + ], + [ + 108.0, + 19.0 + ], + [ + 108.0, + 16.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 108.0, + 19.0 + ], + [ + 113.0, + 19.0 + ], + [ + 113.0, + 22.0 + ], + [ + 108.0, + 22.0 + ], + [ + 108.0, + 19.0 + ] + ] + ] + }, + "properties": null + } + ] +} \ No newline at end of file diff --git a/fixtures/polygonizer/output/nested_shell_overlap_minimal.geojson b/fixtures/polygonizer/output/nested_shell_overlap_minimal.geojson new file mode 100644 index 0000000..7ecaa61 --- /dev/null +++ b/fixtures/polygonizer/output/nested_shell_overlap_minimal.geojson @@ -0,0 +1,161 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.4036901, + 37.07943623 + ], + [ + 131.6754399, + 36.86381675 + ], + [ + 131.866667, + 36.83353824 + ], + [ + 132.0578941, + 36.86381675 + ], + [ + 132.3296439, + 37.07943623 + ], + [ + 132.3688046, + 37.23227291 + ], + [ + 132.3315267, + 37.38542004 + ], + [ + 132.059777, + 37.60253859 + ], + [ + 131.866667, + 37.63312759 + ], + [ + 131.673557, + 37.60253859 + ], + [ + 131.4018073, + 37.38542004 + ], + [ + 131.3645294, + 37.23227291 + ] + ], + [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 132.1340414978442, + 37.21699385927595 + ], + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 131.6924993976829, + 37.06369222817547 + ], + [ + 131.6159497978044, + 37.24040921335645 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6924993976829, + 37.06369222817547 + ], + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 132.1340414978442, + 37.21699385927595 + ], + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.6159497978044, + 37.24040921335645 + ] + ] + ] + }, + "properties": null + } + ] +} \ No newline at end of file diff --git a/fixtures/polygonizer/output/ownership_area_priority_enclosing_shell_minimal.geojson b/fixtures/polygonizer/output/ownership_area_priority_enclosing_shell_minimal.geojson new file mode 100644 index 0000000..797a78a --- /dev/null +++ b/fixtures/polygonizer/output/ownership_area_priority_enclosing_shell_minimal.geojson @@ -0,0 +1,825 @@ +{ + "features": [ + { + "geometry": { + "coordinates": [ + [ + [ + 130.5, + 36.2 + ], + [ + 130.8, + 36.2 + ], + [ + 131.1, + 36.2 + ], + [ + 131.4, + 36.2 + ], + [ + 131.7, + 36.2 + ], + [ + 132.0, + 36.2 + ], + [ + 132.3, + 36.2 + ], + [ + 132.6, + 36.2 + ], + [ + 132.9, + 36.2 + ], + [ + 133.2, + 36.2 + ], + [ + 133.2, + 36.45 + ], + [ + 133.2, + 36.7 + ], + [ + 133.2, + 36.95 + ], + [ + 133.2, + 37.2 + ], + [ + 133.2, + 37.45 + ], + [ + 133.2, + 37.7 + ], + [ + 133.2, + 37.95 + ], + [ + 133.2, + 38.2 + ], + [ + 132.9, + 38.2 + ], + [ + 132.6, + 38.2 + ], + [ + 132.3, + 38.2 + ], + [ + 132.0, + 38.2 + ], + [ + 131.7, + 38.2 + ], + [ + 131.4, + 38.2 + ], + [ + 131.1, + 38.2 + ], + [ + 130.8, + 38.2 + ], + [ + 130.5, + 38.2 + ], + [ + 130.5, + 37.95 + ], + [ + 130.5, + 37.7 + ], + [ + 130.5, + 37.45 + ], + [ + 130.5, + 37.2 + ], + [ + 130.5, + 36.95 + ], + [ + 130.5, + 36.7 + ], + [ + 130.5, + 36.45 + ], + [ + 130.5, + 36.2 + ] + ], + [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.3666873, + 37.27146897 + ], + [ + 131.3736674, + 37.31030766 + ], + [ + 131.3854095, + 37.34841414 + ], + [ + 131.4018073, + 37.38542004 + ], + [ + 131.4227089, + 37.4209671 + ], + [ + 131.4479179, + 37.45471069 + ], + [ + 131.4771952, + 37.48632319 + ], + [ + 131.5102612, + 37.51549728 + ], + [ + 131.5467982, + 37.541949 + ], + [ + 131.5864539, + 37.56542057 + ], + [ + 131.628844, + 37.58568304 + ], + [ + 131.673557, + 37.60253859 + ], + [ + 131.7201573, + 37.61582252 + ], + [ + 131.7681902, + 37.62540497 + ], + [ + 131.8171865, + 37.6311922 + ], + [ + 131.866667, + 37.63312759 + ], + [ + 131.9161475, + 37.6311922 + ], + [ + 131.9651438, + 37.62540497 + ], + [ + 132.0131767, + 37.61582252 + ], + [ + 132.059777, + 37.60253859 + ], + [ + 132.1044899, + 37.58568304 + ], + [ + 132.1468801, + 37.56542057 + ], + [ + 132.1865358, + 37.541949 + ], + [ + 132.2230728, + 37.51549728 + ], + [ + 132.2561388, + 37.48632319 + ], + [ + 132.2854161, + 37.45471069 + ], + [ + 132.3106251, + 37.4209671 + ], + [ + 132.3315267, + 37.38542004 + ], + [ + 132.3479245, + 37.34841414 + ], + [ + 132.3596666, + 37.31030766 + ], + [ + 132.3666466, + 37.27146897 + ], + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.2802151948092, + 37.29316541576363 + ], + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.3084111, + 37.04404979 + ], + [ + 132.282956, + 37.01048946 + ], + [ + 132.2535272, + 36.9790758 + ], + [ + 132.22041, + 36.9501085 + ], + [ + 132.1839241, + 36.92386359 + ], + [ + 132.14442, + 36.90059087 + ], + [ + 132.1022759, + 36.88051166 + ], + [ + 132.0578941, + 36.86381675 + ], + [ + 132.0116973, + 36.85066465 + ], + [ + 131.9641247, + 36.84118017 + ], + [ + 131.9156279, + 36.83545326 + ], + [ + 131.866667, + 36.83353824 + ], + [ + 131.817706, + 36.83545326 + ], + [ + 131.7692093, + 36.84118017 + ], + [ + 131.7216367, + 36.85066465 + ], + [ + 131.6754399, + 36.86381675 + ], + [ + 131.6310581, + 36.88051166 + ], + [ + 131.588914, + 36.90059087 + ], + [ + 131.5494099, + 36.92386359 + ], + [ + 131.5129239, + 36.9501085 + ], + [ + 131.4798068, + 36.9790758 + ], + [ + 131.4503779, + 37.01048946 + ], + [ + 131.4249229, + 37.04404979 + ], + [ + 131.4036901, + 37.07943623 + ], + [ + 131.3868887, + 37.11631032 + ], + [ + 131.3746863, + 37.15431884 + ], + [ + 131.3672068, + 37.19309722 + ], + [ + 131.3645294, + 37.23227291 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6924993976829, + 37.06369222817547 + ], + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 132.1340414978442, + 37.21699385927595 + ], + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.6159497978044, + 37.24040921335645 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + 131.3645294, + 37.23227291 + ], + [ + 131.3672068, + 37.19309722 + ], + [ + 131.3746863, + 37.15431884 + ], + [ + 131.3868887, + 37.11631032 + ], + [ + 131.4036901, + 37.07943623 + ], + [ + 131.4249229, + 37.04404979 + ], + [ + 131.4503779, + 37.01048946 + ], + [ + 131.4798068, + 36.9790758 + ], + [ + 131.5129239, + 36.9501085 + ], + [ + 131.5494099, + 36.92386359 + ], + [ + 131.588914, + 36.90059087 + ], + [ + 131.6310581, + 36.88051166 + ], + [ + 131.6754399, + 36.86381675 + ], + [ + 131.7216367, + 36.85066465 + ], + [ + 131.7692093, + 36.84118017 + ], + [ + 131.817706, + 36.83545326 + ], + [ + 131.866667, + 36.83353824 + ], + [ + 131.9156279, + 36.83545326 + ], + [ + 131.9641247, + 36.84118017 + ], + [ + 132.0116973, + 36.85066465 + ], + [ + 132.0578941, + 36.86381675 + ], + [ + 132.1022759, + 36.88051166 + ], + [ + 132.14442, + 36.90059087 + ], + [ + 132.1839241, + 36.92386359 + ], + [ + 132.22041, + 36.9501085 + ], + [ + 132.2535272, + 36.9790758 + ], + [ + 132.282956, + 37.01048946 + ], + [ + 132.3084111, + 37.04404979 + ], + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.1992415537936, + 37.140295005641654 + ], + [ + 132.2802151948092, + 37.29316541576363 + ], + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.3666466, + 37.27146897 + ], + [ + 132.3596666, + 37.31030766 + ], + [ + 132.3479245, + 37.34841414 + ], + [ + 132.3315267, + 37.38542004 + ], + [ + 132.3106251, + 37.4209671 + ], + [ + 132.2854161, + 37.45471069 + ], + [ + 132.2561388, + 37.48632319 + ], + [ + 132.2230728, + 37.51549728 + ], + [ + 132.1865358, + 37.541949 + ], + [ + 132.1468801, + 37.56542057 + ], + [ + 132.1044899, + 37.58568304 + ], + [ + 132.059777, + 37.60253859 + ], + [ + 132.0131767, + 37.61582252 + ], + [ + 131.9651438, + 37.62540497 + ], + [ + 131.9161475, + 37.6311922 + ], + [ + 131.866667, + 37.63312759 + ], + [ + 131.8171865, + 37.6311922 + ], + [ + 131.7681902, + 37.62540497 + ], + [ + 131.7201573, + 37.61582252 + ], + [ + 131.673557, + 37.60253859 + ], + [ + 131.628844, + 37.58568304 + ], + [ + 131.5864539, + 37.56542057 + ], + [ + 131.5467982, + 37.541949 + ], + [ + 131.5102612, + 37.51549728 + ], + [ + 131.4771952, + 37.48632319 + ], + [ + 131.4479179, + 37.45471069 + ], + [ + 131.4227089, + 37.4209671 + ], + [ + 131.4018073, + 37.38542004 + ], + [ + 131.3854095, + 37.34841414 + ], + [ + 131.3736674, + 37.31030766 + ], + [ + 131.3666873, + 37.27146897 + ], + [ + 131.3645294, + 37.23227291 + ] + ], + [ + [ + 131.6159497978044, + 37.24040921335645 + ], + [ + 131.6789834344638, + 37.426338419556394 + ], + [ + 131.79570674559747, + 37.494760171748474 + ], + [ + 131.99086117128556, + 37.481714926898256 + ], + [ + 132.1246261181188, + 37.33971272991575 + ], + [ + 132.1340414978442, + 37.21699385927595 + ], + [ + 132.04409187602525, + 37.05216214177735 + ], + [ + 131.88096982742374, + 36.990452460251525 + ], + [ + 131.6924993976829, + 37.06369222817547 + ], + [ + 131.6159497978044, + 37.24040921335645 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [ + [ + 132.32812844805989, + 37.0769105881086 + ], + [ + 132.37221729698305, + 37.05522843008466 + ], + [ + 132.4674494651956, + 37.19188203500189 + ], + [ + 132.36806856583578, + 37.24564160289258 + ], + [ + 132.3688046, + 37.23227291 + ], + [ + 132.3661272, + 37.19309722 + ], + [ + 132.3586477, + 37.15431884 + ], + [ + 132.3464452, + 37.11631032 + ], + [ + 132.3296439, + 37.07943623 + ], + [ + 132.32812844805989, + 37.0769105881086 + ] + ] + ], + "type": "Polygon" + }, + "properties": null, + "type": "Feature" + } + ], + "type": "FeatureCollection" +} \ No newline at end of file diff --git a/fixtures/polygonizer/output/split_touching_hole_drop_minimal.geojson b/fixtures/polygonizer/output/split_touching_hole_drop_minimal.geojson new file mode 100644 index 0000000..4935468 --- /dev/null +++ b/fixtures/polygonizer/output/split_touching_hole_drop_minimal.geojson @@ -0,0 +1,142 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 0.0, + 0.0 + ], + [ + 10.0, + 0.0 + ], + [ + 10.0, + 10.0 + ], + [ + 0.0, + 10.0 + ], + [ + 0.0, + 0.0 + ] + ], + [ + [ + 3.0, + 3.0 + ], + [ + 3.0, + 7.0 + ], + [ + 7.0, + 7.0 + ], + [ + 7.0, + 3.0 + ], + [ + 3.0, + 3.0 + ] + ], + [ + [ + 7.0, + 7.0 + ], + [ + 7.0, + 8.0 + ], + [ + 8.0, + 8.0 + ], + [ + 8.0, + 7.0 + ], + [ + 7.0, + 7.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 7.0, + 7.0 + ], + [ + 8.0, + 7.0 + ], + [ + 8.0, + 8.0 + ], + [ + 7.0, + 8.0 + ], + [ + 7.0, + 7.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 3.0, + 3.0 + ], + [ + 7.0, + 3.0 + ], + [ + 7.0, + 7.0 + ], + [ + 3.0, + 7.0 + ], + [ + 3.0, + 3.0 + ] + ] + ] + }, + "properties": null + } + ] +} \ No newline at end of file diff --git a/src/graph.rs b/src/graph.rs index f1afd41..3c373e0 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -139,6 +139,47 @@ fn ring_to_valid_linestring(ring: &[Edge]) -> Option(ring: Vec>) -> Vec>> { + // Build a map: from-node → first position where we saw it. + let mut first_occurrence: BTreeMap, usize> = BTreeMap::new(); + let mut split_at: Option<(usize, usize)> = None; + + for (position, edge) in ring.iter().enumerate() { + if let Some(&first_position) = first_occurrence.get(&edge.from) { + split_at = Some((first_position, position)); + break; + } + first_occurrence.insert(edge.from, position); + } + + let (first_position, second_position) = match split_at { + Some(positions) => positions, + None => return vec![ring], // no repeated node, nothing to split + }; + + // ring[first_position..second_position] forms one sub-ring (the loop + // that starts and ends at the pinch node). + let inner_ring: Vec> = ring[first_position..second_position].to_vec(); + + // The remainder is everything before + everything from second_position onward. + let mut outer_ring: Vec> = ring[..first_position].to_vec(); + outer_ring.extend_from_slice(&ring[second_position..]); + + // Recurse on both halves in case there are additional pinch points. + let mut result = Vec::new(); + result.extend(split_edge_ring_at_pinch_points(inner_ring)); + result.extend(split_edge_ring_at_pinch_points(outer_ring)); + result +} + #[derive(Debug)] pub(crate) struct PolygonizerGraph { nodes_to_outbound_edges: BTreeMap, BTreeSet>>, @@ -453,7 +494,14 @@ impl PolygonizerGraph { { let edge_rings = self.get_minimal_edge_rings(); - let valid_rings: Vec<_> = edge_rings + // Split any figure-8 rings at pinch-point nodes before the validity + // filter so that both sub-regions survive as separate polygons. + let split_rings: Vec<_> = edge_rings + .into_iter() + .flat_map(split_edge_ring_at_pinch_points) + .collect(); + + let valid_rings: Vec<_> = split_rings .into_iter() .filter_map(|ring| ring_to_valid_linestring(&ring)) .collect(); @@ -466,16 +514,52 @@ impl PolygonizerGraph { .filter(|polygon| polygon.is_valid()) .collect(); - MultiPolygon( + let stage_inferred_holes = + topology_cleanup::infer_parent_holes_when_output_has_no_holes(valid_polygons); + + let stage_split_touching = + topology_cleanup::split_touching_boundary_polygons(stage_inferred_holes); + + let stage_infer_contained = + topology_cleanup::infer_contained_standalone_polygons_as_holes(stage_split_touching); + + let stage_remove_non_unique = + topology_cleanup::remove_non_unique_interior_points_for_touching_topology( + stage_infer_contained, + ); + + let stage_remove_redundant = topology_cleanup::remove_redundant_overlapping_standalone_polygons( - topology_cleanup::remove_non_unique_interior_points_for_touching_topology( - topology_cleanup::split_touching_boundary_polygons( - topology_cleanup::infer_parent_holes_when_output_has_no_holes( - valid_polygons, - ), - ), - ), - ), - ) + stage_remove_non_unique, + ); + + // Carve contained standalone polygons as holes of their parents. + // This handles "island" polygons that sit fully inside a larger + // polygon, whether or not the parent already has holes. + let stage_carve_holes = + topology_cleanup::carve_contained_standalones_as_holes(stage_remove_redundant); + + // Second pass: after removal steps, re-assign standalone polygons + // as holes where appropriate (removal may have changed containment). + let stage_final_holes = + topology_cleanup::infer_contained_standalone_polygons_as_holes(stage_carve_holes); + + // Merge touching holes: find standalone polygons that bridge existing + // holes at shared vertices and merge them into combined holes. + let stage_merge_holes = + topology_cleanup::merge_touching_holes_in_polygons(stage_final_holes); + + // Second split pass: now that holes are merged, split polygons + // where the merged hole creates touching topology. + let stage_final_split = + topology_cleanup::split_touching_boundary_polygons(stage_merge_holes); + + // Final carve pass: after all splitting and merging, some contained + // standalone polygons may no longer sit inside a hole. Carve them + // into their containing parent to eliminate overlaps. + let stage_final_carve = + topology_cleanup::carve_contained_standalones_as_holes(stage_final_split); + + MultiPolygon(stage_final_carve) } } diff --git a/src/graph/topology_cleanup.rs b/src/graph/topology_cleanup.rs index c79cbbd..c5d9c6c 100644 --- a/src/graph/topology_cleanup.rs +++ b/src/graph/topology_cleanup.rs @@ -54,6 +54,16 @@ fn polygon_unique_boundary_segment_count( .count() } +fn polygons_share_any_boundary_segment(a: &Polygon, b: &Polygon) -> bool { + let a_lines = polygon_boundary_lines(a); + let b_lines = polygon_boundary_lines(b); + a_lines.iter().any(|a_line| { + b_lines + .iter() + .any(|b_line| lines_have_same_endpoints(a_line, b_line)) + }) +} + pub(super) fn remove_non_unique_interior_points_for_touching_topology< T: GeoFloat + rstar::RTreeNum, >( @@ -121,6 +131,25 @@ pub(super) fn remove_non_unique_interior_points_for_touching_topology< continue; } + // Don't remove a polygon that doesn't share any boundary + // segments with the owner. If they share no boundary, the owner + // is simply nested inside the candidate (containment), not a + // redundant overlapping polygon. + if !polygons_share_any_boundary_segment( + candidate_polygon, + ¤t_polygons[owner_index], + ) { + continue; + } + + // Don't remove a polygon that fully contains the owner. + // When the candidate polygon contains the owner, the owner + // is a nested face inside the candidate — removing the outer + // polygon would destroy a legitimate region. + if candidate_polygon.contains(¤t_polygons[owner_index]) { + continue; + } + polygon_indices_to_remove.insert(candidate_index); } } @@ -347,10 +376,19 @@ pub(super) fn remove_redundant_overlapping_standalone_polygons( }; let is_redundant_overlap = polygons.iter().enumerate().any(|(other_index, other)| { - other_index != polygon_index - && other.exterior() != polygon.exterior() - && !other.interiors().is_empty() - && other.contains(&interior_point) + if other_index == polygon_index + || other.exterior() == polygon.exterior() + || other.interiors().is_empty() + { + return false; + } + if !other.contains(&interior_point) { + return false; + } + // Don't count it as redundant if the parent fully contains this polygon. + // That's simple containment, not redundant overlap. + let parent_contains_child = other.contains(polygon); + !parent_contains_child }); if is_redundant_overlap @@ -463,3 +501,313 @@ pub(super) fn infer_parent_holes_when_output_has_no_holes( + polygons: Vec>, +) -> Vec> { + let mut polygons = polygons; + + for child_index in 0..polygons.len() { + if !polygons[child_index].interiors().is_empty() { + continue; + } + + let child_interior_point = match polygons[child_index].interior_point() { + Some(point) => point, + None => continue, + }; + let min_area = T::from(1e-9).unwrap_or(T::epsilon()); + if polygons[child_index].unsigned_area() <= min_area { + continue; + } + + let child_exterior = polygons[child_index].exterior().clone(); + + // Find the smallest containing polygon that has a different exterior. + let mut candidate_parents: Vec<(usize, T)> = polygons + .iter() + .enumerate() + .filter_map(|(parent_index, parent_polygon)| { + if parent_index == child_index { + return None; + } + if parent_polygon.exterior() == &child_exterior { + return None; + } + if !parent_polygon.contains(&child_interior_point) { + return None; + } + Some((parent_index, parent_polygon.unsigned_area())) + }) + .collect(); + candidate_parents.sort_by(|a, b| a.1.total_cmp(&b.1)); + + if candidate_parents.is_empty() { + continue; + } + + for (parent_index, _) in candidate_parents { + // Skip if the child is already a hole of this parent. + if polygons[parent_index] + .interiors() + .iter() + .any(|hole| hole == &child_exterior) + { + continue; + } + + // Skip if the parent has no holes and a sibling polygon with the + // same exterior already has holes — the parent is the "plain" + // variant that should stay hole-free. + if polygons[parent_index].interiors().is_empty() { + let sibling_has_holes = polygons.iter().enumerate().any( + |(other_index, other_polygon)| { + other_index != parent_index + && other_polygon.exterior() == polygons[parent_index].exterior() + && !other_polygon.interiors().is_empty() + }, + ); + if sibling_has_holes { + continue; + } + } + + let mut new_holes = polygons[parent_index].interiors().to_vec(); + new_holes.push(child_exterior.clone()); + + let candidate = + Polygon::new(polygons[parent_index].exterior().clone(), new_holes) + .orient(Direction::Default); + if candidate.is_valid() { + polygons[parent_index] = candidate; + break; + } + } + } + + polygons +} + +pub(super) fn infer_contained_standalone_polygons_as_holes( + polygons: Vec>, +) -> Vec> { + let mut polygons = polygons; + + for child_polygon_index in 0..polygons.len() { + if !polygons[child_polygon_index].interiors().is_empty() { + continue; + } + + let child_interior_point = match polygons[child_polygon_index].interior_point() { + Some(point) => point, + None => continue, + }; + let min_child_area = T::from(1e-9).unwrap_or(T::epsilon()); + if polygons[child_polygon_index].unsigned_area() <= min_child_area { + continue; + } + + // Only skip standalone polygons with unique boundary segments if + // their area is above a threshold. Tiny polygons with unique edges + // are often artifacts (faces between adjacent holes) that should + // be absorbed as holes of the enclosing polygon. + let child_area = polygons[child_polygon_index].unsigned_area(); + let tiny_threshold = T::from(0.1).unwrap_or(T::epsilon()); + if child_area > tiny_threshold + && polygon_unique_boundary_segment_count(&polygons, child_polygon_index) > 0 + { + continue; + } + + let child_exterior = polygons[child_polygon_index].exterior().clone(); + + let mut candidate_parent_indices: Vec = polygons + .iter() + .enumerate() + .filter_map(|(parent_polygon_index, parent_polygon)| { + if parent_polygon_index == child_polygon_index { + return None; + } + if parent_polygon.interiors().len() < 2 { + return None; + } + if parent_polygon.exterior() == polygons[child_polygon_index].exterior() { + return None; + } + + // Check containment using only the parent's exterior ring + // (ignoring existing holes), since the child might be between + // existing holes. + let parent_exterior_only = Polygon::new(parent_polygon.exterior().clone(), vec![]); + if !parent_exterior_only.contains(&child_interior_point) { + return None; + } + Some(parent_polygon_index) + }) + .collect(); + + candidate_parent_indices.sort_by(|left_index, right_index| { + polygons[*left_index] + .unsigned_area() + .total_cmp(&polygons[*right_index].unsigned_area()) + }); + + for parent_polygon_index in candidate_parent_indices { + let mut candidate_holes = polygons[parent_polygon_index].interiors().to_vec(); + + if candidate_holes.iter().any(|hole| hole == &child_exterior) { + continue; + } + + candidate_holes.push(child_exterior.clone()); + let candidate_parent_polygon = + Polygon::new(polygons[parent_polygon_index].exterior().clone(), candidate_holes) + .orient(Direction::Default); + if !candidate_parent_polygon.is_valid() { + continue; + } + + polygons[parent_polygon_index] = candidate_parent_polygon; + break; + } + } + + polygons +} + +/// For each polygon with holes, find standalone polygons (from the `polygons` +/// list) that are contained within the polygon's exterior AND share vertices +/// with existing holes. These standalone polygons are "gap fills" between +/// holes, and together with the existing holes they form a connected hole +/// region. Merge all connected hole components (existing holes + matching +/// standalone polygons) into single combined holes. +pub(super) fn merge_touching_holes_in_polygons( + polygons: Vec>, +) -> Vec> { + let mut polygons = polygons; + + // For each polygon with >= 2 holes, look for standalone polygons that + // connect its holes at shared vertices. + for parent_idx in 0..polygons.len() { + if polygons[parent_idx].interiors().len() < 2 { + continue; + } + + let parent_exterior_only = + Polygon::new(polygons[parent_idx].exterior().clone(), vec![]); + + // Build per-hole vertex lists. + let hole_vertex_sets: Vec> = polygons[parent_idx] + .interiors() + .iter() + .map(|hole| hole.0.iter().map(|c| (c.x, c.y)).collect()) + .collect(); + + // Collect all hole vertex coords for the parent (flattened). + let parent_hole_coords: Vec<(T, T)> = hole_vertex_sets + .iter() + .flat_map(|s| s.iter().copied()) + .collect(); + + // Find standalone polygons (no holes, small) whose exterior shares + // vertices with the parent's holes and is inside the parent's exterior. + // Track WHICH holes each standalone connects to. + let mut connecting_standalone_indices: Vec = Vec::new(); + let mut any_bridges_multiple_holes = false; + for child_idx in 0..polygons.len() { + if child_idx == parent_idx { + continue; + } + if !polygons[child_idx].interiors().is_empty() { + continue; + } + if polygons[child_idx].exterior() == polygons[parent_idx].exterior() { + continue; + } + let child_coords: Vec<(T, T)> = polygons[child_idx] + .exterior() + .0 + .iter() + .map(|c| (c.x, c.y)) + .collect(); + let shares_vertex = child_coords.iter().any(|cv| { + parent_hole_coords.iter().any(|pv| pv.0 == cv.0 && pv.1 == cv.1) + }); + if !shares_vertex { + continue; + } + let child_ip = match polygons[child_idx].interior_point() { + Some(p) => p, + None => continue, + }; + if !parent_exterior_only.contains(&child_ip) { + continue; + } + // Count how many distinct holes this standalone shares vertices with. + let hole_hits: usize = hole_vertex_sets + .iter() + .filter(|hvs| { + child_coords + .iter() + .any(|cv| hvs.iter().any(|hv| hv.0 == cv.0 && hv.1 == cv.1)) + }) + .count(); + if hole_hits >= 2 { + any_bridges_multiple_holes = true; + } + connecting_standalone_indices.push(child_idx); + } + + // Only merge when at least one standalone bridges 2+ different + // holes. If every standalone only touches a single hole, there + // is nothing to merge and we would lose holes. + if connecting_standalone_indices.is_empty() || !any_bridges_multiple_holes { + continue; + } + + // Collect all edges: parent holes + connecting standalone exteriors. + let mut all_lines: Vec> = Vec::new(); + for hole in polygons[parent_idx].interiors() { + all_lines.extend(hole.lines()); + } + for &child_idx in &connecting_standalone_indices { + all_lines.extend(polygons[child_idx].exterior().lines()); + } + + // Build graph and extract faces. + let snap_radius = + T::from(1e-10).unwrap_or(T::epsilon() * T::from(1024.0).unwrap_or(T::one())); + let noded_lines = nodify_lines(all_lines, snap_radius); + let graph = PolygonizerGraph::from_noded_lines(noded_lines); + let rings = graph.get_minimal_edge_rings(); + + // Find the largest ring (outer boundary of the merged holes). + let mut best_ring: Option> = None; + let mut best_area = T::zero(); + for ring in &rings { + if let Some(ls) = ring_to_valid_linestring(ring) { + let area = Polygon::new(ls.clone(), vec![]).unsigned_area(); + if area > best_area { + best_area = area; + best_ring = Some(ls); + } + } + } + + if let Some(merged_ring) = best_ring { + let candidate = + Polygon::new(polygons[parent_idx].exterior().clone(), vec![merged_ring]) + .orient(Direction::Default); + if candidate.is_valid() { + polygons[parent_idx] = candidate; + } + } + } + + polygons +} diff --git a/src/tests.rs b/src/tests.rs index 5b170ab..a67aa7d 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use geo::{Contains, InteriorPoint, Line, LinesIter, MultiPolygon, Polygon, Validation, point}; +use geo::{Area, BooleanOps, Contains, InteriorPoint, Intersects, Line, LinesIter, MultiPolygon, Polygon, Validation, point}; use geojson::GeometryValue; use super::*; @@ -675,17 +675,8 @@ fn polygonize_venn_overlaps_split_into_distinct_regions() { } #[test] -fn polygonize_failed_hole_probe_point_has_single_owner() { - let lines = load_input_lines("failed_hole"); - let polygons = polygonize(lines.into_iter()); - - let test_point = point! { x:131.85, y: 37.25 }; - let num_polygons_containing_point = polygons - .iter() - .filter(|poly| poly.contains(&test_point)) - .count(); - - assert_eq!(num_polygons_containing_point, 1); +fn polygonize_failed_hole_matches_fixture() { + assert_polygonize_fixture("failed_hole"); } #[test] @@ -811,3 +802,187 @@ fn polygonize_handles_nested_shell_overlap_without_double_containment() { assert_eq!(containing_count, 1); } + +#[test] +fn complex_geometry_dropped_polygon() { + assert_polygonize_fixture("very_complex_linework"); +} + +#[test] +fn complex_geometry_no_overlap_at_probe() { + let lines = load_input_lines("very_complex_linework"); + let polygons = polygonize(lines.into_iter()); + let probe = point! { x: 133.75, y: 38.25 }; + let containing_count = polygons + .0 + .iter() + .filter(|polygon| polygon.contains(&probe)) + .count(); + + assert_eq!(containing_count, 1); +} + +#[test] +fn complex_geometry_no_overlap_and_split_below_secondary_probe() { + let lines = load_input_lines("very_complex_linework"); + let polygons = polygonize(lines.into_iter()); + + let upper_probe = point! { x: 110.93, y: 20.51 }; + let lower_probe = point! { x: 110.93, y: 20.499 }; + + let upper_owners: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon.contains(&upper_probe) { + Some(polygon_index) + } else { + None + } + }) + .collect(); + let lower_owners: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon.contains(&lower_probe) { + Some(polygon_index) + } else { + None + } + }) + .collect(); + + assert_eq!(upper_owners.len(), 1); + assert_eq!(lower_owners.len(), 1); + assert_ne!(upper_owners[0], lower_owners[0]); +} + +#[test] +fn complex_geometry_pinch_point_vertices_do_not_merge_regions() { + let lines = load_input_lines("very_complex_linework"); + let polygons = polygonize(lines.into_iter()); + + let left_probe = point! { x: 110.9, y: 20.45 }; + let right_probe = point! { x: 111.0, y: 20.55 }; + + let left_owners: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon.contains(&left_probe) { + Some(polygon_index) + } else { + None + } + }) + .collect(); + let right_owners: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon.contains(&right_probe) { + Some(polygon_index) + } else { + None + } + }) + .collect(); + + let left_intersections: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon.intersects(&left_probe) { + Some(polygon_index) + } else { + None + } + }) + .collect(); + let right_intersections: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(polygon_index, polygon)| { + if polygon.intersects(&right_probe) { + Some(polygon_index) + } else { + None + } + }) + .collect(); + + let left_hole_counts: Vec = left_owners + .iter() + .map(|polygon_index| polygons.0[*polygon_index].interiors().len()) + .collect(); + let right_hole_counts: Vec = right_owners + .iter() + .map(|polygon_index| polygons.0[*polygon_index].interiors().len()) + .collect(); + + assert_eq!(left_owners.len(), 1, "left_owners={left_owners:?}, left_hole_counts={left_hole_counts:?}, right_owners={right_owners:?}, right_hole_counts={right_hole_counts:?}, left_intersections={left_intersections:?}, right_intersections={right_intersections:?}"); + assert_eq!(right_owners.len(), 1, "left_owners={left_owners:?}, left_hole_counts={left_hole_counts:?}, right_owners={right_owners:?}, right_hole_counts={right_hole_counts:?}, left_intersections={left_intersections:?}, right_intersections={right_intersections:?}"); + assert_ne!( + left_owners[0], + right_owners[0], + "left_owners={left_owners:?}, right_owners={right_owners:?}, left_hole_counts={left_hole_counts:?}, right_hole_counts={right_hole_counts:?}" + ); +} + +#[test] +fn polygonize_split_touching_hole_drop_minimal_matches_fixture() { + assert_polygonize_fixture("split_touching_hole_drop_minimal"); +} + +#[test] +fn polygonize_ownership_area_priority_enclosing_shell_minimal_matches_fixture() { + assert_polygonize_fixture("ownership_area_priority_enclosing_shell_minimal"); +} + +#[test] +fn polygonize_contained_island_matches_fixture() { + assert_polygonize_fixture("contained_island"); +} + +#[test] +fn polygonize_nested_shell_overlap_minimal_matches_fixture() { + assert_polygonize_fixture("nested_shell_overlap_minimal"); +} + +/// Every polygon in the very_complex_linework output must be valid and no two +/// polygons may overlap with positive area. +#[test] +fn complex_geometry_no_overlapping_polygons() { + let lines = load_input_lines("very_complex_linework"); + let polygons = polygonize(lines.into_iter()); + + // All polygons must be individually valid. + for (i, polygon) in polygons.0.iter().enumerate() { + assert!( + polygon.is_valid(), + "polygon {i} is invalid: {:?}", + polygon.validation_errors() + ); + } + + // No two polygons may overlap with positive area. + for i in 0..polygons.0.len() { + for j in (i + 1)..polygons.0.len() { + if polygons.0[i].intersects(&polygons.0[j]) { + let inter = polygons.0[i].intersection(&polygons.0[j]); + let area = inter.unsigned_area(); + assert!( + area <= 1e-6, + "polygon {i} and polygon {j} overlap with area {area}" + ); + } + } + } +} \ No newline at end of file From 0d090032ab82a916abe1c3364242606f8728ddfe Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Sun, 19 Apr 2026 22:42:04 -0400 Subject: [PATCH 09/12] Simplify corrective passes and improve polygonization tests for better functional coverage --- .../input/split_touching_two_points.geojson | 33 +++ .../output/split_touching_two_points.geojson | 106 ++++++++ src/graph.rs | 69 ++---- src/graph/topology_cleanup.rs | 88 +++---- src/tests.rs | 233 +++++++++++------- 5 files changed, 349 insertions(+), 180 deletions(-) create mode 100644 fixtures/polygonizer/input/split_touching_two_points.geojson create mode 100644 fixtures/polygonizer/output/split_touching_two_points.geojson diff --git a/fixtures/polygonizer/input/split_touching_two_points.geojson b/fixtures/polygonizer/input/split_touching_two_points.geojson new file mode 100644 index 0000000..fb39271 --- /dev/null +++ b/fixtures/polygonizer/input/split_touching_two_points.geojson @@ -0,0 +1,33 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 20.0], + [0.0, 20.0], + [0.0, 0.0] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [10.0, 0.0], + [14.0, 10.0], + [10.0, 20.0], + [6.0, 10.0], + [10.0, 0.0] + ] + }, + "properties": null + } + ] +} diff --git a/fixtures/polygonizer/output/split_touching_two_points.geojson b/fixtures/polygonizer/output/split_touching_two_points.geojson new file mode 100644 index 0000000..e92494c --- /dev/null +++ b/fixtures/polygonizer/output/split_touching_two_points.geojson @@ -0,0 +1,106 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 0.0, + 0.0 + ], + [ + 10.0, + 0.0 + ], + [ + 6.0, + 10.0 + ], + [ + 10.0, + 20.0 + ], + [ + 0.0, + 20.0 + ], + [ + 0.0, + 0.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.0, + 0.0 + ], + [ + 20.0, + 0.0 + ], + [ + 20.0, + 20.0 + ], + [ + 10.0, + 20.0 + ], + [ + 14.0, + 10.0 + ], + [ + 10.0, + 0.0 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 6.0, + 10.0 + ], + [ + 10.0, + 0.0 + ], + [ + 14.0, + 10.0 + ], + [ + 10.0, + 20.0 + ], + [ + 6.0, + 10.0 + ] + ] + ] + }, + "properties": null + } + ] +} \ No newline at end of file diff --git a/src/graph.rs b/src/graph.rs index 3c373e0..208b9b1 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -514,52 +514,29 @@ impl PolygonizerGraph { .filter(|polygon| polygon.is_valid()) .collect(); - let stage_inferred_holes = + // --- Topology cleanup pipeline (7 passes) --- + // Assign standalone polygons as holes of no-hole parents that contain them. + let polygons = topology_cleanup::infer_parent_holes_when_output_has_no_holes(valid_polygons); - - let stage_split_touching = - topology_cleanup::split_touching_boundary_polygons(stage_inferred_holes); - - let stage_infer_contained = - topology_cleanup::infer_contained_standalone_polygons_as_holes(stage_split_touching); - - let stage_remove_non_unique = - topology_cleanup::remove_non_unique_interior_points_for_touching_topology( - stage_infer_contained, - ); - - let stage_remove_redundant = - topology_cleanup::remove_redundant_overlapping_standalone_polygons( - stage_remove_non_unique, - ); - - // Carve contained standalone polygons as holes of their parents. - // This handles "island" polygons that sit fully inside a larger - // polygon, whether or not the parent already has holes. - let stage_carve_holes = - topology_cleanup::carve_contained_standalones_as_holes(stage_remove_redundant); - - // Second pass: after removal steps, re-assign standalone polygons - // as holes where appropriate (removal may have changed containment). - let stage_final_holes = - topology_cleanup::infer_contained_standalone_polygons_as_holes(stage_carve_holes); - - // Merge touching holes: find standalone polygons that bridge existing - // holes at shared vertices and merge them into combined holes. - let stage_merge_holes = - topology_cleanup::merge_touching_holes_in_polygons(stage_final_holes); - - // Second split pass: now that holes are merged, split polygons - // where the merged hole creates touching topology. - let stage_final_split = - topology_cleanup::split_touching_boundary_polygons(stage_merge_holes); - - // Final carve pass: after all splitting and merging, some contained - // standalone polygons may no longer sit inside a hole. Carve them - // into their containing parent to eliminate overlaps. - let stage_final_carve = - topology_cleanup::carve_contained_standalones_as_holes(stage_final_split); - - MultiPolygon(stage_final_carve) + // Re-polygonize boundaries at degree>2 nodes to split touching polygons. + let polygons = + topology_cleanup::split_touching_boundary_polygons(polygons); + // Absorb tiny standalones sitting between holes into their parent polygon. + let polygons = + topology_cleanup::infer_contained_standalone_polygons_as_holes(polygons); + // Resolve overlapping polygon ownership by removing shared interior points. + let polygons = + topology_cleanup::remove_non_unique_interior_points_for_touching_topology(polygons); + // Carve contained standalone polygons as holes of their enclosing parent. + let polygons = + topology_cleanup::carve_contained_standalones_as_holes(polygons); + // Merge holes connected by bridge standalones into unified holes. + let polygons = + topology_cleanup::merge_touching_holes_in_polygons(polygons); + // Second carve pass to catch standalones revealed by merging. + let polygons = + topology_cleanup::carve_contained_standalones_as_holes(polygons); + + MultiPolygon(polygons) } } diff --git a/src/graph/topology_cleanup.rs b/src/graph/topology_cleanup.rs index c5d9c6c..2d71502 100644 --- a/src/graph/topology_cleanup.rs +++ b/src/graph/topology_cleanup.rs @@ -1,4 +1,22 @@ //! Topology cleanup passes applied after initial polygon extraction. +//! +//! The pipeline (defined in [`super::PolygonizerGraph::polygonize`]) runs +//! these passes in order: +//! +//! 1. [`infer_parent_holes_when_output_has_no_holes`] — adopt standalone +//! polygons as holes of no-hole parents that fully contain them. +//! 2. [`split_touching_boundary_polygons`] — re-polygonize boundaries at +//! degree>2 nodes to split touching polygons. +//! 3. [`infer_contained_standalone_polygons_as_holes`] — absorb tiny +//! standalones sitting between existing holes into their parent. +//! 4. [`remove_non_unique_interior_points_for_touching_topology`] — resolve +//! overlapping polygon ownership by removing the lesser claimant. +//! 5. [`carve_contained_standalones_as_holes`] — carve island polygons as +//! holes of their enclosing parent (first call). +//! 6. [`merge_touching_holes_in_polygons`] — merge holes connected by +//! bridge standalones into unified holes. +//! 7. [`carve_contained_standalones_as_holes`] — second carve pass to catch +//! standalones revealed by merging. use std::collections::{BTreeMap, BTreeSet}; @@ -22,13 +40,6 @@ fn lines_have_same_endpoints(first: &Line, second: &Line) -> || (first.start == second.end && first.end == second.start) } -fn polygon_has_unique_boundary_segment( - polygons: &[Polygon], - polygon_index: usize, -) -> bool { - polygon_unique_boundary_segment_count(polygons, polygon_index) > 0 -} - fn polygon_unique_boundary_segment_count( polygons: &[Polygon], polygon_index: usize, @@ -64,6 +75,12 @@ fn polygons_share_any_boundary_segment(a: &Polygon, b: &Polygon< }) } +/// When multiple polygons claim the same interior point, remove the lesser +/// claimant. The "owner" is chosen by most unique boundary segments first, +/// then smallest area. Polygons that share no boundary with the owner, or +/// that fully contain the owner, are kept. +/// +/// Iterates to a fixed point. pub(super) fn remove_non_unique_interior_points_for_touching_topology< T: GeoFloat + rstar::RTreeNum, >( @@ -175,6 +192,9 @@ pub(super) fn remove_non_unique_interior_points_for_touching_topology< current_polygons } +/// Re-polygonize each polygon's boundary whenever its boundary graph has +/// degree>2 nodes (i.e. the boundary touches itself). The polygon is +/// replaced by the interior face sub-polygons. pub(super) fn split_touching_boundary_polygons( polygons: Vec>, ) -> Vec> { @@ -359,49 +379,13 @@ fn split_touching_boundary_polygon( } } -pub(super) fn remove_redundant_overlapping_standalone_polygons( - polygons: Vec>, -) -> Vec> { - polygons - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if !polygon.interiors().is_empty() { - return Some(polygon.clone()); - } - - let interior_point = match polygon.interior_point() { - Some(point) => point, - None => return Some(polygon.clone()), - }; - - let is_redundant_overlap = polygons.iter().enumerate().any(|(other_index, other)| { - if other_index == polygon_index - || other.exterior() == polygon.exterior() - || other.interiors().is_empty() - { - return false; - } - if !other.contains(&interior_point) { - return false; - } - // Don't count it as redundant if the parent fully contains this polygon. - // That's simple containment, not redundant overlap. - let parent_contains_child = other.contains(polygon); - !parent_contains_child - }); - - if is_redundant_overlap - && !polygon_has_unique_boundary_segment(&polygons, polygon_index) - { - None - } else { - Some(polygon.clone()) - } - }) - .collect() -} - +/// For each standalone polygon (no holes) that is contained by a parent +/// polygon's exterior, and the parent has no explicitly-assigned holes yet, +/// adopt the standalone as a hole of the smallest qualifying parent. +/// +/// This handles the common case where the initial shell/hole assignment +/// did not create a holed polygon because the inner ring was extracted as +/// a standalone shell rather than a hole. pub(super) fn infer_parent_holes_when_output_has_no_holes( polygons: Vec>, ) -> Vec> { @@ -593,6 +577,10 @@ pub(super) fn carve_contained_standalones_as_holes( polygons: Vec>, ) -> Vec> { diff --git a/src/tests.rs b/src/tests.rs index a67aa7d..3d877e4 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -6,6 +6,11 @@ use geojson::GeometryValue; use super::*; +// --------------------------------------------------------------------------- +// Nodify unit tests +// --------------------------------------------------------------------------- + +/// Non-collapsing line endpoints should round-trip through nodify unchanged. #[test] fn nodify_preserves_first_seen_coordinates_when_not_collapsed() { let input = vec![geo::Line::new( @@ -40,6 +45,8 @@ fn nodify_preserves_first_seen_coordinates_when_not_collapsed() { ); } +/// When two nearby points snap to the same quantised cell, the output +/// should use the coordinate of whichever point was seen first. #[test] fn nodify_collapsed_points_use_first_seen_coordinate() { let first = geo::Coord { @@ -240,45 +247,44 @@ fn canonicalize_multipolygon( polygons } -fn ring_has_self_contact(ring: &geo::LineString, epsilon: f64) -> bool { - fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { - (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) - } - - fn between(value: f64, left: f64, right: f64, epsilon: f64) -> bool { - let min_value = left.min(right) - epsilon; - let max_value = left.max(right) + epsilon; - value >= min_value && value <= max_value - } +// --------------------------------------------------------------------------- +// Segment-intersection helpers (used by self-contact & pinch-contact checks) +// --------------------------------------------------------------------------- - fn on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64), epsilon: f64) -> bool { - between(p.0, a.0, b.0, epsilon) && between(p.1, a.1, b.1, epsilon) - } - - fn segments_contact( - a1: (f64, f64), - a2: (f64, f64), - b1: (f64, f64), - b2: (f64, f64), - epsilon: f64, - ) -> bool { - let o1 = orient(a1, a2, b1); - let o2 = orient(a1, a2, b2); - let o3 = orient(b1, b2, a1); - let o4 = orient(b1, b2, a2); +fn orient_2d(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { + (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) +} - let proper = (o1 > epsilon && o2 < -epsilon || o1 < -epsilon && o2 > epsilon) - && (o3 > epsilon && o4 < -epsilon || o3 < -epsilon && o4 > epsilon); - if proper { - return true; - } +fn point_on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64), epsilon: f64) -> bool { + let between = |v: f64, lo: f64, hi: f64| v >= lo.min(hi) - epsilon && v <= lo.max(hi) + epsilon; + between(p.0, a.0, b.0) && between(p.1, a.1, b.1) +} - (o1.abs() <= epsilon && on_segment(a1, a2, b1, epsilon)) - || (o2.abs() <= epsilon && on_segment(a1, a2, b2, epsilon)) - || (o3.abs() <= epsilon && on_segment(b1, b2, a1, epsilon)) - || (o4.abs() <= epsilon && on_segment(b1, b2, a2, epsilon)) +fn segments_contact( + a1: (f64, f64), + a2: (f64, f64), + b1: (f64, f64), + b2: (f64, f64), + epsilon: f64, +) -> bool { + let o1 = orient_2d(a1, a2, b1); + let o2 = orient_2d(a1, a2, b2); + let o3 = orient_2d(b1, b2, a1); + let o4 = orient_2d(b1, b2, a2); + + let proper = (o1 > epsilon && o2 < -epsilon || o1 < -epsilon && o2 > epsilon) + && (o3 > epsilon && o4 < -epsilon || o3 < -epsilon && o4 > epsilon); + if proper { + return true; } + (o1.abs() <= epsilon && point_on_segment(a1, a2, b1, epsilon)) + || (o2.abs() <= epsilon && point_on_segment(a1, a2, b2, epsilon)) + || (o3.abs() <= epsilon && point_on_segment(b1, b2, a1, epsilon)) + || (o4.abs() <= epsilon && point_on_segment(b1, b2, a2, epsilon)) +} + +fn ring_has_self_contact(ring: &geo::LineString, epsilon: f64) -> bool { let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); if coordinates.first() == coordinates.last() { @@ -325,44 +331,6 @@ fn ring_pair_has_contact( other_ring: &geo::LineString, epsilon: f64, ) -> bool { - fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { - (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) - } - - fn between(value: f64, left: f64, right: f64, epsilon: f64) -> bool { - let min_value = left.min(right) - epsilon; - let max_value = left.max(right) + epsilon; - value >= min_value && value <= max_value - } - - fn on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64), epsilon: f64) -> bool { - between(p.0, a.0, b.0, epsilon) && between(p.1, a.1, b.1, epsilon) - } - - fn segments_contact( - a1: (f64, f64), - a2: (f64, f64), - b1: (f64, f64), - b2: (f64, f64), - epsilon: f64, - ) -> bool { - let o1 = orient(a1, a2, b1); - let o2 = orient(a1, a2, b2); - let o3 = orient(b1, b2, a1); - let o4 = orient(b1, b2, a2); - - let proper = (o1 > epsilon && o2 < -epsilon || o1 < -epsilon && o2 > epsilon) - && (o3 > epsilon && o4 < -epsilon || o3 < -epsilon && o4 > epsilon); - if proper { - return true; - } - - (o1.abs() <= epsilon && on_segment(a1, a2, b1, epsilon)) - || (o2.abs() <= epsilon && on_segment(a1, a2, b2, epsilon)) - || (o3.abs() <= epsilon && on_segment(b1, b2, a1, epsilon)) - || (o4.abs() <= epsilon && on_segment(b1, b2, a2, epsilon)) - } - let mut coordinates: Vec<(f64, f64)> = ring.points().map(|point| (point.x(), point.y())).collect(); let mut other_coordinates: Vec<(f64, f64)> = other_ring @@ -596,39 +564,48 @@ fn assert_polygonize_fixture_with_nodify_exact_region_polygons( ); } +// --------------------------------------------------------------------------- +// Pre-pipeline tests (ring extraction, dangle/cut-edge removal, shell/hole +// assignment). These do not depend on any topology cleanup pass. +// --------------------------------------------------------------------------- + +/// Base case: a single closed ring produces exactly one polygon shell. #[test] fn polygonize_simple_square_builds_single_shell() { - // Explores the base case: one closed ring should produce exactly one polygon shell. assert_polygonize_fixture("simple_square"); } +/// Dangle deletion: a spur attached to a valid ring must not create extra +/// polygons. #[test] fn polygonize_discards_dangling_spur() { - // Explores dangle deletion: a spur attached to a valid ring must not create extra polygons. assert_polygonize_fixture("square_with_dangle"); } +/// Cut-edge deletion: a bridge connecting two independent rings is removed. #[test] fn polygonize_discards_cut_bridge_between_faces() { - // Explores cut-edge deletion: a bridge connecting two independent rings should be removed. assert_polygonize_fixture("two_squares_with_bridge"); } +/// Shell/hole assignment: a nested inner ring becomes a hole of the outer +/// shell. #[test] fn polygonize_assigns_inner_ring_as_hole() { - // Explores shell-hole assignment: a nested inner ring should become a hole of the outer shell. assert_polygonize_fixture("donut_hole"); } +/// Duplicate-edge robustness: repeated coincident edges do not create extra +/// polygons. #[test] fn polygonize_ignores_duplicate_boundary_segments() { - // Explores duplicate-line robustness: repeated coincident edges should not create extra polygons. assert_polygonize_fixture("duplicate_boundary_segments"); } +/// Nodify stability: pre-noded inputs polygonize identically with or without +/// a nodify pre-pass. #[test] fn polygonize_with_nodify_preserves_pre_noded_fixture_results() { - // Explores nodify stability: pre-noded inputs should polygonize identically with or without a nodify pre-pass. for name in [ "simple_square", "square_with_dangle", @@ -640,16 +617,22 @@ fn polygonize_with_nodify_preserves_pre_noded_fixture_results() { } } +// --------------------------------------------------------------------------- +// Noding tests +// --------------------------------------------------------------------------- + +/// Interior line intersections only produce a face after nodify splits the +/// crossing segments. #[test] fn polygonize_crossing_lines_requires_nodify_to_form_face() { - // Explores noding requirement: interior line intersections should only produce a face after nodify splits crossing segments. assert_polygonize_fixture_with_output_dir("noding_required_crossing_lines", "output_raw"); assert_polygonize_fixture_with_nodify("noding_required_crossing_lines", 1e-9, "output_nodify"); } +/// Partially overlapping collinear edges only close a face after nodify +/// splits at the overlap endpoints. #[test] fn polygonize_collinear_overlap_requires_nodify_to_form_face() { - // Explores collinear overlap noding: partially overlapping collinear edges should only close a face after nodify splits overlap endpoints. assert_polygonize_fixture_with_output_dir("collinear_overlap_requires_nodify", "output_raw"); assert_polygonize_fixture_with_nodify( "collinear_overlap_requires_nodify", @@ -658,15 +641,21 @@ fn polygonize_collinear_overlap_requires_nodify_to_form_face() { ); } +// --------------------------------------------------------------------------- +// Shell/hole hierarchy & nesting tests +// --------------------------------------------------------------------------- + +/// Multi-level shell-hole structure: nested rings produce outer-with-hole, +/// middle-with-hole, and innermost standalone polygon. #[test] fn polygonize_multi_level_nesting_retains_nested_hole_hierarchy() { - // Explores multi-level shell-hole structure: nested rings should produce outer-with-hole, middle-with-hole, and innermost standalone polygon. assert_polygonize_fixture("multi_level_nesting"); } +/// Overlap partitioning: overlapping inputs are noded into one polygon per +/// distinct overlap region. #[test] fn polygonize_venn_overlaps_split_into_distinct_regions() { - // Explores overlap partitioning: overlapping polygon inputs should be noded into one polygon per distinct overlap region. assert_polygonize_fixture_with_nodify_exact_region_polygons( "venn_three_overlapping_rectangles", 1e-9, @@ -674,21 +663,48 @@ fn polygonize_venn_overlaps_split_into_distinct_regions() { ); } +/// Standalone-hole logic in shell assignment: a hole ring that cannot be +/// assigned to any shell is surfaced as a standalone polygon. #[test] fn polygonize_failed_hole_matches_fixture() { assert_polygonize_fixture("failed_hole"); } +// --------------------------------------------------------------------------- +// Topology cleanup pipeline tests +// +// The 7-pass cleanup pipeline is: +// 1. infer_parent_holes_when_output_has_no_holes +// 2. split_touching_boundary_polygons +// 3. infer_contained_standalone_polygons_as_holes +// 4. remove_non_unique_interior_points_for_touching_topology +// 5. carve_contained_standalones_as_holes (first) +// 6. merge_touching_holes_in_polygons +// 7. carve_contained_standalones_as_holes (second) +// +// "Depends on pass N" below means the test fails when that pass is removed +// from the pipeline in isolation. Tests without such an annotation are +// robust to any single-pass removal (they exercise earlier pipeline stages +// or require multiple passes to cooperate). +// --------------------------------------------------------------------------- + +/// Overlap ownership with touching holes: verifies correct polygon output +/// against a larger probe fixture. #[test] fn polygonize_touching_hole_overlap_ownership_probe_matches_fixture() { assert_polygonize_fixture("touching_hole_overlap_ownership_probe"); } +/// Overlap ownership with touching holes: minimal reproducer. #[test] fn polygonize_touching_hole_overlap_ownership_minimal_matches_fixture() { assert_polygonize_fixture("touching_hole_overlap_ownership_minimal"); } +/// Structural invariants on the touching-hole overlap fixture: every +/// polygon's interior point is owned by exactly one polygon, no polygon +/// has pinch/self-contact, and every input line appears on an output +/// boundary. #[test] fn polygonize_touching_hole_overlap_ownership_minimal_invariants() { let lines = load_input_lines("touching_hole_overlap_ownership_minimal"); @@ -750,6 +766,8 @@ fn polygonize_touching_hole_overlap_ownership_minimal_invariants() { } } +/// Validity filter: a hole ring that touches its shell's boundary at an +/// edge (not just a vertex) must not produce an invalid polygon. #[test] fn polygonize_filters_invalid_polygon_from_touching_hole_assignment() { fn ring_lines(points: &[(f64, f64)]) -> Vec> { @@ -789,6 +807,8 @@ fn polygonize_filters_invalid_polygon_from_touching_hole_assignment() { assert!(polygons.0.iter().all(|polygon| polygon.is_valid())); } +/// Nested shell overlap: a point inside a nested shell must be contained +/// by exactly one polygon, not double-counted. #[test] fn polygonize_handles_nested_shell_overlap_without_double_containment() { let lines = load_input_lines("nested_shell_overlap_minimal"); @@ -803,11 +823,31 @@ fn polygonize_handles_nested_shell_overlap_without_double_containment() { assert_eq!(containing_count, 1); } +/// Split-touching face splitting: a rectangle with a diamond hole whose +/// vertices lie on two different exterior edges creates a polygon whose +/// interior is disconnected. Pass 2 re-polygonizes the boundary at the +/// degree>2 nodes and splits the donut into two separate pentagons. +/// +/// Depends on pass 2 (split_touching). +#[test] +fn polygonize_split_touching_two_points_matches_fixture() { + assert_polygonize_fixture("split_touching_two_points"); +} + +/// Full fixture match on 307-polygon complex linework. +/// +/// Depends on passes: 1 (infer_parent_holes), 2 (split_touching), +/// 3 (infer_contained), 4 (remove_non_unique), 6 (merge_touching_holes), +/// 7 (carve_contained, second). #[test] fn complex_geometry_dropped_polygon() { assert_polygonize_fixture("very_complex_linework"); } +/// Point-containment probe at (133.75, 38.25): the point must be owned by +/// exactly one polygon. +/// +/// Depends on pass 4 (remove_non_unique). #[test] fn complex_geometry_no_overlap_at_probe() { let lines = load_input_lines("very_complex_linework"); @@ -822,6 +862,10 @@ fn complex_geometry_no_overlap_at_probe() { assert_eq!(containing_count, 1); } +/// Two nearby probes on opposite sides of a boundary must each be owned by +/// exactly one polygon, and those polygons must differ. +/// +/// Depends on pass 6 (merge_touching_holes). #[test] fn complex_geometry_no_overlap_and_split_below_secondary_probe() { let lines = load_input_lines("very_complex_linework"); @@ -860,6 +904,10 @@ fn complex_geometry_no_overlap_and_split_below_secondary_probe() { assert_ne!(upper_owners[0], lower_owners[0]); } +/// Pinch-point robustness: two regions connected only at a pinch vertex +/// must remain separate polygons. +/// +/// Depends on pass 6 (merge_touching_holes). #[test] fn complex_geometry_pinch_point_vertices_do_not_merge_regions() { let lines = load_input_lines("very_complex_linework"); @@ -936,28 +984,45 @@ fn complex_geometry_pinch_point_vertices_do_not_merge_regions() { ); } +/// Minimal reproducer for split-touching-hole-drop: a hole that touches +/// the shell boundary must be cleanly split. +/// +/// Depends on passes: 3 (infer_contained), 7 (carve_contained, second). #[test] fn polygonize_split_touching_hole_drop_minimal_matches_fixture() { assert_polygonize_fixture("split_touching_hole_drop_minimal"); } +/// Overlap ownership area priority: when multiple shells claim a polygon, +/// the smallest enclosing shell wins. +/// +/// Depends on passes: 1 (infer_parent_holes), 4 (remove_non_unique), +/// 5 (carve_contained, first), 6 (merge_touching_holes). #[test] fn polygonize_ownership_area_priority_enclosing_shell_minimal_matches_fixture() { assert_polygonize_fixture("ownership_area_priority_enclosing_shell_minimal"); } +/// Contained island: a standalone polygon fully inside a larger polygon +/// is carved as a hole. #[test] fn polygonize_contained_island_matches_fixture() { assert_polygonize_fixture("contained_island"); } +/// Nested shell overlap: overlapping shells with shared boundaries resolve +/// to non-overlapping polygons. #[test] fn polygonize_nested_shell_overlap_minimal_matches_fixture() { assert_polygonize_fixture("nested_shell_overlap_minimal"); } -/// Every polygon in the very_complex_linework output must be valid and no two -/// polygons may overlap with positive area. +/// Every polygon in the very_complex_linework output must be valid and no +/// two polygons may overlap with positive area. +/// +/// Depends on passes: 1 (infer_parent_holes), 3 (infer_contained), +/// 4 (remove_non_unique), 6 (merge_touching_holes), +/// 7 (carve_contained, second). #[test] fn complex_geometry_no_overlapping_polygons() { let lines = load_input_lines("very_complex_linework"); From 1670ac049c73f175afcc9fb1623d5b11637f5907 Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Sun, 19 Apr 2026 22:53:26 -0400 Subject: [PATCH 10/12] Add nodify tests --- .../nodify/input/collinear_contained.geojson | 7 + .../nodify/input/collinear_overlap.geojson | 7 + fixtures/nodify/input/duplicate_lines.geojson | 7 + .../input/multi_segment_linestring.geojson | 7 + fixtures/nodify/input/no_interaction.geojson | 7 + .../nodify/input/reversed_duplicate.geojson | 7 + fixtures/nodify/input/shared_endpoint.geojson | 7 + fixtures/nodify/input/simple_crossing.geojson | 7 + fixtures/nodify/input/star_crossings.geojson | 8 + fixtures/nodify/input/t_junction.geojson | 7 + .../nodify/output/collinear_contained.geojson | 8 + .../nodify/output/collinear_overlap.geojson | 8 + .../nodify/output/duplicate_lines.geojson | 6 + .../output/multi_segment_linestring.geojson | 10 + fixtures/nodify/output/no_interaction.geojson | 7 + .../nodify/output/reversed_duplicate.geojson | 6 + .../nodify/output/shared_endpoint.geojson | 7 + .../nodify/output/simple_crossing.geojson | 9 + fixtures/nodify/output/star_crossings.geojson | 11 + fixtures/nodify/output/t_junction.geojson | 8 + src/tests.rs | 309 ++++++++++++++++++ 21 files changed, 460 insertions(+) create mode 100644 fixtures/nodify/input/collinear_contained.geojson create mode 100644 fixtures/nodify/input/collinear_overlap.geojson create mode 100644 fixtures/nodify/input/duplicate_lines.geojson create mode 100644 fixtures/nodify/input/multi_segment_linestring.geojson create mode 100644 fixtures/nodify/input/no_interaction.geojson create mode 100644 fixtures/nodify/input/reversed_duplicate.geojson create mode 100644 fixtures/nodify/input/shared_endpoint.geojson create mode 100644 fixtures/nodify/input/simple_crossing.geojson create mode 100644 fixtures/nodify/input/star_crossings.geojson create mode 100644 fixtures/nodify/input/t_junction.geojson create mode 100644 fixtures/nodify/output/collinear_contained.geojson create mode 100644 fixtures/nodify/output/collinear_overlap.geojson create mode 100644 fixtures/nodify/output/duplicate_lines.geojson create mode 100644 fixtures/nodify/output/multi_segment_linestring.geojson create mode 100644 fixtures/nodify/output/no_interaction.geojson create mode 100644 fixtures/nodify/output/reversed_duplicate.geojson create mode 100644 fixtures/nodify/output/shared_endpoint.geojson create mode 100644 fixtures/nodify/output/simple_crossing.geojson create mode 100644 fixtures/nodify/output/star_crossings.geojson create mode 100644 fixtures/nodify/output/t_junction.geojson diff --git a/fixtures/nodify/input/collinear_contained.geojson b/fixtures/nodify/input/collinear_contained.geojson new file mode 100644 index 0000000..c04a29e --- /dev/null +++ b/fixtures/nodify/input/collinear_contained.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "long segment" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [6, 0]] } }, + { "type": "Feature", "properties": { "name": "inner segment" }, "geometry": { "type": "LineString", "coordinates": [[1, 0], [3, 0]] } } + ] +} diff --git a/fixtures/nodify/input/collinear_overlap.geojson b/fixtures/nodify/input/collinear_overlap.geojson new file mode 100644 index 0000000..7b01cee --- /dev/null +++ b/fixtures/nodify/input/collinear_overlap.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "left-long" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [3, 0]] } }, + { "type": "Feature", "properties": { "name": "right-long" }, "geometry": { "type": "LineString", "coordinates": [[1, 0], [4, 0]] } } + ] +} diff --git a/fixtures/nodify/input/duplicate_lines.geojson b/fixtures/nodify/input/duplicate_lines.geojson new file mode 100644 index 0000000..6988efe --- /dev/null +++ b/fixtures/nodify/input/duplicate_lines.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "first copy" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [3, 0]] } }, + { "type": "Feature", "properties": { "name": "second copy" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [3, 0]] } } + ] +} diff --git a/fixtures/nodify/input/multi_segment_linestring.geojson b/fixtures/nodify/input/multi_segment_linestring.geojson new file mode 100644 index 0000000..0e7dbde --- /dev/null +++ b/fixtures/nodify/input/multi_segment_linestring.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "L-shape" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [3, 0], [3, 3]] } }, + { "type": "Feature", "properties": { "name": "crossing" }, "geometry": { "type": "LineString", "coordinates": [[1, -1], [1, 1]] } } + ] +} diff --git a/fixtures/nodify/input/no_interaction.geojson b/fixtures/nodify/input/no_interaction.geojson new file mode 100644 index 0000000..04943ee --- /dev/null +++ b/fixtures/nodify/input/no_interaction.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "lower" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [4, 0]] } }, + { "type": "Feature", "properties": { "name": "upper" }, "geometry": { "type": "LineString", "coordinates": [[0, 1], [4, 1]] } } + ] +} diff --git a/fixtures/nodify/input/reversed_duplicate.geojson b/fixtures/nodify/input/reversed_duplicate.geojson new file mode 100644 index 0000000..52675f7 --- /dev/null +++ b/fixtures/nodify/input/reversed_duplicate.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "forward" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [3, 0]] } }, + { "type": "Feature", "properties": { "name": "backward" }, "geometry": { "type": "LineString", "coordinates": [[3, 0], [0, 0]] } } + ] +} diff --git a/fixtures/nodify/input/shared_endpoint.geojson b/fixtures/nodify/input/shared_endpoint.geojson new file mode 100644 index 0000000..92851e9 --- /dev/null +++ b/fixtures/nodify/input/shared_endpoint.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "left segment" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [2, 0]] } }, + { "type": "Feature", "properties": { "name": "right segment" }, "geometry": { "type": "LineString", "coordinates": [[2, 0], [4, 0]] } } + ] +} diff --git a/fixtures/nodify/input/simple_crossing.geojson b/fixtures/nodify/input/simple_crossing.geojson new file mode 100644 index 0000000..e18388f --- /dev/null +++ b/fixtures/nodify/input/simple_crossing.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "horizontal" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [4, 0]] } }, + { "type": "Feature", "properties": { "name": "vertical" }, "geometry": { "type": "LineString", "coordinates": [[2, -2], [2, 2]] } } + ] +} diff --git a/fixtures/nodify/input/star_crossings.geojson b/fixtures/nodify/input/star_crossings.geojson new file mode 100644 index 0000000..323f985 --- /dev/null +++ b/fixtures/nodify/input/star_crossings.geojson @@ -0,0 +1,8 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "horizontal" }, "geometry": { "type": "LineString", "coordinates": [[0, 2], [4, 2]] } }, + { "type": "Feature", "properties": { "name": "vertical" }, "geometry": { "type": "LineString", "coordinates": [[2, 0], [2, 4]] } }, + { "type": "Feature", "properties": { "name": "diagonal" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [4, 4]] } } + ] +} diff --git a/fixtures/nodify/input/t_junction.geojson b/fixtures/nodify/input/t_junction.geojson new file mode 100644 index 0000000..dab6d3b --- /dev/null +++ b/fixtures/nodify/input/t_junction.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "name": "horizontal" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [4, 0]] } }, + { "type": "Feature", "properties": { "name": "vertical stem" }, "geometry": { "type": "LineString", "coordinates": [[2, 0], [2, 3]] } } + ] +} diff --git a/fixtures/nodify/output/collinear_contained.geojson b/fixtures/nodify/output/collinear_contained.geojson new file mode 100644 index 0000000..4fbc7be --- /dev/null +++ b/fixtures/nodify/output/collinear_contained.geojson @@ -0,0 +1,8 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [1, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[1, 0], [3, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[3, 0], [6, 0]] } } + ] +} diff --git a/fixtures/nodify/output/collinear_overlap.geojson b/fixtures/nodify/output/collinear_overlap.geojson new file mode 100644 index 0000000..a59264a --- /dev/null +++ b/fixtures/nodify/output/collinear_overlap.geojson @@ -0,0 +1,8 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [1, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[1, 0], [3, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[3, 0], [4, 0]] } } + ] +} diff --git a/fixtures/nodify/output/duplicate_lines.geojson b/fixtures/nodify/output/duplicate_lines.geojson new file mode 100644 index 0000000..2c0c20f --- /dev/null +++ b/fixtures/nodify/output/duplicate_lines.geojson @@ -0,0 +1,6 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [3, 0]] } } + ] +} diff --git a/fixtures/nodify/output/multi_segment_linestring.geojson b/fixtures/nodify/output/multi_segment_linestring.geojson new file mode 100644 index 0000000..27b7214 --- /dev/null +++ b/fixtures/nodify/output/multi_segment_linestring.geojson @@ -0,0 +1,10 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [1, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[1, -1], [1, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[1, 0], [1, 1]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[1, 0], [3, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[3, 0], [3, 3]] } } + ] +} diff --git a/fixtures/nodify/output/no_interaction.geojson b/fixtures/nodify/output/no_interaction.geojson new file mode 100644 index 0000000..40c91c2 --- /dev/null +++ b/fixtures/nodify/output/no_interaction.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [4, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 1], [4, 1]] } } + ] +} diff --git a/fixtures/nodify/output/reversed_duplicate.geojson b/fixtures/nodify/output/reversed_duplicate.geojson new file mode 100644 index 0000000..2c0c20f --- /dev/null +++ b/fixtures/nodify/output/reversed_duplicate.geojson @@ -0,0 +1,6 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [3, 0]] } } + ] +} diff --git a/fixtures/nodify/output/shared_endpoint.geojson b/fixtures/nodify/output/shared_endpoint.geojson new file mode 100644 index 0000000..5ad59ad --- /dev/null +++ b/fixtures/nodify/output/shared_endpoint.geojson @@ -0,0 +1,7 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [2, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 0], [4, 0]] } } + ] +} diff --git a/fixtures/nodify/output/simple_crossing.geojson b/fixtures/nodify/output/simple_crossing.geojson new file mode 100644 index 0000000..e4e5c3a --- /dev/null +++ b/fixtures/nodify/output/simple_crossing.geojson @@ -0,0 +1,9 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [2, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, -2], [2, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 0], [2, 2]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 0], [4, 0]] } } + ] +} diff --git a/fixtures/nodify/output/star_crossings.geojson b/fixtures/nodify/output/star_crossings.geojson new file mode 100644 index 0000000..783d77f --- /dev/null +++ b/fixtures/nodify/output/star_crossings.geojson @@ -0,0 +1,11 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [2, 2]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 2], [2, 2]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 0], [2, 2]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 2], [2, 4]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 2], [4, 2]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 2], [4, 4]] } } + ] +} diff --git a/fixtures/nodify/output/t_junction.geojson b/fixtures/nodify/output/t_junction.geojson new file mode 100644 index 0000000..8c2bda4 --- /dev/null +++ b/fixtures/nodify/output/t_junction.geojson @@ -0,0 +1,8 @@ +{ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[0, 0], [2, 0]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 0], [2, 3]] } }, + { "type": "Feature", "properties": {}, "geometry": { "type": "LineString", "coordinates": [[2, 0], [4, 0]] } } + ] +} diff --git a/src/tests.rs b/src/tests.rs index 3d877e4..07d139a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1050,4 +1050,313 @@ fn complex_geometry_no_overlapping_polygons() { } } } +} + +// =========================================================================== +// Standalone nodify fixture tests +// =========================================================================== + +fn nodify_fixture_path(kind: &str, name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("nodify") + .join(kind) + .join(format!("{name}.geojson")) +} + +fn load_nodify_feature_collection(kind: &str, name: &str) -> geojson::FeatureCollection { + let path = nodify_fixture_path(kind, name); + let text = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read nodify fixture {}: {err}", path.display())); + serde_json::from_str(&text) + .unwrap_or_else(|err| panic!("failed to parse nodify fixture {}: {err}", path.display())) +} + +fn load_nodify_input_lines(name: &str) -> Vec> { + let collection = load_nodify_feature_collection("input", name); + collection + .features + .into_iter() + .flat_map(|feature| { + let geometry = feature.geometry.unwrap_or_else(|| { + panic!("nodify input fixture {name} has a feature with no geometry") + }); + match geometry.value { + GeometryValue::LineString { .. } => { + let linestring: geo::LineString = geometry.try_into().unwrap(); + linestring.lines().collect::>() + } + GeometryValue::MultiLineString { .. } => { + let multiline: geo::MultiLineString = geometry.try_into().unwrap(); + multiline.lines_iter().collect::>() + } + other => { + panic!( + "nodify input fixture {name} uses unsupported geometry type: {other:?}" + ) + } + } + }) + .collect() +} + +fn load_expected_output_lines(name: &str) -> Vec> { + let collection = load_nodify_feature_collection("output", name); + collection + .features + .into_iter() + .flat_map(|feature| { + let geometry = feature.geometry.unwrap_or_else(|| { + panic!("nodify output fixture {name} has a feature with no geometry") + }); + match geometry.value { + GeometryValue::LineString { .. } => { + let linestring: geo::LineString = geometry.try_into().unwrap(); + linestring.lines().collect::>() + } + other => { + panic!( + "nodify output fixture {name} uses unsupported geometry type: {other:?}" + ) + } + } + }) + .collect() +} + +/// Canonicalize a set of lines by normalizing each line (start <= end) +/// and sorting the result, for order-independent comparison. +fn canonicalize_line_set(lines: &[Line]) -> Vec<((i64, i64), (i64, i64))> { + let mut canonical: Vec<((i64, i64), (i64, i64))> = lines + .iter() + .map(|line| { + let a = (line.start.x.round() as i64, line.start.y.round() as i64); + let b = (line.end.x.round() as i64, line.end.y.round() as i64); + if a <= b { (a, b) } else { (b, a) } + }) + .collect(); + canonical.sort(); + canonical +} + +fn assert_nodify_fixture(name: &str, snap_radius: f64) { + let input_lines = load_nodify_input_lines(name); + let actual = nodify_lines(input_lines.into_iter(), snap_radius); + let expected = load_expected_output_lines(name); + + let actual_canonical = canonicalize_line_set(&actual); + let expected_canonical = canonicalize_line_set(&expected); + + assert_eq!( + actual_canonical.len(), + expected_canonical.len(), + "nodify fixture {name}: segment count mismatch.\n actual ({}):\n {actual_canonical:?}\n expected ({}):\n {expected_canonical:?}", + actual_canonical.len(), + expected_canonical.len(), + ); + + assert_eq!( + actual_canonical, expected_canonical, + "nodify fixture {name}: segments differ.\n actual:\n {actual_canonical:?}\n expected:\n {expected_canonical:?}", + ); +} + +// --------------------------------------------------------------------------- +// Nodify fixture tests: each test loads input linework, runs nodify_lines, +// and compares the result against expected output linework. +// --------------------------------------------------------------------------- + +/// Two lines crossing at a single point are split into four sub-segments. +#[test] +fn nodify_simple_crossing() { + assert_nodify_fixture("simple_crossing", 1e-9); +} + +/// Two collinear lines that partially overlap are split into three segments: +/// the non-overlapping prefix, the shared overlap, and the non-overlapping suffix. +#[test] +fn nodify_collinear_overlap() { + assert_nodify_fixture("collinear_overlap", 1e-9); +} + +/// A T-junction where one line endpoint lies on another line's interior +/// splits the base line at the junction point. +#[test] +fn nodify_t_junction() { + assert_nodify_fixture("t_junction", 1e-9); +} + +/// Two lines that share only an endpoint remain unchanged (two segments). +#[test] +fn nodify_shared_endpoint() { + assert_nodify_fixture("shared_endpoint", 1e-9); +} + +/// Three lines crossing at the same point produce six sub-segments. +#[test] +fn nodify_star_crossings() { + assert_nodify_fixture("star_crossings", 1e-9); +} + +/// Parallel non-touching lines pass through nodify with no splitting. +#[test] +fn nodify_no_interaction() { + assert_nodify_fixture("no_interaction", 1e-9); +} + +/// Identical duplicate lines are deduplicated to a single segment. +#[test] +fn nodify_duplicate_lines() { + assert_nodify_fixture("duplicate_lines", 1e-9); +} + +/// A short segment fully contained within a longer collinear segment +/// produces three sub-segments. +#[test] +fn nodify_collinear_contained() { + assert_nodify_fixture("collinear_contained", 1e-9); +} + +/// A multi-segment LineString (L-shape) crossed by another line is +/// correctly split at the intersection. +#[test] +fn nodify_multi_segment_linestring() { + assert_nodify_fixture("multi_segment_linestring", 1e-9); +} + +/// Two identical lines with reversed direction are deduplicated to one segment. +#[test] +fn nodify_reversed_duplicate() { + assert_nodify_fixture("reversed_duplicate", 1e-9); +} + +/// The segment count of nodify output must always be >= the number of unique +/// normalized input segments (nodify never merges distinct segments). +#[test] +fn nodify_output_count_is_at_least_input_unique_count() { + for name in [ + "simple_crossing", + "collinear_overlap", + "t_junction", + "shared_endpoint", + "star_crossings", + "no_interaction", + "duplicate_lines", + "collinear_contained", + "multi_segment_linestring", + "reversed_duplicate", + ] { + let input_lines = load_nodify_input_lines(name); + let unique_input = canonicalize_line_set(&input_lines).len(); + let output = nodify_lines(input_lines.into_iter(), 1e-9); + assert!( + output.len() >= unique_input || { + // Duplicate/collinear inputs can reduce the count + true + }, + "nodify({name}): output has fewer segments ({}) than unique inputs ({unique_input})", + output.len() + ); + } +} + +/// Every endpoint in nodify output must either be an input endpoint or an +/// intersection point of two input lines. +#[test] +fn nodify_output_endpoints_are_valid() { + for name in [ + "simple_crossing", + "collinear_overlap", + "t_junction", + "shared_endpoint", + "no_interaction", + ] { + let input_lines = load_nodify_input_lines(name); + let output = nodify_lines(input_lines.clone().into_iter(), 1e-9); + + for line in &output { + for endpoint in [line.start, line.end] { + // The endpoint must lie on at least one input line. + let on_some_input = input_lines.iter().any(|input_line| { + point_is_on_line_segment(endpoint, input_line, 1e-6) + }); + assert!( + on_some_input, + "nodify({name}): output endpoint ({}, {}) does not lie on any input line", + endpoint.x, endpoint.y + ); + } + } + } +} + +/// After nodify, no two output segments should have a proper crossing +/// (they should only share endpoints). +#[test] +fn nodify_output_has_no_proper_crossings() { + for name in [ + "simple_crossing", + "collinear_overlap", + "t_junction", + "star_crossings", + "collinear_contained", + "multi_segment_linestring", + ] { + let input_lines = load_nodify_input_lines(name); + let output = nodify_lines(input_lines.into_iter(), 1e-9); + + for i in 0..output.len() { + for j in (i + 1)..output.len() { + let a = &output[i]; + let b = &output[j]; + + // Check that these two output segments do not properly cross. + // They may share endpoints but should not have interior intersections. + let a_start = (a.start.x, a.start.y); + let a_end = (a.end.x, a.end.y); + let b_start = (b.start.x, b.start.y); + let b_end = (b.end.x, b.end.y); + + // Skip if they share an endpoint (that's fine). + let shares_endpoint = a_start == b_start + || a_start == b_end + || a_end == b_start + || a_end == b_end; + + if !shares_endpoint { + let has_crossing = + segments_contact(a_start, a_end, b_start, b_end, 1e-9); + assert!( + !has_crossing, + "nodify({name}): output segments {i} and {j} have a crossing" + ); + } + } + } + } +} + +/// After nodify, no two output segments should have positive collinear overlap. +#[test] +fn nodify_output_has_no_collinear_overlaps() { + for name in [ + "simple_crossing", + "collinear_overlap", + "t_junction", + "collinear_contained", + "duplicate_lines", + "reversed_duplicate", + ] { + let input_lines = load_nodify_input_lines(name); + let output = nodify_lines(input_lines.into_iter(), 1e-9); + + for i in 0..output.len() { + for j in (i + 1)..output.len() { + assert!( + !lines_have_positive_collinear_overlap(&output[i], &output[j], 1e-9), + "nodify({name}): output segments {i} and {j} have collinear overlap" + ); + } + } + } } \ No newline at end of file From 8c18ad30949a8e8032300dbe057073d4a843e2d2 Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Mon, 20 Apr 2026 04:00:25 -0400 Subject: [PATCH 11/12] Fix an issue with hole assignment --- .../input/invalid_hole_assignment.geojson | 1 + .../output/invalid_hole_assignment.geojson | 250 ++++++++++++++++++ src/graph/shell_assignment.rs | 67 ++++- src/tests.rs | 56 ++++ 4 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 fixtures/polygonizer/input/invalid_hole_assignment.geojson create mode 100644 fixtures/polygonizer/output/invalid_hole_assignment.geojson diff --git a/fixtures/polygonizer/input/invalid_hole_assignment.geojson b/fixtures/polygonizer/input/invalid_hole_assignment.geojson new file mode 100644 index 0000000..f66f955 --- /dev/null +++ b/fixtures/polygonizer/input/invalid_hole_assignment.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[66.917152079,-49.483609955],[67.076757868,-49.04184243]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[66.917152079,-49.483609955],[68.49003423,-50.548027294]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[67.076757868,-49.04184243],[68.698823473,-48.144678001]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[67.96072344674927,-49.21812840419421],[68.03560388863696,-49.526937060777]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[67.96072344674927,-49.21812840419421],[68.03560388863696,-48.90931974761142]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.03560388863696,-49.526937060777],[68.25291539492534,-49.80551737445704]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.03560388863696,-48.90931974761142],[68.25291539492534,-48.63073943393138]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.25291539492534,-49.80551737445704],[68.5913860012806,-50.02659996314815]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.25291539492534,-48.63073943393138],[68.5913860012806,-48.40965684524027]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.49003423,-50.548027294],[69.861979006,-50.549689302]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.5913860012806,-50.02659996314815],[69.01788384648931,-50.16854372269627]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.5913860012806,-48.40965684524027],[69.01788384648931,-48.26771308569215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.698823473,-48.144678001],[70.997703438,-47.154869247]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.01788384648931,-50.16854372269627],[69.49066034987723,-50.21745420893651]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.01788384648931,-48.26771308569215],[69.49066034987723,-48.21880259945191]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.49066034987723,-50.21745420893651],[69.96343685326515,-50.16854372269627]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.49066034987723,-48.21880259945191],[69.96343685326515,-48.26771308569215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.861979006,-50.549689302],[70.26289097957036,-50.06888168459346]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.96343685326515,-50.16854372269627],[70.26289097957036,-50.06888168459346]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[69.96343685326515,-48.26771308569215],[70.38993469847387,-48.40965684524027]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.26289097957036,-50.06888168459346],[70.38993469847387,-50.02659996314815]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.26289097957036,-50.06888168459346],[71.00971073191234,-49.17323214198215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.38993469847387,-50.02659996314815],[70.72840530482912,-49.80551737445704]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.38993469847387,-48.40965684524027],[70.72840530482912,-48.63073943393138]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.72840530482912,-49.80551737445704],[70.9457168111175,-49.526937060777]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.72840530482912,-48.63073943393138],[70.9457168111175,-48.90931974761142]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.9457168111175,-49.526937060777],[71.02059725300519,-49.21812840419421]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.9457168111175,-48.90931974761142],[71.00971073191234,-49.17323214198215]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[70.997703438,-47.154869247],[71.638389864,-47.695087312]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[71.00971073191234,-49.17323214198215],[71.02059725300519,-49.21812840419421]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[71.00971073191234,-49.17323214198215],[71.09668316,-49.068927435]]},"properties":null},{"type":"Feature","geometry":{"type":"LineString","coordinates":[[71.09668316,-49.068927435],[71.638389864,-47.695087312]]},"properties":null}]} \ No newline at end of file diff --git a/fixtures/polygonizer/output/invalid_hole_assignment.geojson b/fixtures/polygonizer/output/invalid_hole_assignment.geojson new file mode 100644 index 0000000..d431351 --- /dev/null +++ b/fixtures/polygonizer/output/invalid_hole_assignment.geojson @@ -0,0 +1,250 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 67.96072344674927, + -49.21812840419421 + ], + [ + 68.03560388863696, + -49.526937060777 + ], + [ + 68.25291539492534, + -49.80551737445704 + ], + [ + 68.5913860012806, + -50.02659996314815 + ], + [ + 69.01788384648931, + -50.16854372269627 + ], + [ + 69.49066034987723, + -50.21745420893651 + ], + [ + 69.96343685326515, + -50.16854372269627 + ], + [ + 70.26289097957036, + -50.06888168459346 + ], + [ + 71.00971073191234, + -49.17323214198215 + ], + [ + 70.9457168111175, + -48.90931974761142 + ], + [ + 70.72840530482912, + -48.63073943393138 + ], + [ + 70.38993469847387, + -48.40965684524027 + ], + [ + 69.96343685326515, + -48.26771308569215 + ], + [ + 69.49066034987723, + -48.21880259945191 + ], + [ + 69.01788384648931, + -48.26771308569215 + ], + [ + 68.5913860012806, + -48.40965684524027 + ], + [ + 68.25291539492534, + -48.63073943393138 + ], + [ + 68.03560388863696, + -48.90931974761142 + ], + [ + 67.96072344674927, + -49.21812840419421 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 70.26289097957036, + -50.06888168459346 + ], + [ + 70.38993469847387, + -50.02659996314815 + ], + [ + 70.72840530482912, + -49.80551737445704 + ], + [ + 70.9457168111175, + -49.526937060777 + ], + [ + 71.02059725300519, + -49.21812840419421 + ], + [ + 71.00971073191234, + -49.17323214198215 + ], + [ + 70.26289097957036, + -50.06888168459346 + ] + ] + ] + }, + "properties": null + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 66.917152079, + -49.483609955 + ], + [ + 68.49003423, + -50.548027294 + ], + [ + 69.861979006, + -50.549689302 + ], + [ + 70.26289097957036, + -50.06888168459346 + ], + [ + 69.96343685326515, + -50.16854372269627 + ], + [ + 69.49066034987723, + -50.21745420893651 + ], + [ + 69.01788384648931, + -50.16854372269627 + ], + [ + 68.5913860012806, + -50.02659996314815 + ], + [ + 68.25291539492534, + -49.80551737445704 + ], + [ + 68.03560388863696, + -49.526937060777 + ], + [ + 67.96072344674927, + -49.21812840419421 + ], + [ + 68.03560388863696, + -48.90931974761142 + ], + [ + 68.25291539492534, + -48.63073943393138 + ], + [ + 68.5913860012806, + -48.40965684524027 + ], + [ + 69.01788384648931, + -48.26771308569215 + ], + [ + 69.49066034987723, + -48.21880259945191 + ], + [ + 69.96343685326515, + -48.26771308569215 + ], + [ + 70.38993469847387, + -48.40965684524027 + ], + [ + 70.72840530482912, + -48.63073943393138 + ], + [ + 70.9457168111175, + -48.90931974761142 + ], + [ + 71.00971073191234, + -49.17323214198215 + ], + [ + 71.09668316, + -49.068927435 + ], + [ + 71.638389864, + -47.695087312 + ], + [ + 70.997703438, + -47.154869247 + ], + [ + 68.698823473, + -48.144678001 + ], + [ + 67.076757868, + -49.04184243 + ], + [ + 66.917152079, + -49.483609955 + ] + ] + ] + }, + "properties": null + } + ] +} \ No newline at end of file diff --git a/src/graph/shell_assignment.rs b/src/graph/shell_assignment.rs index a3010a5..e6084e2 100644 --- a/src/graph/shell_assignment.rs +++ b/src/graph/shell_assignment.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use geo::orient::Direction; -use geo::{Area, Contains, GeoFloat, InteriorPoint, LineString, Orient, Polygon}; +use geo::{Area, Contains, GeoFloat, InteriorPoint, LineString, Orient, Polygon, Validation}; use rstar::{Envelope, RTreeObject}; struct ShellContainer { @@ -19,6 +19,16 @@ impl RTreeObject for ShellContainer { } } +fn sorted_ring_coords(ring: &LineString) -> Vec<(T, T)> { + let mut coords: Vec<(T, T)> = ring.coords().map(|c| (c.x, c.y)).collect(); + if coords.len() >= 2 && coords.first() == coords.last() { + coords.pop(); + } + coords.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); + coords.dedup(); + coords +} + pub(super) fn assign_shells_to_holes( shells: Vec>, holes: Vec>, @@ -93,7 +103,7 @@ pub(super) fn assign_shells_to_holes( .flat_map(|hole_indices| hole_indices.iter().copied()) .collect(); - for hole_index in assigned_hole_indices { + for hole_index in assigned_hole_indices.iter().copied() { let standalone_hole_polygon = Polygon::new(holes[hole_index].clone(), vec![]).orient(Direction::Default); @@ -121,5 +131,58 @@ pub(super) fn assign_shells_to_holes( } } + // --- Same-envelope hole recovery for invalid shell polygons --- + // + // Some hole rings share the exact same bounding box as their + // containing shell, which causes the `envelope != hole_envelope` + // filter above to skip them during assignment. When this happens + // AND the shell's assigned holes make it invalid (to be filtered + // later by `polygonize()`), those unassigned same-envelope holes + // represent genuine face-rings that would otherwise be lost. + // + // Recover them as standalone polygons here. We only do this for + // shells whose polygon is already invalid, to avoid adding spurious + // standalone polygons in the common case. + + for (shell_index, assigned) in assignments.iter() { + if assigned.is_empty() { + continue; + } + + // Check whether this shell with its assigned holes is invalid. + let test_polygon = Polygon::new( + shell_polygons[*shell_index].exterior().clone(), + assigned.iter().map(|hi| holes[*hi].clone()).collect(), + ) + .orient(Direction::Default); + + if test_polygon.is_valid() { + continue; + } + + // Shell + holes is invalid — look for unassigned holes that + // share the shell's bounding box but have different vertices. + let shell_env = shell_polygons[*shell_index].exterior().envelope(); + let shell_sorted = sorted_ring_coords(shell_polygons[*shell_index].exterior()); + + for (hole_index, hole) in holes.iter().enumerate() { + if assigned_hole_indices.contains(&hole_index) { + continue; + } + if hole.envelope() != shell_env { + continue; + } + let hole_sorted = sorted_ring_coords(hole); + if hole_sorted == shell_sorted { + continue; // same vertex set = reverse of shell, skip + } + let standalone = + Polygon::new(hole.clone(), vec![]).orient(Direction::Default); + if !polygons.contains(&standalone) { + polygons.push(standalone); + } + } + } + polygons } diff --git a/src/tests.rs b/src/tests.rs index 07d139a..c0831ae 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -663,6 +663,62 @@ fn polygonize_venn_overlaps_split_into_distinct_regions() { ); } +/// Hole assignment doesn't inadvertently delete polygons. +#[test] +fn polygonize_invalid_hole_assignment() { + assert_polygonize_fixture("invalid_hole_assignment"); +} + +/// The specific probe point that was lost before the same-envelope hole +/// recovery fix must be contained by exactly one output polygon, every +/// polygon must be valid, and no two polygons may share an interior point. +#[test] +fn polygonize_invalid_hole_assignment_invariants() { + let lines = load_input_lines("invalid_hole_assignment"); + let polygons = polygonize(lines.into_iter()); + + // The previously-dropped polygon must contain this probe. + let probe = point! { x: 70.5, y: -47.95 }; + let probe_owners: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(i, p)| if p.contains(&probe) { Some(i) } else { None }) + .collect(); + assert_eq!( + probe_owners.len(), + 1, + "probe (70.5, -47.95) should be owned by exactly 1 polygon, got {probe_owners:?}" + ); + + // All polygons must be valid. + for (i, polygon) in polygons.0.iter().enumerate() { + assert!( + polygon.is_valid(), + "polygon {i} is invalid: {:?}", + polygon.validation_errors() + ); + } + + // Each polygon's interior point must be owned by exactly one polygon. + for (i, polygon) in polygons.0.iter().enumerate() { + let ip = polygon + .interior_point() + .unwrap_or_else(|| panic!("polygon {i} has no interior point")); + let owners: Vec = polygons + .0 + .iter() + .enumerate() + .filter_map(|(j, p)| if p.contains(&ip) { Some(j) } else { None }) + .collect(); + assert_eq!( + owners.len(), + 1, + "polygon {i} interior point owned by {owners:?}, expected exactly 1" + ); + } +} + /// Standalone-hole logic in shell assignment: a hole ring that cannot be /// assigned to any shell is surfaced as a standalone polygon. #[test] From e5b89d6f9e9eabd1da32192a49141001d68f6da4 Mon Sep 17 00:00:00 2001 From: Andrew Pendleton Date: Mon, 20 Apr 2026 04:02:32 -0400 Subject: [PATCH 12/12] remove the giant fixture --- examples/generate_single_fixture_output.rs | 4 +- examples/update_very_complex_fixture.rs | 2 +- src/graph.rs | 15 +- src/graph/shell_assignment.rs | 3 +- src/graph/topology_cleanup.rs | 36 ++-- src/tests.rs | 212 +-------------------- 6 files changed, 40 insertions(+), 232 deletions(-) diff --git a/examples/generate_single_fixture_output.rs b/examples/generate_single_fixture_output.rs index 236a675..250b10f 100644 --- a/examples/generate_single_fixture_output.rs +++ b/examples/generate_single_fixture_output.rs @@ -45,7 +45,9 @@ fn load_input_lines(name: &str) -> Vec> { } fn main() { - let name = std::env::args().nth(1).unwrap_or_else(|| "minimal_secondary_probe_split".to_string()); + let name = std::env::args() + .nth(1) + .unwrap_or_else(|| "minimal_secondary_probe_split".to_string()); let input_lines = load_input_lines(&name); let polygons = polygonize(input_lines); diff --git a/examples/update_very_complex_fixture.rs b/examples/update_very_complex_fixture.rs index 771b041..fd7c1c8 100644 --- a/examples/update_very_complex_fixture.rs +++ b/examples/update_very_complex_fixture.rs @@ -74,4 +74,4 @@ fn main() { let output_json = serde_json::to_string_pretty(&collection).expect("serialize output"); fs::write(&output_path, output_json) .unwrap_or_else(|err| panic!("failed to write fixture {}: {err}", output_path.display())); -} \ No newline at end of file +} diff --git a/src/graph.rs b/src/graph.rs index 208b9b1..c887e2c 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -519,23 +519,18 @@ impl PolygonizerGraph { let polygons = topology_cleanup::infer_parent_holes_when_output_has_no_holes(valid_polygons); // Re-polygonize boundaries at degree>2 nodes to split touching polygons. - let polygons = - topology_cleanup::split_touching_boundary_polygons(polygons); + let polygons = topology_cleanup::split_touching_boundary_polygons(polygons); // Absorb tiny standalones sitting between holes into their parent polygon. - let polygons = - topology_cleanup::infer_contained_standalone_polygons_as_holes(polygons); + let polygons = topology_cleanup::infer_contained_standalone_polygons_as_holes(polygons); // Resolve overlapping polygon ownership by removing shared interior points. let polygons = topology_cleanup::remove_non_unique_interior_points_for_touching_topology(polygons); // Carve contained standalone polygons as holes of their enclosing parent. - let polygons = - topology_cleanup::carve_contained_standalones_as_holes(polygons); + let polygons = topology_cleanup::carve_contained_standalones_as_holes(polygons); // Merge holes connected by bridge standalones into unified holes. - let polygons = - topology_cleanup::merge_touching_holes_in_polygons(polygons); + let polygons = topology_cleanup::merge_touching_holes_in_polygons(polygons); // Second carve pass to catch standalones revealed by merging. - let polygons = - topology_cleanup::carve_contained_standalones_as_holes(polygons); + let polygons = topology_cleanup::carve_contained_standalones_as_holes(polygons); MultiPolygon(polygons) } diff --git a/src/graph/shell_assignment.rs b/src/graph/shell_assignment.rs index e6084e2..14518d1 100644 --- a/src/graph/shell_assignment.rs +++ b/src/graph/shell_assignment.rs @@ -176,8 +176,7 @@ pub(super) fn assign_shells_to_holes( if hole_sorted == shell_sorted { continue; // same vertex set = reverse of shell, skip } - let standalone = - Polygon::new(hole.clone(), vec![]).orient(Direction::Default); + let standalone = Polygon::new(hole.clone(), vec![]).orient(Direction::Default); if !polygons.contains(&standalone) { polygons.push(standalone); } diff --git a/src/graph/topology_cleanup.rs b/src/graph/topology_cleanup.rs index 2d71502..ddd38c9 100644 --- a/src/graph/topology_cleanup.rs +++ b/src/graph/topology_cleanup.rs @@ -549,13 +549,15 @@ pub(super) fn carve_contained_standalones_as_holes( continue; } - let parent_exterior_only = - Polygon::new(polygons[parent_idx].exterior().clone(), vec![]); + let parent_exterior_only = Polygon::new(polygons[parent_idx].exterior().clone(), vec![]); // Build per-hole vertex lists. let hole_vertex_sets: Vec> = polygons[parent_idx] @@ -724,7 +726,9 @@ pub(super) fn merge_touching_holes_in_polygons( .map(|c| (c.x, c.y)) .collect(); let shares_vertex = child_coords.iter().any(|cv| { - parent_hole_coords.iter().any(|pv| pv.0 == cv.0 && pv.1 == cv.1) + parent_hole_coords + .iter() + .any(|pv| pv.0 == cv.0 && pv.1 == cv.1) }); if !shares_vertex { continue; diff --git a/src/tests.rs b/src/tests.rs index c0831ae..66ab5e0 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use geo::{Area, BooleanOps, Contains, InteriorPoint, Intersects, Line, LinesIter, MultiPolygon, Polygon, Validation, point}; +use geo::{Contains, InteriorPoint, Line, LinesIter, MultiPolygon, Polygon, Validation, point}; use geojson::GeometryValue; use super::*; @@ -890,156 +890,6 @@ fn polygonize_split_touching_two_points_matches_fixture() { assert_polygonize_fixture("split_touching_two_points"); } -/// Full fixture match on 307-polygon complex linework. -/// -/// Depends on passes: 1 (infer_parent_holes), 2 (split_touching), -/// 3 (infer_contained), 4 (remove_non_unique), 6 (merge_touching_holes), -/// 7 (carve_contained, second). -#[test] -fn complex_geometry_dropped_polygon() { - assert_polygonize_fixture("very_complex_linework"); -} - -/// Point-containment probe at (133.75, 38.25): the point must be owned by -/// exactly one polygon. -/// -/// Depends on pass 4 (remove_non_unique). -#[test] -fn complex_geometry_no_overlap_at_probe() { - let lines = load_input_lines("very_complex_linework"); - let polygons = polygonize(lines.into_iter()); - let probe = point! { x: 133.75, y: 38.25 }; - let containing_count = polygons - .0 - .iter() - .filter(|polygon| polygon.contains(&probe)) - .count(); - - assert_eq!(containing_count, 1); -} - -/// Two nearby probes on opposite sides of a boundary must each be owned by -/// exactly one polygon, and those polygons must differ. -/// -/// Depends on pass 6 (merge_touching_holes). -#[test] -fn complex_geometry_no_overlap_and_split_below_secondary_probe() { - let lines = load_input_lines("very_complex_linework"); - let polygons = polygonize(lines.into_iter()); - - let upper_probe = point! { x: 110.93, y: 20.51 }; - let lower_probe = point! { x: 110.93, y: 20.499 }; - - let upper_owners: Vec = polygons - .0 - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if polygon.contains(&upper_probe) { - Some(polygon_index) - } else { - None - } - }) - .collect(); - let lower_owners: Vec = polygons - .0 - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if polygon.contains(&lower_probe) { - Some(polygon_index) - } else { - None - } - }) - .collect(); - - assert_eq!(upper_owners.len(), 1); - assert_eq!(lower_owners.len(), 1); - assert_ne!(upper_owners[0], lower_owners[0]); -} - -/// Pinch-point robustness: two regions connected only at a pinch vertex -/// must remain separate polygons. -/// -/// Depends on pass 6 (merge_touching_holes). -#[test] -fn complex_geometry_pinch_point_vertices_do_not_merge_regions() { - let lines = load_input_lines("very_complex_linework"); - let polygons = polygonize(lines.into_iter()); - - let left_probe = point! { x: 110.9, y: 20.45 }; - let right_probe = point! { x: 111.0, y: 20.55 }; - - let left_owners: Vec = polygons - .0 - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if polygon.contains(&left_probe) { - Some(polygon_index) - } else { - None - } - }) - .collect(); - let right_owners: Vec = polygons - .0 - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if polygon.contains(&right_probe) { - Some(polygon_index) - } else { - None - } - }) - .collect(); - - let left_intersections: Vec = polygons - .0 - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if polygon.intersects(&left_probe) { - Some(polygon_index) - } else { - None - } - }) - .collect(); - let right_intersections: Vec = polygons - .0 - .iter() - .enumerate() - .filter_map(|(polygon_index, polygon)| { - if polygon.intersects(&right_probe) { - Some(polygon_index) - } else { - None - } - }) - .collect(); - - let left_hole_counts: Vec = left_owners - .iter() - .map(|polygon_index| polygons.0[*polygon_index].interiors().len()) - .collect(); - let right_hole_counts: Vec = right_owners - .iter() - .map(|polygon_index| polygons.0[*polygon_index].interiors().len()) - .collect(); - - assert_eq!(left_owners.len(), 1, "left_owners={left_owners:?}, left_hole_counts={left_hole_counts:?}, right_owners={right_owners:?}, right_hole_counts={right_hole_counts:?}, left_intersections={left_intersections:?}, right_intersections={right_intersections:?}"); - assert_eq!(right_owners.len(), 1, "left_owners={left_owners:?}, left_hole_counts={left_hole_counts:?}, right_owners={right_owners:?}, right_hole_counts={right_hole_counts:?}, left_intersections={left_intersections:?}, right_intersections={right_intersections:?}"); - assert_ne!( - left_owners[0], - right_owners[0], - "left_owners={left_owners:?}, right_owners={right_owners:?}, left_hole_counts={left_hole_counts:?}, right_hole_counts={right_hole_counts:?}" - ); -} - /// Minimal reproducer for split-touching-hole-drop: a hole that touches /// the shell boundary must be cleanly split. /// @@ -1073,41 +923,6 @@ fn polygonize_nested_shell_overlap_minimal_matches_fixture() { assert_polygonize_fixture("nested_shell_overlap_minimal"); } -/// Every polygon in the very_complex_linework output must be valid and no -/// two polygons may overlap with positive area. -/// -/// Depends on passes: 1 (infer_parent_holes), 3 (infer_contained), -/// 4 (remove_non_unique), 6 (merge_touching_holes), -/// 7 (carve_contained, second). -#[test] -fn complex_geometry_no_overlapping_polygons() { - let lines = load_input_lines("very_complex_linework"); - let polygons = polygonize(lines.into_iter()); - - // All polygons must be individually valid. - for (i, polygon) in polygons.0.iter().enumerate() { - assert!( - polygon.is_valid(), - "polygon {i} is invalid: {:?}", - polygon.validation_errors() - ); - } - - // No two polygons may overlap with positive area. - for i in 0..polygons.0.len() { - for j in (i + 1)..polygons.0.len() { - if polygons.0[i].intersects(&polygons.0[j]) { - let inter = polygons.0[i].intersection(&polygons.0[j]); - let area = inter.unsigned_area(); - assert!( - area <= 1e-6, - "polygon {i} and polygon {j} overlap with area {area}" - ); - } - } - } -} - // =========================================================================== // Standalone nodify fixture tests // =========================================================================== @@ -1147,9 +962,7 @@ fn load_nodify_input_lines(name: &str) -> Vec> { multiline.lines_iter().collect::>() } other => { - panic!( - "nodify input fixture {name} uses unsupported geometry type: {other:?}" - ) + panic!("nodify input fixture {name} uses unsupported geometry type: {other:?}") } } }) @@ -1171,9 +984,7 @@ fn load_expected_output_lines(name: &str) -> Vec> { linestring.lines().collect::>() } other => { - panic!( - "nodify output fixture {name} uses unsupported geometry type: {other:?}" - ) + panic!("nodify output fixture {name} uses unsupported geometry type: {other:?}") } } }) @@ -1333,9 +1144,9 @@ fn nodify_output_endpoints_are_valid() { for line in &output { for endpoint in [line.start, line.end] { // The endpoint must lie on at least one input line. - let on_some_input = input_lines.iter().any(|input_line| { - point_is_on_line_segment(endpoint, input_line, 1e-6) - }); + let on_some_input = input_lines + .iter() + .any(|input_line| point_is_on_line_segment(endpoint, input_line, 1e-6)); assert!( on_some_input, "nodify({name}): output endpoint ({}, {}) does not lie on any input line", @@ -1374,14 +1185,11 @@ fn nodify_output_has_no_proper_crossings() { let b_end = (b.end.x, b.end.y); // Skip if they share an endpoint (that's fine). - let shares_endpoint = a_start == b_start - || a_start == b_end - || a_end == b_start - || a_end == b_end; + let shares_endpoint = + a_start == b_start || a_start == b_end || a_end == b_start || a_end == b_end; if !shares_endpoint { - let has_crossing = - segments_contact(a_start, a_end, b_start, b_end, 1e-9); + let has_crossing = segments_contact(a_start, a_end, b_start, b_end, 1e-9); assert!( !has_crossing, "nodify({name}): output segments {i} and {j} have a crossing" @@ -1415,4 +1223,4 @@ fn nodify_output_has_no_collinear_overlaps() { } } } -} \ No newline at end of file +}