Skip to main content

geo_polygonize_core/
polygonizer.rs

1use crate::containment::ContainmentForest;
2use crate::diagnostics::{ContainmentStats, PolygonizerDiagnostics};
3use crate::error::Result;
4use crate::graph::{ExtractedRing, PlanarGraph};
5use crate::noding::advanced::AdvancedNoder;
6use crate::noding::snap::SnapNoder;
7use crate::options::DiagnosticsOptions;
8use crate::options::{DeterminismOptions, PolygonizerOptions, SnapStrategy};
9use crate::types::{Coord3D, Line3D, Polygon3D, RingGraphIdentity};
10use crate::utils::simd::SimdRing;
11use crate::utils::z_order_index;
12use geo::Contains;
13use geo_types::{Coord, Geometry, Polygon};
14use std::collections::HashMap;
15
16#[cfg(not(target_arch = "wasm32"))]
17use std::time::Instant;
18#[cfg(target_arch = "wasm32")]
19use web_time::Instant;
20
21fn get_time() -> Instant {
22    Instant::now()
23}
24
25fn get_elapsed(start: Instant) -> std::time::Duration {
26    start.elapsed()
27}
28
29#[cfg(feature = "parallel")]
30use rayon::prelude::*;
31
32/// A robust polygonizer that reconstructs polygons from a set of lines (3D supported).
33pub struct Polygonizer {
34    graph: PlanarGraph,
35    // Configuration
36    pub check_valid_rings: bool,
37    pub options: PolygonizerOptions,
38
39    // Legacy fields maintained for backward compatibility wrappers during transition
40    pub node_input: bool,
41    pub snap_grid_size: f64,
42    pub extract_only_polygonal: bool,
43    pub determinism: DeterminismOptions,
44    pub diagnostics_options: DiagnosticsOptions,
45
46    // Buffer for explicit line segments (3D)
47    input_lines: Vec<Line3D>,
48    dirty: bool,
49}
50
51#[derive(Clone, Debug, serde::Serialize)]
52pub struct PolygonizerResult {
53    pub polygons: Vec<Polygon3D>,
54    pub dangles: Vec<Vec<Coord3D>>,
55    pub cut_edges: Vec<Vec<Coord3D>>,
56    pub invalid_rings: Vec<Vec<Coord3D>>,
57    pub diagnostics: Option<PolygonizerDiagnostics>,
58}
59
60impl Default for Polygonizer {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66/// A stable, explicit entrypoint across all bindings to polygonize via `PolygonizerOptions`.
67pub fn polygonize_with_options(
68    lines: &[Line3D],
69    options: &PolygonizerOptions,
70) -> Result<PolygonizerResult> {
71    let mut polygonizer = Polygonizer::with_options(options.clone());
72    polygonizer.add_lines(lines.to_vec());
73    polygonizer.polygonize()
74}
75
76impl Polygonizer {
77    /// Creates a new `Polygonizer` with default configuration.
78    pub fn new() -> Self {
79        Self {
80            graph: PlanarGraph::new(),
81            check_valid_rings: true,
82            options: PolygonizerOptions::default(),
83            node_input: false,
84            snap_grid_size: 1e-10, // Default tolerance
85            extract_only_polygonal: false,
86            determinism: DeterminismOptions::default(),
87            diagnostics_options: DiagnosticsOptions::default(),
88            input_lines: Vec::new(),
89            dirty: false,
90        }
91    }
92    /// Creates a new `Polygonizer` with specific options.
93    pub fn with_options(options: PolygonizerOptions) -> Self {
94        Self {
95            graph: PlanarGraph::new(),
96            check_valid_rings: true,
97            node_input: options.node_input,
98            snap_grid_size: options.snap_grid_size,
99            extract_only_polygonal: options.extract_only_polygonal,
100            determinism: options.determinism.clone(),
101            diagnostics_options: options.diagnostics.clone(),
102            options,
103            input_lines: Vec::new(),
104            dirty: false,
105        }
106    }
107
108    /// Sets the snap grid size for noding.
109    ///
110    /// # Arguments
111    ///
112    /// * `grid_size` - The size of the grid cells. Smaller values mean higher precision but potential for robustness issues if too small.
113    pub fn with_snap_grid(mut self, grid_size: f64) -> Self {
114        self.snap_grid_size = grid_size;
115        self
116    }
117
118    /// Adds a 2D geometry to the graph (Z=0).
119    pub fn add_geometry(&mut self, geom: Geometry<f64>) {
120        extract_segments(&geom, &mut self.input_lines);
121        self.dirty = true;
122    }
123
124    /// Adds a 2D geometry to the graph (Z=0) from a reference.
125    pub fn add_borrowed_geometry(&mut self, geom: &Geometry<f64>) {
126        extract_segments(geom, &mut self.input_lines);
127        self.dirty = true;
128    }
129
130    /// Adds explicit 3D lines.
131    pub fn add_lines(&mut self, lines: Vec<Line3D>) {
132        self.input_lines.extend(lines);
133        self.dirty = true;
134    }
135
136    fn build_graph(&mut self, diagnostics: Option<&mut PolygonizerDiagnostics>) -> Result<()> {
137        if !self.dirty {
138            return Ok(());
139        }
140
141        // Sync legacy wrapper fields back into options before executing
142        self.options.node_input = self.node_input;
143        self.options.snap_grid_size = self.snap_grid_size;
144        self.options.extract_only_polygonal = self.extract_only_polygonal;
145        self.options.determinism = self.determinism.clone();
146        self.options.diagnostics = self.diagnostics_options.clone();
147
148        let mut all_segments: Vec<Line3D> = self.input_lines.clone();
149
150        let mut segments;
151
152        if self.options.node_input {
153            let mut pre_snap_vertex_candidates = 0;
154            if self.options.pre_snap_tolerance > 0.0 {
155                let (snapped, candidates) = SnapNoder::pre_snap_to_reference_vertices_with_stats(
156                    &all_segments,
157                    self.options.pre_snap_tolerance,
158                );
159                all_segments = snapped;
160                pre_snap_vertex_candidates = candidates;
161            }
162
163            // Sort by 2D coordinates
164            all_segments.sort_unstable_by(|a, b| {
165                a.start
166                    .x
167                    .total_cmp(&b.start.x)
168                    .then(a.start.y.total_cmp(&b.start.y))
169            });
170            // Dedup based on 3D equality? or 2D?
171            // SnapNoder will handle dedup.
172            all_segments.dedup_by(|a, b| {
173                a.start.x == b.start.x && a.start.y == b.start.y
174                && a.end.x == b.end.x && a.end.y == b.end.y
175                // Ignore Z for initial dedup of "same projected line" if that's what we want?
176                // Probably better to keep exact duplicates removed.
177                && a.start.z == b.start.z && a.end.z == b.end.z
178            });
179
180            // OPTIMIZATION: Spatial Sort (Z-Order 2D)
181            let mut numbered_lines: Vec<(u64, Line3D)> = all_segments
182                .iter()
183                .map(|l| (z_order_index(l.start.to_coord_2d()), *l))
184                .collect();
185
186            // Unstable sort is faster and sufficient
187            numbered_lines.sort_unstable_by_key(|k| k.0);
188
189            all_segments = numbered_lines.into_iter().map(|k| k.1).collect();
190
191            match self.options.noding.backend {
192                crate::options::NodingBackend::Snap => {
193                    let noder = SnapNoder::new(self.options.snap_grid_size)
194                        .with_snap_strategy(self.options.snap_strategy.clone());
195                    // ponytail: one source coordinate per snapped node preserves connectivity;
196                    // use per-line restoration only if #798 can avoid reopening gaps.
197                    let restore_coordinates =
198                        matches!(self.options.snap_strategy, SnapStrategy::GeosCompat)
199                            .then(|| restored_coordinates(&noder, &all_segments));
200                    if let Some(diagnostics) = diagnostics {
201                        let (noded, stats, mut work_stats) = noder.node_with_stats(all_segments);
202                        work_stats.pre_snap_vertex_candidates = pre_snap_vertex_candidates;
203                        diagnostics.intersection_stats.interpolated_intersections = stats
204                            .iter()
205                            .map(|iteration| iteration.intersections_found)
206                            .sum();
207                        diagnostics.noding_iterations = stats;
208                        diagnostics.intersection_stats.exact_intersections =
209                            work_stats.exact_intersection_calls;
210                        diagnostics.noding_work_stats = work_stats;
211                        segments = noded;
212                    } else {
213                        segments = noder.node(all_segments);
214                    }
215                    if let Some(coordinates) = restore_coordinates {
216                        restore_noded_coordinates(&mut segments, &coordinates);
217                    }
218                }
219                crate::options::NodingBackend::Advanced => {
220                    let noder = AdvancedNoder::new();
221                    if let Some(diagnostics) = diagnostics {
222                        let (noded, stats, mut work_stats) =
223                            SnapNoder::new(0.0).node_with_stats(all_segments);
224                        work_stats.pre_snap_vertex_candidates = pre_snap_vertex_candidates;
225                        diagnostics.intersection_stats.interpolated_intersections = stats
226                            .iter()
227                            .map(|iteration| iteration.intersections_found)
228                            .sum();
229                        diagnostics.noding_iterations = stats;
230                        diagnostics.intersection_stats.exact_intersections =
231                            work_stats.exact_intersection_calls;
232                        diagnostics.noding_work_stats = work_stats;
233                        segments = noded;
234                    } else {
235                        segments = noder.node(all_segments);
236                    }
237                }
238            }
239        } else {
240            segments = all_segments;
241        }
242
243        // Use bulk load
244        self.graph.bulk_load(segments);
245
246        self.dirty = false;
247        Ok(())
248    }
249
250    /// Computes the polygons.
251    /// This is the main entry point.
252    ///
253    /// Returns a `PolygonizerResult` containing polygons and dangles.
254    pub fn polygonize(&mut self) -> Result<PolygonizerResult> {
255        // Sync legacy wrapper fields back into options before executing (if not called by build_graph)
256        self.options.node_input = self.node_input;
257        self.options.snap_grid_size = self.snap_grid_size;
258        self.options.extract_only_polygonal = self.extract_only_polygonal;
259        self.options.determinism = self.determinism.clone();
260        self.options.diagnostics = self.diagnostics_options.clone();
261
262        let mut diag = if self.options.diagnostics.enabled || self.options.diagnostics.timings {
263            let d = PolygonizerDiagnostics {
264                input_segment_count: self.input_lines.len(),
265                ..Default::default()
266            };
267            Some(d)
268        } else {
269            None
270        };
271
272        let t_ingest_start = get_time();
273        self.build_graph(if self.options.diagnostics.enabled {
274            diag.as_mut()
275        } else {
276            None
277        })?;
278        if let Some(ref mut d) = diag {
279            d.phase_times.ingest_and_node = get_elapsed(t_ingest_start);
280            d.noded_segment_count = self.graph.edges.len();
281        }
282
283        let t_graph_build_start = get_time();
284        // 1. Sort edges (Geometry Graph operation)
285        self.graph.sort_edges();
286
287        // 2. Prune dangles
288        let mut dangles = self.graph.prune_dangles();
289
290        // 3. Find rings (3D)
291        let rings_with_ids = self
292            .graph
293            .get_edge_rings_with_graph_ids(self.options.node_input);
294
295        // 3b. Find cut edges
296        let mut cut_edges = self.graph.get_cut_edges();
297
298        if let Some(ref mut d) = diag {
299            d.phase_times.graph_build = get_elapsed(t_graph_build_start);
300            d.ring_count = rings_with_ids.len();
301            d.cut_edge_count = cut_edges.len();
302            d.dangle_count = dangles.len();
303        }
304
305        // 4. Classify Rings (Shell vs Hole)
306        let t_ring_extraction_start = get_time();
307        let (shells, holes, invalid_rings_candidates) =
308            extract_and_classify_rings(rings_with_ids, self.options.node_input);
309
310        if let Some(ref mut d) = diag {
311            d.phase_times.ring_extraction = get_elapsed(t_ring_extraction_start);
312            d.shell_count = shells.len();
313            d.hole_count = holes.len();
314        }
315
316        let t_containment_start = get_time();
317        // 5. Establish Topology
318        let (
319            shells,
320            shell_holes,
321            shell_holes_ids,
322            unassigned_hole_count,
323            unassigned_hole_area,
324            containment_stats,
325        ) = establish_topology(shells, holes, &self.options);
326
327        // 6. Construct Final Polygons
328        // Ensure we don't crash on NaNs during processing
329        let mut invalid_rings = if invalid_rings_candidates.is_empty() {
330            Vec::new()
331        } else {
332            let shells_2d: Vec<Polygon<f64>> = shells.iter().map(|s| s.to_polygon_2d()).collect();
333            process_invalid_rings(invalid_rings_candidates, &shells_2d)
334        };
335
336        let mut result =
337            construct_final_polygons(shells, shell_holes, shell_holes_ids, &self.options);
338
339        result = apply_determinism(
340            result,
341            &mut dangles,
342            &mut cut_edges,
343            &mut invalid_rings,
344            &self.options,
345        );
346
347        if let Some(ref mut d) = diag {
348            d.phase_times.containment = get_elapsed(t_containment_start);
349            d.unassigned_hole_count = unassigned_hole_count;
350            d.unassigned_hole_area = unassigned_hole_area;
351            d.containment_stats = containment_stats;
352            d.invalid_ring_count = invalid_rings.len();
353            // output_flatten time could be measured here if we had a separate pass, but we'll leave it 0 or record what we have
354        }
355        Ok(PolygonizerResult {
356            polygons: result,
357            dangles,
358            cut_edges,
359            invalid_rings,
360            diagnostics: diag,
361        })
362    }
363}
364
365fn restored_coordinates(noder: &SnapNoder, lines: &[Line3D]) -> HashMap<(u64, u64), Coord3D> {
366    let mut coordinates = HashMap::with_capacity(lines.len() * 2);
367    for coord in lines.iter().flat_map(|line| [line.start, line.end]) {
368        let snapped = noder.snap(coord);
369        let key = coordinate_key(snapped);
370        coordinates
371            .entry(key)
372            .and_modify(|current: &mut Coord3D| {
373                let distance = (coord.x - snapped.x).powi(2) + (coord.y - snapped.y).powi(2);
374                let current_distance =
375                    (current.x - snapped.x).powi(2) + (current.y - snapped.y).powi(2);
376                if distance.total_cmp(&current_distance).is_lt()
377                    || (distance == current_distance
378                        && (coord.x, coord.y, coord.z) < (current.x, current.y, current.z))
379                {
380                    *current = coord;
381                }
382            })
383            .or_insert(coord);
384    }
385    coordinates
386}
387
388fn coordinate_key(coord: Coord3D) -> (u64, u64) {
389    let x = if coord.x == 0.0 { 0.0 } else { coord.x };
390    let y = if coord.y == 0.0 { 0.0 } else { coord.y };
391    (x.to_bits(), y.to_bits())
392}
393
394fn restore_noded_coordinates(lines: &mut [Line3D], coordinates: &HashMap<(u64, u64), Coord3D>) {
395    for line in lines {
396        line.start = coordinates
397            .get(&coordinate_key(line.start))
398            .copied()
399            .unwrap_or(line.start);
400        line.end = coordinates
401            .get(&coordinate_key(line.end))
402            .copied()
403            .unwrap_or(line.end);
404    }
405}
406
407pub(crate) fn canonicalize_ring(ring: &mut Vec<Coord3D>, mut ids: Option<&mut Vec<u32>>) {
408    if ring.is_empty() {
409        return;
410    }
411    let n = if ring.len() > 1 && ring.first() == ring.last() {
412        ring.len() - 1
413    } else {
414        ring.len()
415    };
416
417    // Bolt optimization: using `ring[..n].iter().enumerate().min_by(...)` instead of `(0..n).min_by(...)`
418    // eliminates bounds-checking overhead while preserving readability.
419    let min_idx = ring[..n]
420        .iter()
421        .enumerate()
422        .min_by(|(_, a), (_, b)| {
423            a.x.total_cmp(&b.x)
424                .then(a.y.total_cmp(&b.y))
425                .then(a.z.total_cmp(&b.z))
426        })
427        .map(|(i, _)| i)
428        .unwrap_or(0);
429
430    if min_idx > 0 {
431        ring[..n].rotate_left(min_idx);
432        if n < ring.len() {
433            ring[n] = ring[0];
434        } else {
435            ring.push(ring[0]);
436        }
437
438        if let Some(ref mut ids_vec) = ids {
439            if !ids_vec.is_empty() {
440                ids_vec.rotate_left(min_idx);
441            }
442        }
443    }
444}
445
446pub(crate) fn canonicalize_open_line(line: &mut [Coord3D]) {
447    if line.len() > 1 {
448        let first = line.first().unwrap();
449        let last = line.last().unwrap();
450        let cmp = last
451            .x
452            .total_cmp(&first.x)
453            .then(last.y.total_cmp(&first.y))
454            .then(last.z.total_cmp(&first.z));
455        if cmp == std::cmp::Ordering::Less {
456            line.reverse();
457        }
458    }
459}
460
461type ClassifiedRing = (Polygon3D, Option<RingGraphIdentity>);
462
463pub(crate) fn extract_and_classify_rings(
464    rings_with_ids: Vec<ExtractedRing>,
465    include_graph_ids: bool,
466) -> (Vec<ClassifiedRing>, Vec<ClassifiedRing>, Vec<Polygon3D>) {
467    let mut shells = Vec::with_capacity(rings_with_ids.len() / 2);
468    let mut holes = Vec::with_capacity(rings_with_ids.len() / 2);
469    let mut invalid_rings_candidates = Vec::new();
470
471    for ring in rings_with_ids {
472        let poly3d = Polygon3D::new(ring.coords, vec![], ring.line_ids, vec![]);
473        let area = poly3d.signed_area_2d();
474
475        if !area.is_finite() || area.abs() < 1e-9 {
476            invalid_rings_candidates.push(poly3d);
477            continue;
478        }
479
480        let ring = (
481            poly3d,
482            include_graph_ids.then(|| RingGraphIdentity::new(ring.edge_keys, ring.node_ids)),
483        );
484        if area > 0.0 {
485            shells.push(ring);
486        } else {
487            holes.push(ring);
488        }
489    }
490
491    (shells, holes, invalid_rings_candidates)
492}
493
494#[allow(clippy::type_complexity)]
495fn establish_topology(
496    shells: Vec<ClassifiedRing>,
497    holes: Vec<ClassifiedRing>,
498    options: &PolygonizerOptions,
499) -> (
500    Vec<Polygon3D>,
501    Vec<Vec<Vec<Coord3D>>>,
502    Vec<Vec<Vec<u32>>>,
503    usize,
504    f64,
505    ContainmentStats,
506) {
507    let (mut shells, mut shell_graph_ids): (Vec<_>, Vec<_>) = shells.into_iter().unzip();
508    let collect_stats = options.diagnostics.enabled;
509    let mut containment_stats = ContainmentStats::default();
510    if collect_stats {
511        containment_stats.prepared_shells = shells.len();
512    }
513    let mut forest = ContainmentForest::new_with_graph_ids(&shells, &shell_graph_ids);
514
515    if options.extract_only_polygonal {
516        let keep_mask = if collect_stats {
517            let (keep_mask, stats) =
518                forest.filter_polygonal_with_stats(&shells, &options.containment.touch_policy);
519            containment_stats.merge(stats);
520            keep_mask
521        } else {
522            forest.filter_polygonal(&shells, &options.containment.touch_policy)
523        };
524        let removed_count = keep_mask.iter().filter(|&&keep| !keep).count();
525
526        if removed_count > 0 {
527            if collect_stats {
528                forest.record_locator_reuse(&mut containment_stats);
529            }
530            let mut new_shells = Vec::new();
531            let mut new_shell_graph_ids = Vec::new();
532
533            for ((keep, shell), graph_ids) in keep_mask.into_iter().zip(shells).zip(shell_graph_ids)
534            {
535                if keep {
536                    new_shells.push(shell);
537                    new_shell_graph_ids.push(graph_ids);
538                }
539            }
540            shells = new_shells;
541            shell_graph_ids = new_shell_graph_ids;
542            if collect_stats {
543                containment_stats.prepared_shells += shells.len();
544            }
545            forest = ContainmentForest::new_with_graph_ids(&shells, &shell_graph_ids);
546        }
547    }
548
549    let process_hole_assignment = |(hole_3d, graph_ids): (
550        Polygon3D,
551        Option<RingGraphIdentity>,
552    )|
553     -> (Option<usize>, Polygon3D, ContainmentStats) {
554            let (best_shell_idx, stats) = if collect_stats {
555                forest.assign_hole_with_graph_ids_and_stats(
556                    &hole_3d,
557                    graph_ids.as_ref(),
558                    &shells,
559                    &options.containment.touch_policy,
560                )
561            } else {
562                (
563                    forest.assign_hole_with_graph_ids(
564                        &hole_3d,
565                        graph_ids.as_ref(),
566                        &shells,
567                        &options.containment.touch_policy,
568                    ),
569                    ContainmentStats::default(),
570                )
571            };
572            (best_shell_idx, hole_3d, stats)
573        };
574
575    let assignments: Vec<_>;
576    #[cfg(feature = "parallel")]
577    {
578        assignments = holes.into_par_iter().map(process_hole_assignment).collect();
579    }
580    #[cfg(not(feature = "parallel"))]
581    {
582        assignments = holes.into_iter().map(process_hole_assignment).collect();
583    }
584
585    let mut shell_holes: Vec<Vec<Vec<Coord3D>>> = vec![vec![]; shells.len()];
586    let mut shell_holes_ids: Vec<Vec<Vec<u32>>> = vec![vec![]; shells.len()];
587    let mut unassigned_hole_count = 0;
588    let mut unassigned_hole_area = 0.0;
589
590    for (idx, hole, stats) in assignments {
591        containment_stats.merge(stats);
592        if let Some(idx) = idx {
593            shell_holes[idx].push(hole.exterior);
594            shell_holes_ids[idx].push(hole.exterior_ids);
595        } else {
596            unassigned_hole_count += 1;
597            unassigned_hole_area += hole.exterior_unsigned_area_2d();
598        }
599    }
600    if collect_stats {
601        forest.record_locator_reuse(&mut containment_stats);
602    }
603
604    (
605        shells,
606        shell_holes,
607        shell_holes_ids,
608        unassigned_hole_count,
609        unassigned_hole_area,
610        containment_stats,
611    )
612}
613
614pub(crate) fn construct_final_polygons(
615    shells: Vec<Polygon3D>,
616    shell_holes: Vec<Vec<Vec<Coord3D>>>,
617    shell_holes_ids: Vec<Vec<Vec<u32>>>,
618    options: &PolygonizerOptions,
619) -> Vec<Polygon3D> {
620    let mut result = Vec::with_capacity(shells.len());
621    for ((shell, mut holes), mut holes_ids) in
622        shells.into_iter().zip(shell_holes).zip(shell_holes_ids)
623    {
624        let mut exterior = shell.exterior;
625        let mut exterior_ids = shell.exterior_ids;
626
627        if options.determinism.canonical_ring_rotation {
628            canonicalize_ring(&mut exterior, Some(&mut exterior_ids));
629            for (h, h_ids) in holes.iter_mut().zip(holes_ids.iter_mut()) {
630                canonicalize_ring(h, Some(h_ids));
631            }
632        }
633
634        if options.determinism.canonical_sort {
635            let mut combined_holes: Vec<_> = holes
636                .into_iter()
637                .zip(holes_ids)
638                .map(|(h, hi)| {
639                    let area = Polygon3D::ring_signed_area_2d(&h).abs();
640                    (h, hi, area)
641                })
642                .collect();
643
644            let use_stable_tie_breaks = options.determinism.stable_tie_breaks;
645            if use_stable_tie_breaks {
646                let mut combined_holes_with_bbox: Vec<_> = combined_holes
647                    .into_iter()
648                    .map(|(h, hi, area)| {
649                        let b = bounding_rect_3d(&h).unwrap_or(geo::Rect::new(
650                            geo::Coord { x: 0.0, y: 0.0 },
651                            geo::Coord { x: 0.0, y: 0.0 },
652                        ));
653                        (h, hi, area, b)
654                    })
655                    .collect();
656                combined_holes_with_bbox.sort_unstable_by(
657                    |(h1, _, area1, b1), (h2, _, area2, b2)| {
658                        area2.total_cmp(area1).then_with(|| {
659                            b1.min()
660                                .x
661                                .total_cmp(&b2.min().x)
662                                .then(b1.min().y.total_cmp(&b2.min().y))
663                                .then(h1.len().cmp(&h2.len()))
664                        })
665                    },
666                );
667
668                let mut h = Vec::with_capacity(combined_holes_with_bbox.len());
669                let mut hi = Vec::with_capacity(combined_holes_with_bbox.len());
670                for (hole, ids, _, _) in combined_holes_with_bbox {
671                    h.push(hole);
672                    hi.push(ids);
673                }
674                holes = h;
675                holes_ids = hi;
676            } else {
677                combined_holes
678                    .sort_unstable_by(|(_, _, area1), (_, _, area2)| area2.total_cmp(area1));
679
680                let mut h = Vec::with_capacity(combined_holes.len());
681                let mut hi = Vec::with_capacity(combined_holes.len());
682                for (hole, ids, _) in combined_holes {
683                    h.push(hole);
684                    hi.push(ids);
685                }
686                holes = h;
687                holes_ids = hi;
688            }
689        }
690
691        let mut p = Polygon3D::new(exterior, holes, exterior_ids, holes_ids);
692
693        if options.provenance.enabled {
694            let mut b_ids = Vec::new();
695            if options.provenance.include_boundary_line_ids {
696                for &id in &p.exterior_ids {
697                    if id != 0 {
698                        b_ids.push(id as u64);
699                    }
700                }
701                for hole_ids in &p.interiors_ids {
702                    for &id in hole_ids {
703                        if id != 0 {
704                            b_ids.push(id as u64);
705                        }
706                    }
707                }
708                b_ids.sort_unstable();
709                b_ids.dedup();
710            }
711
712            p.provenance = Some(crate::types::PolygonProvenance {
713                boundary_line_ids: b_ids,
714                input_profile_id: options.input_profile_id.clone(),
715            });
716        }
717
718        if p.unsigned_area_2d() > 1e-6 {
719            result.push(p);
720        }
721    }
722    result
723}
724
725#[cfg(test)]
726mod topology_tests {
727    use super::*;
728
729    #[test]
730    fn establish_topology_reports_unassigned_holes() {
731        let hole = Polygon3D::new(
732            vec![
733                Coord3D::new(0.0, 0.0, 0.0),
734                Coord3D::new(0.0, 10.0, 0.0),
735                Coord3D::new(10.0, 10.0, 0.0),
736                Coord3D::new(10.0, 0.0, 0.0),
737                Coord3D::new(0.0, 0.0, 0.0),
738            ],
739            vec![],
740            vec![1, 2, 3, 4],
741            vec![],
742        );
743
744        let (_, _, _, unassigned_count, unassigned_area, _) =
745            establish_topology(vec![], vec![(hole, None)], &PolygonizerOptions::default());
746
747        assert_eq!(unassigned_count, 1);
748        assert!((unassigned_area - 100.0).abs() < 1e-9);
749    }
750
751    #[test]
752    fn reorder_polygons_applies_sorted_old_indices() {
753        let polygon =
754            |label| Polygon3D::new(vec![Coord3D::new(label, 0.0, 0.0)], vec![], vec![], vec![]);
755        let mut polygons = vec![polygon(0.0), polygon(1.0), polygon(2.0)];
756
757        reorder_polygons(&mut polygons, [2, 0, 1].into_iter());
758
759        let labels: Vec<_> = polygons.iter().map(|p| p.exterior[0].x).collect();
760        assert_eq!(labels, [2.0, 0.0, 1.0]);
761    }
762}
763
764pub(crate) fn apply_determinism(
765    mut result: Vec<Polygon3D>,
766    dangles: &mut [Vec<Coord3D>],
767    cut_edges: &mut [Vec<Coord3D>],
768    invalid_rings: &mut Vec<Vec<Coord3D>>,
769    options: &PolygonizerOptions,
770) -> Vec<Polygon3D> {
771    if options.determinism.canonical_sort {
772        let use_stable_tie_breaks = options.determinism.stable_tie_breaks;
773        if use_stable_tie_breaks {
774            let mut sort_keys: Vec<_> = result
775                .iter()
776                .enumerate()
777                .map(|(index, p)| {
778                    let area = p.exterior_unsigned_area_2d();
779                    let b = bounding_rect_3d(&p.exterior).unwrap_or(geo::Rect::new(
780                        geo::Coord { x: 0.0, y: 0.0 },
781                        geo::Coord { x: 0.0, y: 0.0 },
782                    ));
783                    (index, area, b, p.interiors.len())
784                })
785                .collect();
786
787            sort_keys.sort_unstable_by(|(_, area1, b1, holes1), (_, area2, b2, holes2)| {
788                area2.total_cmp(area1).then_with(|| {
789                    b1.min()
790                        .x
791                        .total_cmp(&b2.min().x)
792                        .then(b1.min().y.total_cmp(&b2.min().y))
793                        .then(holes1.cmp(holes2))
794                })
795            });
796
797            reorder_polygons(
798                &mut result,
799                sort_keys.into_iter().map(|(index, _, _, _)| index),
800            );
801        } else {
802            let mut sort_keys: Vec<_> = result
803                .iter()
804                .enumerate()
805                .map(|(index, p)| {
806                    let area = p.exterior_unsigned_area_2d();
807                    (index, area)
808                })
809                .collect();
810
811            sort_keys.sort_unstable_by(|(_, area1), (_, area2)| area2.total_cmp(area1));
812
813            reorder_polygons(&mut result, sort_keys.into_iter().map(|(index, _)| index));
814        }
815
816        if options.determinism.canonical_ring_rotation {
817            for d in dangles.iter_mut() {
818                canonicalize_open_line(d);
819            }
820            for edge in cut_edges.iter_mut() {
821                canonicalize_open_line(edge);
822            }
823            for ir in invalid_rings.iter_mut() {
824                canonicalize_ring(ir, None);
825            }
826        }
827
828        if use_stable_tie_breaks {
829            // Bolt optimization: Schwartzian Transform for dangles.
830            // Caches the bounding boxes of the open lines to avoid redundant
831            // O(N) evaluations of `bounding_rect_3d` during the O(K log K) sort closure.
832            let mut dangles_with_cache: Vec<_> = dangles
833                .iter_mut()
834                .map(|l| {
835                    let b = bounding_rect_3d(l).unwrap_or(geo::Rect::new(
836                        geo::Coord { x: 0.0, y: 0.0 },
837                        geo::Coord { x: 0.0, y: 0.0 },
838                    ));
839                    (std::mem::take(l), b)
840                })
841                .collect();
842
843            dangles_with_cache.sort_unstable_by(|(l1, b1), (l2, b2)| {
844                b1.min()
845                    .x
846                    .total_cmp(&b2.min().x)
847                    .then(b1.min().y.total_cmp(&b2.min().y))
848                    .then(l1.len().cmp(&l2.len()))
849            });
850
851            for (i, (l, _)) in dangles_with_cache.into_iter().enumerate() {
852                dangles[i] = l;
853            }
854            sort_open_lines(cut_edges);
855        }
856
857        let mut combined_invalid: Vec<_> = invalid_rings
858            .drain(..)
859            .map(|r| {
860                let area = Polygon3D::ring_signed_area_2d(&r).abs();
861                (r, area)
862            })
863            .collect();
864
865        if use_stable_tie_breaks {
866            let mut invalid_with_bbox: Vec<_> = combined_invalid
867                .into_iter()
868                .map(|(r, area)| {
869                    let b = bounding_rect_3d(&r).unwrap_or(geo::Rect::new(
870                        geo::Coord { x: 0.0, y: 0.0 },
871                        geo::Coord { x: 0.0, y: 0.0 },
872                    ));
873                    (r, area, b)
874                })
875                .collect();
876
877            invalid_with_bbox.sort_unstable_by(|(r1, area1, b1), (r2, area2, b2)| {
878                area2.total_cmp(area1).then_with(|| {
879                    b1.min()
880                        .x
881                        .total_cmp(&b2.min().x)
882                        .then(b1.min().y.total_cmp(&b2.min().y))
883                        .then(r1.len().cmp(&r2.len()))
884                })
885            });
886            invalid_rings.extend(invalid_with_bbox.into_iter().map(|(r, _, _)| r));
887        } else {
888            combined_invalid.sort_unstable_by(|(_, area1), (_, area2)| area2.total_cmp(area1));
889            invalid_rings.extend(combined_invalid.into_iter().map(|(r, _)| r));
890        }
891    }
892    result
893}
894
895fn reorder_polygons(result: &mut [Polygon3D], old_indices: impl Iterator<Item = usize>) {
896    let mut destinations = vec![0; result.len()];
897    for (new_index, old_index) in old_indices.enumerate() {
898        destinations[old_index] = new_index;
899    }
900    for index in 0..destinations.len() {
901        while destinations[index] != index {
902            let destination = destinations[index];
903            result.swap(index, destination);
904            destinations.swap(index, destination);
905        }
906    }
907}
908
909fn sort_open_lines(lines: &mut [Vec<Coord3D>]) {
910    let mut with_cache: Vec<_> = lines
911        .iter_mut()
912        .map(|l| {
913            let b = bounding_rect_3d(l).unwrap_or(geo::Rect::new(
914                geo::Coord { x: 0.0, y: 0.0 },
915                geo::Coord { x: 0.0, y: 0.0 },
916            ));
917            (std::mem::take(l), b)
918        })
919        .collect();
920
921    with_cache.sort_unstable_by(|(l1, b1), (l2, b2)| {
922        b1.min()
923            .x
924            .total_cmp(&b2.min().x)
925            .then(b1.min().y.total_cmp(&b2.min().y))
926            .then(l1.len().cmp(&l2.len()))
927    });
928
929    for (i, (line, _)) in with_cache.into_iter().enumerate() {
930        lines[i] = line;
931    }
932}
933
934pub(crate) fn process_invalid_rings(
935    rings: Vec<Polygon3D>,
936    valid_shells_2d: &[Polygon<f64>],
937) -> Vec<Vec<Coord3D>> {
938    let mut processable = Vec::new();
939    let mut others = Vec::new();
940
941    for ring in rings {
942        if ring
943            .exterior
944            .iter()
945            .all(|c| c.x.is_finite() && c.y.is_finite())
946        {
947            processable.push(ring);
948        } else {
949            others.push(ring);
950        }
951    }
952
953    // Sort by 2D bbox area in ascending order using Schwartzian Transform
954    let mut processable_with_areas: Vec<_> = processable
955        .into_iter()
956        .map(|ring| {
957            let area = if ring.exterior.is_empty() {
958                0.0
959            } else {
960                let mut min_x = ring.exterior[0].x;
961                let mut max_x = ring.exterior[0].x;
962                let mut min_y = ring.exterior[0].y;
963                let mut max_y = ring.exterior[0].y;
964                for c in &ring.exterior[1..] {
965                    if c.x < min_x {
966                        min_x = c.x;
967                    }
968                    if c.x > max_x {
969                        max_x = c.x;
970                    }
971                    if c.y < min_y {
972                        min_y = c.y;
973                    }
974                    if c.y > max_y {
975                        max_y = c.y;
976                    }
977                }
978                (max_x - min_x) * (max_y - min_y)
979            };
980            (ring, area)
981        })
982        .collect();
983
984    processable_with_areas.sort_unstable_by(|(_, area_a), (_, area_b)| area_a.total_cmp(area_b));
985
986    let processable: Vec<_> = processable_with_areas.into_iter().map(|(r, _)| r).collect();
987
988    struct RingPair {
989        p3d: Polygon3D,
990        p2d: Polygon<f64>,
991    }
992
993    let mut accepted: Vec<RingPair> = Vec::new();
994
995    for ring in processable {
996        let p2d = ring.to_polygon_2d();
997        // Outer invalid rings are discarded if their linework is entirely contained
998        // by an already-processed (smaller) invalid ring or a valid ring.
999        let contains_invalid = accepted.iter().any(|existing| p2d.contains(&existing.p2d));
1000        let contains_valid = valid_shells_2d.iter().any(|valid| p2d.contains(valid));
1001
1002        if !contains_invalid && !contains_valid {
1003            accepted.push(RingPair { p3d: ring, p2d });
1004        }
1005    }
1006
1007    let mut result: Vec<Vec<Coord3D>> = accepted.into_iter().map(|rp| rp.p3d.exterior).collect();
1008    result.extend(others.into_iter().map(|p| p.exterior));
1009
1010    result
1011}
1012
1013pub fn bounding_rect_3d(coords: &[Coord3D]) -> Option<geo::Rect<f64>> {
1014    if coords.is_empty() {
1015        return None;
1016    }
1017    let mut min_x = coords[0].x;
1018    let mut max_x = coords[0].x;
1019    let mut min_y = coords[0].y;
1020    let mut max_y = coords[0].y;
1021    for c in &coords[1..] {
1022        if c.x < min_x {
1023            min_x = c.x;
1024        }
1025        if c.x > max_x {
1026            max_x = c.x;
1027        }
1028        if c.y < min_y {
1029            min_y = c.y;
1030        }
1031        if c.y > max_y {
1032            max_y = c.y;
1033        }
1034    }
1035    Some(geo::Rect::new(
1036        geo::Coord { x: min_x, y: min_y },
1037        geo::Coord { x: max_x, y: max_y },
1038    ))
1039}
1040
1041pub fn guaranteed_interior_probe(coords: &[Coord3D]) -> Option<geo_types::Point<f64>> {
1042    if coords.len() < 4 {
1043        return None;
1044    }
1045
1046    let unique_n = coords.len().saturating_sub(1);
1047    if unique_n < 3 {
1048        return None;
1049    }
1050
1051    let area = Polygon3D::ring_signed_area_2d(coords);
1052    if !area.is_finite() || area.abs() < 1e-12 {
1053        return None;
1054    }
1055
1056    let locator = SimdRing::new_3d(coords);
1057    let bbox = bounding_rect_3d(coords);
1058    let diagonal = bbox
1059        .map(|bbox| {
1060            let dx = bbox.max().x - bbox.min().x;
1061            let dy = bbox.max().y - bbox.min().y;
1062            (dx * dx + dy * dy).sqrt()
1063        })
1064        .unwrap_or(1.0);
1065    guaranteed_interior_probe_prepared(coords, area, diagonal, &locator)
1066}
1067
1068pub(crate) fn guaranteed_interior_probe_prepared(
1069    coords: &[Coord3D],
1070    signed_area: f64,
1071    diagonal: f64,
1072    locator: &SimdRing,
1073) -> Option<geo_types::Point<f64>> {
1074    if coords.len() < 4 || !signed_area.is_finite() || signed_area.abs() < 1e-12 {
1075        return None;
1076    }
1077
1078    let unique_n = coords.len().saturating_sub(1);
1079    if unique_n < 3 {
1080        return None;
1081    }
1082
1083    let eps = (diagonal * 1e-9).max(1e-10);
1084
1085    for i in 0..unique_n {
1086        let prev = coords[(i + unique_n - 1) % unique_n];
1087        let curr = coords[i];
1088        let next = coords[(i + 1) % unique_n];
1089
1090        let in_edge = Coord {
1091            x: curr.x - prev.x,
1092            y: curr.y - prev.y,
1093        };
1094        let out_edge = Coord {
1095            x: next.x - curr.x,
1096            y: next.y - curr.y,
1097        };
1098
1099        let in_len = (in_edge.x * in_edge.x + in_edge.y * in_edge.y).sqrt();
1100        let out_len = (out_edge.x * out_edge.x + out_edge.y * out_edge.y).sqrt();
1101        if in_len < 1e-12 || out_len < 1e-12 {
1102            continue;
1103        }
1104
1105        let turn = in_edge.x * out_edge.y - in_edge.y * out_edge.x;
1106        let convex = if signed_area > 0.0 {
1107            turn > 1e-12
1108        } else {
1109            turn < -1e-12
1110        };
1111        if !convex {
1112            continue;
1113        }
1114
1115        let to_prev = Coord {
1116            x: (prev.x - curr.x) / in_len,
1117            y: (prev.y - curr.y) / in_len,
1118        };
1119        let to_next = Coord {
1120            x: (next.x - curr.x) / out_len,
1121            y: (next.y - curr.y) / out_len,
1122        };
1123
1124        let bisector = Coord {
1125            x: to_prev.x + to_next.x,
1126            y: to_prev.y + to_next.y,
1127        };
1128        let bisector_len = (bisector.x * bisector.x + bisector.y * bisector.y).sqrt();
1129        if bisector_len < 1e-12 {
1130            continue;
1131        }
1132
1133        let bisector_unit = Coord {
1134            x: bisector.x / bisector_len,
1135            y: bisector.y / bisector_len,
1136        };
1137
1138        for sign in [1.0, -1.0] {
1139            let candidate = Coord {
1140                x: curr.x + sign * bisector_unit.x * eps,
1141                y: curr.y + sign * bisector_unit.y * eps,
1142            };
1143            if locator.contains(candidate) {
1144                return Some(geo_types::Point(candidate));
1145            }
1146        }
1147    }
1148
1149    Some(geo_types::Point(coords[0].to_coord_2d()))
1150}
1151
1152pub fn rings_share_edge(shell: &[Coord3D], hole: &[Coord3D], eps: f64) -> bool {
1153    rings_share_edge_with_count(shell, hole, eps).0
1154}
1155
1156pub(crate) fn rings_share_edge_with_count(
1157    shell: &[Coord3D],
1158    hole: &[Coord3D],
1159    eps: f64,
1160) -> (bool, usize) {
1161    if shell.len() < 2 || hole.len() < 2 {
1162        return (false, 0);
1163    }
1164
1165    let mut pair_checks = 0;
1166    // Bolt optimization: using .windows(2) is faster than loop indexing
1167    // because it eliminates O(N*M) array bounds checks in this hot inner loop.
1168    for shell_edge in shell.windows(2) {
1169        let a1 = shell_edge[0].to_coord_2d();
1170        let a2 = shell_edge[1].to_coord_2d();
1171        for hole_edge in hole.windows(2) {
1172            pair_checks += 1;
1173            let b1 = hole_edge[0].to_coord_2d();
1174            let b2 = hole_edge[1].to_coord_2d();
1175            if segments_overlap_with_length(a1, a2, b1, b2, eps) {
1176                return (true, pair_checks);
1177            }
1178        }
1179    }
1180
1181    (false, pair_checks)
1182}
1183
1184pub fn rings_touch_at_vertex(shell: &[Coord3D], hole: &[Coord3D], eps: f64) -> bool {
1185    rings_touch_at_vertex_with_count(shell, hole, eps).0
1186}
1187
1188pub(crate) fn rings_touch_at_vertex_with_count(
1189    shell: &[Coord3D],
1190    hole: &[Coord3D],
1191    eps: f64,
1192) -> (bool, usize) {
1193    let eps_sq = eps * eps;
1194    let mut pair_checks = 0;
1195    for s_pt in shell {
1196        for h_pt in hole {
1197            pair_checks += 1;
1198            let dx = s_pt.x - h_pt.x;
1199            let dy = s_pt.y - h_pt.y;
1200            if dx * dx + dy * dy <= eps_sq {
1201                return (true, pair_checks);
1202            }
1203        }
1204    }
1205    (false, pair_checks)
1206}
1207
1208fn segments_overlap_with_length(
1209    a1: Coord<f64>,
1210    a2: Coord<f64>,
1211    b1: Coord<f64>,
1212    b2: Coord<f64>,
1213    eps: f64,
1214) -> bool {
1215    let ax = a2.x - a1.x;
1216    let ay = a2.y - a1.y;
1217    let a_len_sq = ax * ax + ay * ay;
1218    if a_len_sq <= eps * eps {
1219        return false;
1220    }
1221
1222    // Collinearity checks for segment B endpoints against segment A line
1223    // using squared comparisons to avoid costly `.sqrt()`
1224    let cross_b1 = ax * (b1.y - a1.y) - ay * (b1.x - a1.x);
1225    let cross_b2 = ax * (b2.y - a1.y) - ay * (b2.x - a1.x);
1226    let tol_sq = eps * eps * a_len_sq;
1227    if cross_b1 * cross_b1 > tol_sq || cross_b2 * cross_b2 > tol_sq {
1228        return false;
1229    }
1230
1231    let t1 = ((b1.x - a1.x) * ax + (b1.y - a1.y) * ay) / a_len_sq;
1232    let t2 = ((b2.x - a1.x) * ax + (b2.y - a1.y) * ay) / a_len_sq;
1233
1234    let min_t = t1.min(t2);
1235    let max_t = t1.max(t2);
1236    let overlap_start = 0.0_f64.max(min_t);
1237    let overlap_end = 1.0_f64.min(max_t);
1238
1239    if overlap_end <= overlap_start {
1240        return false;
1241    }
1242
1243    let overlap_len = overlap_end - overlap_start;
1244    overlap_len * overlap_len * a_len_sq > eps * eps
1245}
1246
1247fn extract_segments(geom: &Geometry<f64>, out: &mut Vec<Line3D>) {
1248    let mut stack = smallvec::SmallVec::<[&Geometry<f64>; 16]>::new();
1249    stack.push(geom);
1250    while let Some(current) = stack.pop() {
1251        match current {
1252            Geometry::LineString(ls) => {
1253                let len = ls.0.len().saturating_sub(1);
1254                out.reserve(len);
1255                out.extend(ls.lines().map(Line3D::from));
1256            }
1257            Geometry::MultiLineString(mls) => {
1258                for ls in &mls.0 {
1259                    let len = ls.0.len().saturating_sub(1);
1260                    out.reserve(len);
1261                    out.extend(ls.lines().map(Line3D::from));
1262                }
1263            }
1264            Geometry::Polygon(poly) => {
1265                let ext = poly.exterior();
1266                let len = ext.0.len().saturating_sub(1);
1267                out.reserve(len);
1268                out.extend(ext.lines().map(Line3D::from));
1269                for interior in poly.interiors() {
1270                    let len = interior.0.len().saturating_sub(1);
1271                    out.reserve(len);
1272                    out.extend(interior.lines().map(Line3D::from));
1273                }
1274            }
1275            Geometry::MultiPolygon(mpoly) => {
1276                for poly in mpoly {
1277                    let ext = poly.exterior();
1278                    let len = ext.0.len().saturating_sub(1);
1279                    out.reserve(len);
1280                    out.extend(ext.lines().map(Line3D::from));
1281                    for interior in poly.interiors() {
1282                        let len = interior.0.len().saturating_sub(1);
1283                        out.reserve(len);
1284                        out.extend(interior.lines().map(Line3D::from));
1285                    }
1286                }
1287            }
1288            Geometry::GeometryCollection(gc) => {
1289                stack.extend(gc.0.iter().rev());
1290            }
1291            _ => {}
1292        }
1293    }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299    use geo::LineString;
1300
1301    #[test]
1302    fn test_with_snap_grid() {
1303        let polygonizer = Polygonizer::new().with_snap_grid(0.123);
1304        assert_eq!(polygonizer.snap_grid_size, 0.123);
1305    }
1306
1307    #[test]
1308    fn geos_compat_preserves_source_coordinates() {
1309        let points = [
1310            Coord3D::new(0.04, 0.04, 0.0),
1311            Coord3D::new(10.04, 0.04, 0.0),
1312            Coord3D::new(10.04, 10.04, 0.0),
1313            Coord3D::new(0.04, 10.04, 0.0),
1314        ];
1315        let lines = (0..4)
1316            .map(|i| Line3D::new(points[i], points[(i + 1) % 4], i as u32))
1317            .collect::<Vec<_>>();
1318        let mut options = PolygonizerOptions::default();
1319        options.node_input = true;
1320        options.snap_grid_size = 0.1;
1321        options.snap_strategy = SnapStrategy::GeosCompat;
1322
1323        let result = polygonize_with_options(&lines, &options).unwrap();
1324
1325        assert_eq!(result.polygons.len(), 1);
1326        assert!(points.iter().all(|point| result.polygons[0]
1327            .exterior
1328            .iter()
1329            .any(|actual| actual == point)));
1330    }
1331
1332    #[test]
1333    fn geos_compat_drops_sub_grid_face_from_near_coincident_endpoints() {
1334        let lines = vec![
1335            Line3D::new(
1336                Coord3D::new(139131.39157939597, 332081.47584308265, 0.0),
1337                Coord3D::new(139138.25173987588, 332179.7525957378, 0.0),
1338                1,
1339            ),
1340            Line3D::new(
1341                Coord3D::new(139128.6709763695, 332081.6202812562, 0.0),
1342                Coord3D::new(139131.39157939597, 332081.4758430829, 0.0),
1343                2,
1344            ),
1345            Line3D::new(
1346                Coord3D::new(139131.39157939597, 332081.4758430829, 0.0),
1347                Coord3D::new(139134.1124077957, 332081.33571445255, 0.0),
1348                3,
1349            ),
1350        ];
1351
1352        let result = polygonize_with_options(&lines, &PolygonizerOptions::cfb_robust_v1()).unwrap();
1353
1354        assert!(result.polygons.is_empty());
1355    }
1356
1357    #[test]
1358    fn test_add_lines() {
1359        let mut polygonizer = Polygonizer::new();
1360        assert!(!polygonizer.dirty);
1361        assert!(polygonizer.input_lines.is_empty());
1362
1363        let l1 = Line3D::new(Coord3D::new(0.0, 0.0, 0.0), Coord3D::new(1.0, 0.0, 0.0), 0);
1364        let l2 = Line3D::new(Coord3D::new(1.0, 0.0, 0.0), Coord3D::new(1.0, 1.0, 0.0), 1);
1365
1366        polygonizer.add_lines(vec![l1, l2]);
1367
1368        assert!(polygonizer.dirty);
1369        assert_eq!(polygonizer.input_lines.len(), 2);
1370        assert_eq!(polygonizer.input_lines[0].start.x, 0.0);
1371        assert_eq!(polygonizer.input_lines[1].end.y, 1.0);
1372    }
1373
1374    #[test]
1375    fn test_bounding_rect_3d() {
1376        // 1. Empty slice
1377        assert_eq!(bounding_rect_3d(&[]), None);
1378
1379        // 2. Single coordinate
1380        let single = vec![Coord3D::new(5.0, 10.0, 15.0)];
1381        let rect1 = bounding_rect_3d(&single).unwrap();
1382        assert_eq!(rect1.min().x, 5.0);
1383        assert_eq!(rect1.max().x, 5.0);
1384        assert_eq!(rect1.min().y, 10.0);
1385        assert_eq!(rect1.max().y, 10.0);
1386
1387        // 3. Multiple coordinates (Z should be ignored)
1388        let coords = vec![
1389            Coord3D::new(1.0, 2.0, 100.0),
1390            Coord3D::new(-5.0, 8.0, -50.0),
1391            Coord3D::new(10.0, -3.0, 0.0),
1392            Coord3D::new(4.0, 12.0, 20.0),
1393        ];
1394        let rect2 = bounding_rect_3d(&coords).unwrap();
1395        assert_eq!(rect2.min().x, -5.0);
1396        assert_eq!(rect2.max().x, 10.0);
1397        assert_eq!(rect2.min().y, -3.0);
1398        assert_eq!(rect2.max().y, 12.0);
1399
1400        // 4. Multiple coordinates with same X/Y
1401        let coords2 = vec![
1402            Coord3D::new(0.0, 0.0, 0.0),
1403            Coord3D::new(0.0, 0.0, 10.0),
1404            Coord3D::new(0.0, 0.0, -10.0),
1405        ];
1406        let rect3 = bounding_rect_3d(&coords2).unwrap();
1407        assert_eq!(rect3.min().x, 0.0);
1408        assert_eq!(rect3.max().x, 0.0);
1409        assert_eq!(rect3.min().y, 0.0);
1410        assert_eq!(rect3.max().y, 0.0);
1411    }
1412
1413    #[test]
1414    fn test_rings_share_edge() {
1415        let shell = vec![
1416            Coord3D::new(0.0, 0.0, 0.0),
1417            Coord3D::new(10.0, 0.0, 0.0),
1418            Coord3D::new(10.0, 10.0, 0.0),
1419            Coord3D::new(0.0, 10.0, 0.0),
1420            Coord3D::new(0.0, 0.0, 0.0),
1421        ];
1422
1423        // 1. Identical edge (opposite direction)
1424        let hole1 = vec![
1425            Coord3D::new(2.0, 0.0, 0.0),
1426            Coord3D::new(1.0, 0.0, 0.0),
1427            Coord3D::new(1.0, 1.0, 0.0),
1428            Coord3D::new(2.0, 1.0, 0.0),
1429            Coord3D::new(2.0, 0.0, 0.0),
1430        ];
1431        // shell has (0,0)->(10,0). hole1 has (2,0)->(1,0) which is on the same line.
1432        assert!(rings_share_edge(&shell, &hole1, 1e-10));
1433
1434        // 2. Partial overlap
1435        let hole2 = vec![
1436            Coord3D::new(-1.0, 0.0, 0.0),
1437            Coord3D::new(1.0, 0.0, 0.0),
1438            Coord3D::new(1.0, -1.0, 0.0),
1439            Coord3D::new(-1.0, -1.0, 0.0),
1440            Coord3D::new(-1.0, 0.0, 0.0),
1441        ];
1442        // shell (0,0)->(10,0). hole2 (-1,0)->(1,0). Overlap is (0,0)->(1,0).
1443        assert!(rings_share_edge(&shell, &hole2, 1e-10));
1444
1445        // 3. Touching at vertex only
1446        let hole3 = vec![
1447            Coord3D::new(0.0, 0.0, 0.0),
1448            Coord3D::new(-1.0, -1.0, 0.0),
1449            Coord3D::new(0.0, -1.0, 0.0),
1450            Coord3D::new(0.0, 0.0, 0.0),
1451        ];
1452        assert!(!rings_share_edge(&shell, &hole3, 1e-10));
1453
1454        // 4. Disjoint but collinear
1455        let hole4 = vec![
1456            Coord3D::new(11.0, 0.0, 0.0),
1457            Coord3D::new(12.0, 0.0, 0.0),
1458            Coord3D::new(12.0, 1.0, 0.0),
1459            Coord3D::new(11.0, 1.0, 0.0),
1460            Coord3D::new(11.0, 0.0, 0.0),
1461        ];
1462        assert!(!rings_share_edge(&shell, &hole4, 1e-10));
1463
1464        // 5. Parallel but not collinear
1465        let hole5 = vec![
1466            Coord3D::new(0.0, -1.0, 0.0),
1467            Coord3D::new(10.0, -1.0, 0.0),
1468            Coord3D::new(10.0, -2.0, 0.0),
1469            Coord3D::new(0.0, -2.0, 0.0),
1470            Coord3D::new(0.0, -1.0, 0.0),
1471        ];
1472        assert!(!rings_share_edge(&shell, &hole5, 1e-10));
1473
1474        // 6. Shared edge with epsilon (within distance)
1475        let hole6 = vec![
1476            Coord3D::new(0.0, 1e-11, 0.0),
1477            Coord3D::new(10.0, 1e-11, 0.0),
1478            Coord3D::new(10.0, 1.0, 0.0),
1479            Coord3D::new(0.0, 1.0, 0.0),
1480            Coord3D::new(0.0, 1e-11, 0.0),
1481        ];
1482        assert!(rings_share_edge(&shell, &hole6, 1e-10));
1483
1484        // 7. Shared edge with epsilon (just outside distance)
1485        let hole7 = vec![
1486            Coord3D::new(1.0, 1e-9, 0.0),
1487            Coord3D::new(9.0, 1e-9, 0.0),
1488            Coord3D::new(9.0, 1.0, 0.0),
1489            Coord3D::new(1.0, 1.0, 0.0),
1490            Coord3D::new(1.0, 1e-9, 0.0),
1491        ];
1492        assert!(!rings_share_edge(&shell, &hole7, 1e-10));
1493
1494        // 8. Short segment bug case
1495        let shell_short = vec![
1496            Coord3D::new(0.0, 0.0, 0.0),
1497            Coord3D::new(0.01, 0.0, 0.0),
1498            Coord3D::new(0.01, 0.01, 0.0),
1499            Coord3D::new(0.0, 0.01, 0.0),
1500            Coord3D::new(0.0, 0.0, 0.0),
1501        ];
1502        let hole_short = vec![
1503            Coord3D::new(0.0, 0.0, 0.0),
1504            Coord3D::new(0.01, 0.0, 0.0),
1505            Coord3D::new(0.01, -0.01, 0.0),
1506            Coord3D::new(0.0, -0.01, 0.0),
1507            Coord3D::new(0.0, 0.0, 0.0),
1508        ];
1509        // Overlap is (0,0) -> (0.01, 0), length 0.01.
1510        // If eps is 0.1, it should NOT share edge.
1511        assert!(!rings_share_edge(&shell_short, &hole_short, 0.1));
1512        // If eps is 0.001, it SHOULD share edge.
1513        assert!(rings_share_edge(&shell_short, &hole_short, 0.001));
1514    }
1515
1516    #[test]
1517    fn test_guaranteed_interior_probe() {
1518        // 1. Less than 4 points
1519        let coords1 = vec![
1520            Coord3D::new(0.0, 0.0, 0.0),
1521            Coord3D::new(1.0, 0.0, 0.0),
1522            Coord3D::new(0.0, 1.0, 0.0),
1523        ];
1524        assert_eq!(guaranteed_interior_probe(&coords1), None);
1525
1526        // 2. Less than 3 unique points
1527        let coords2 = vec![
1528            Coord3D::new(0.0, 0.0, 0.0),
1529            Coord3D::new(1.0, 0.0, 0.0),
1530            Coord3D::new(1.0, 0.0, 0.0),
1531            Coord3D::new(0.0, 0.0, 0.0),
1532        ];
1533        assert_eq!(guaranteed_interior_probe(&coords2), None);
1534
1535        // 3. Zero area / collinear
1536        let coords3 = vec![
1537            Coord3D::new(0.0, 0.0, 0.0),
1538            Coord3D::new(1.0, 0.0, 0.0),
1539            Coord3D::new(2.0, 0.0, 0.0),
1540            Coord3D::new(0.0, 0.0, 0.0),
1541        ];
1542        assert_eq!(guaranteed_interior_probe(&coords3), None);
1543
1544        // 4. Simple convex polygon
1545        let coords4 = vec![
1546            Coord3D::new(0.0, 0.0, 0.0),
1547            Coord3D::new(10.0, 0.0, 0.0),
1548            Coord3D::new(10.0, 10.0, 0.0),
1549            Coord3D::new(0.0, 10.0, 0.0),
1550            Coord3D::new(0.0, 0.0, 0.0),
1551        ];
1552        let probe4 = guaranteed_interior_probe(&coords4).expect("Should find a point");
1553        let coords4_2d: Vec<_> = coords4.iter().map(|c| c.to_coord_2d()).collect();
1554        let p4 = Polygon::new(LineString::from(coords4_2d), vec![]);
1555        assert!(p4.contains(&probe4));
1556
1557        // 5. Highly concave polygon where centroid is outside
1558        // A "U" shape:
1559        // (0, 0) to (10, 0) to (10, 10) to (8, 10) to (8, 2) to (2, 2) to (2, 10) to (0, 10) to (0, 0)
1560        let coords5 = vec![
1561            Coord3D::new(0.0, 0.0, 0.0),
1562            Coord3D::new(10.0, 0.0, 0.0),
1563            Coord3D::new(10.0, 10.0, 0.0),
1564            Coord3D::new(8.0, 10.0, 0.0),
1565            Coord3D::new(8.0, 2.0, 0.0),
1566            Coord3D::new(2.0, 2.0, 0.0),
1567            Coord3D::new(2.0, 10.0, 0.0),
1568            Coord3D::new(0.0, 10.0, 0.0),
1569            Coord3D::new(0.0, 0.0, 0.0),
1570        ];
1571        let probe5 =
1572            guaranteed_interior_probe(&coords5).expect("Should find a point via bisector fallback");
1573        let coords5_2d: Vec<_> = coords5.iter().map(|c| c.to_coord_2d()).collect();
1574        let p5 = Polygon::new(LineString::from(coords5_2d), vec![]);
1575        assert!(p5.contains(&probe5));
1576    }
1577
1578    #[test]
1579    fn test_rings_touch_at_vertex() {
1580        let shell = vec![
1581            Coord3D::new(0.0, 0.0, 0.0),
1582            Coord3D::new(10.0, 0.0, 0.0),
1583            Coord3D::new(10.0, 10.0, 0.0),
1584            Coord3D::new(0.0, 10.0, 0.0),
1585            Coord3D::new(0.0, 0.0, 0.0),
1586        ];
1587
1588        // 1. Exact touch at one vertex (0,0)
1589        let hole1 = vec![
1590            Coord3D::new(0.0, 0.0, 0.0),
1591            Coord3D::new(-1.0, -1.0, 0.0),
1592            Coord3D::new(1.0, -1.0, 0.0),
1593            Coord3D::new(0.0, 0.0, 0.0),
1594        ];
1595        assert!(rings_touch_at_vertex(&shell, &hole1, 1e-10));
1596
1597        // 2. Touch within epsilon of vertex (10,10)
1598        let hole2 = vec![
1599            Coord3D::new(10.0 + 1e-11, 10.0, 0.0),
1600            Coord3D::new(11.0, 11.0, 0.0),
1601            Coord3D::new(11.0, 10.0, 0.0),
1602            Coord3D::new(10.0 + 1e-11, 10.0, 0.0),
1603        ];
1604        assert!(rings_touch_at_vertex(&shell, &hole2, 1e-10));
1605
1606        // 3. No touch (just outside epsilon of vertex 10,10)
1607        let hole3 = vec![
1608            Coord3D::new(10.0 + 1e-9, 10.0, 0.0),
1609            Coord3D::new(11.0, 11.0, 0.0),
1610            Coord3D::new(11.0, 10.0, 0.0),
1611            Coord3D::new(10.0 + 1e-9, 10.0, 0.0),
1612        ];
1613        assert!(!rings_touch_at_vertex(&shell, &hole3, 1e-10));
1614
1615        // 4. Touch at multiple vertices
1616        let hole4 = vec![
1617            Coord3D::new(0.0, 0.0, 0.0),
1618            Coord3D::new(10.0, 0.0, 0.0),
1619            Coord3D::new(5.0, -5.0, 0.0),
1620            Coord3D::new(0.0, 0.0, 0.0),
1621        ];
1622        assert!(rings_touch_at_vertex(&shell, &hole4, 1e-10));
1623
1624        // 5. Disjoint
1625        let hole5 = vec![
1626            Coord3D::new(20.0, 20.0, 0.0),
1627            Coord3D::new(21.0, 20.0, 0.0),
1628            Coord3D::new(21.0, 21.0, 0.0),
1629            Coord3D::new(20.0, 20.0, 0.0),
1630        ];
1631        assert!(!rings_touch_at_vertex(&shell, &hole5, 1e-10));
1632
1633        // 6. Empty rings
1634        assert!(!rings_touch_at_vertex(&[], &hole1, 1e-10));
1635        assert!(!rings_touch_at_vertex(&shell, &[], 1e-10));
1636    }
1637}