Skip to main content

oxigdal_algorithms/raster/polygonize/
boundary.rs

1//! Boundary tracing and ring classification for raster polygonization
2//!
3//! Implements:
4//! - Moore-Neighbor contour tracing (boundary following on a label grid)
5//! - Signed-area ring classification (exterior vs. hole)
6//! - Hole-to-exterior assignment via point-in-polygon tests
7//!
8//! The algorithm operates on a **label grid** produced by connected component
9//! labeling. Each unique non-zero label represents one connected region. The
10//! tracer walks around the boundary of each region, producing closed coordinate
11//! rings in pixel space.
12
13use std::collections::HashMap;
14
15use oxigdal_core::vector::Coordinate;
16
17use crate::error::{AlgorithmError, Result};
18
19// ---------------------------------------------------------------------------
20// Connectivity mode
21// ---------------------------------------------------------------------------
22
23/// Pixel connectivity mode for boundary tracing.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Connectivity {
26    /// 4-connected: only orthogonal neighbors (N, E, S, W)
27    Four,
28    /// 8-connected: orthogonal + diagonal neighbors
29    Eight,
30}
31
32impl Default for Connectivity {
33    fn default() -> Self {
34        Self::Eight
35    }
36}
37
38// ---------------------------------------------------------------------------
39// Direction tables
40// ---------------------------------------------------------------------------
41
42/// 8-direction offsets: (dx, dy) in clockwise order starting from E.
43///
44/// Index mapping:
45///   0=E, 1=SE, 2=S, 3=SW, 4=W, 5=NW, 6=N, 7=NE
46const DIR8_DX: [i32; 8] = [1, 1, 0, -1, -1, -1, 0, 1];
47const DIR8_DY: [i32; 8] = [0, 1, 1, 1, 0, -1, -1, -1];
48
49/// 4-direction offsets: (dx, dy) in clockwise order starting from E.
50///
51/// Index mapping:
52///   0=E, 1=S, 2=W, 3=N
53const DIR4_DX: [i32; 4] = [1, 0, -1, 0];
54const DIR4_DY: [i32; 4] = [0, 1, 0, -1];
55
56// ---------------------------------------------------------------------------
57// Ring types
58// ---------------------------------------------------------------------------
59
60/// A closed ring of pixel-space coordinates extracted from boundary tracing.
61#[derive(Debug, Clone)]
62pub(crate) struct PixelRing {
63    /// The label of the connected component this ring belongs to.
64    pub label: u32,
65    /// Coordinates in pixel space (closed: first == last).
66    pub coords: Vec<(f64, f64)>,
67    /// Signed area (positive = CCW = exterior, negative = CW = hole).
68    pub signed_area: f64,
69}
70
71impl PixelRing {
72    /// Returns `true` if this ring is an exterior ring (CCW, positive signed area).
73    pub fn is_exterior(&self) -> bool {
74        self.signed_area >= 0.0
75    }
76}
77
78/// A classified polygon: one exterior ring plus zero or more holes.
79#[derive(Debug, Clone)]
80pub(crate) struct ClassifiedPolygon {
81    pub label: u32,
82    pub exterior: Vec<(f64, f64)>,
83    pub holes: Vec<Vec<(f64, f64)>>,
84}
85
86// ---------------------------------------------------------------------------
87// Boundary tracing
88// ---------------------------------------------------------------------------
89
90/// Trace all boundary rings for every non-zero label in the grid.
91///
92/// Uses Moore-Neighbor contour tracing. Each connected component can produce
93/// one exterior ring and zero or more interior (hole) rings.
94///
95/// # Arguments
96/// * `labels` - Flat label grid (row-major, width x height). Label 0 = background.
97/// * `width` - Grid width in pixels.
98/// * `height` - Grid height in pixels.
99/// * `connectivity` - Pixel connectivity mode.
100///
101/// # Returns
102/// A list of classified polygons, one per connected component.
103pub(crate) fn trace_boundaries(
104    labels: &[u32],
105    width: usize,
106    height: usize,
107    connectivity: Connectivity,
108) -> Result<Vec<ClassifiedPolygon>> {
109    if labels.len() != width * height {
110        return Err(AlgorithmError::InvalidInput(
111            "label grid size does not match width*height".to_string(),
112        ));
113    }
114
115    // Collect unique non-zero labels
116    let mut label_set: Vec<u32> = Vec::new();
117    {
118        let mut seen = std::collections::HashSet::new();
119        for &lbl in labels {
120            if lbl != 0 && seen.insert(lbl) {
121                label_set.push(lbl);
122            }
123        }
124    }
125    label_set.sort_unstable();
126
127    // For each label, trace all boundary rings
128    let mut all_rings: Vec<PixelRing> = Vec::new();
129
130    // We use a visited grid per label to avoid re-tracing the same boundary.
131    // To save memory, we reuse a single visited buffer.
132    let mut visited = vec![false; width * height];
133
134    for &target_label in &label_set {
135        // Reset visited
136        for v in visited.iter_mut() {
137            *v = false;
138        }
139
140        // Find all boundary start pixels for this label and trace rings
141        let rings = trace_label_boundaries(
142            labels,
143            width,
144            height,
145            target_label,
146            connectivity,
147            &mut visited,
148        );
149        all_rings.extend(rings);
150    }
151
152    // Classify rings into exteriors and holes, then assign holes to exteriors
153    classify_and_assemble(all_rings)
154}
155
156/// Trace all boundary rings for a single label.
157fn trace_label_boundaries(
158    labels: &[u32],
159    width: usize,
160    height: usize,
161    target: u32,
162    connectivity: Connectivity,
163    visited: &mut [bool],
164) -> Vec<PixelRing> {
165    let mut rings = Vec::new();
166
167    // Scan for boundary pixels of this label.
168    // A boundary pixel is one that belongs to `target` and has at least one
169    // non-target neighbor (including out-of-bounds as non-target).
170    for y in 0..height {
171        for x in 0..width {
172            let idx = y * width + x;
173            if labels[idx] != target || visited[idx] {
174                continue;
175            }
176
177            // Check if this is a boundary pixel
178            if !is_boundary_pixel(labels, width, height, x, y, target) {
179                continue;
180            }
181
182            // Trace the contour starting from this pixel
183            if let Some(ring) =
184                trace_single_ring(labels, width, height, x, y, target, connectivity, visited)
185            {
186                if ring.coords.len() >= 4 {
187                    rings.push(ring);
188                }
189            }
190        }
191    }
192
193    rings
194}
195
196/// Check whether pixel (x, y) is on the boundary of `target`.
197fn is_boundary_pixel(
198    labels: &[u32],
199    width: usize,
200    height: usize,
201    x: usize,
202    y: usize,
203    target: u32,
204) -> bool {
205    // A pixel is on the boundary if any of its 4-connected neighbors is not `target`
206    let neighbors: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
207    for (dx, dy) in neighbors {
208        let nx = x as i32 + dx;
209        let ny = y as i32 + dy;
210        if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
211            return true; // Edge of grid counts as non-target
212        }
213        if labels[ny as usize * width + nx as usize] != target {
214            return true;
215        }
216    }
217    false
218}
219
220/// Trace a single contour ring using Moore-Neighbor tracing.
221///
222/// The algorithm walks around the boundary of a connected region, following
223/// the Moore neighborhood (8 or 4 directions). It produces a closed ring
224/// of pixel-center coordinates.
225fn trace_single_ring(
226    labels: &[u32],
227    width: usize,
228    height: usize,
229    start_x: usize,
230    start_y: usize,
231    target: u32,
232    connectivity: Connectivity,
233    visited: &mut [bool],
234) -> Option<PixelRing> {
235    let num_dirs = match connectivity {
236        Connectivity::Eight => 8usize,
237        Connectivity::Four => 4usize,
238    };
239
240    let dx_table: &[i32] = match connectivity {
241        Connectivity::Eight => &DIR8_DX,
242        Connectivity::Four => &DIR4_DX,
243    };
244    let dy_table: &[i32] = match connectivity {
245        Connectivity::Eight => &DIR8_DY,
246        Connectivity::Four => &DIR4_DY,
247    };
248
249    // Pixel-center coordinates for the ring
250    let mut coords: Vec<(f64, f64)> = Vec::new();
251    coords.push((start_x as f64 + 0.5, start_y as f64 + 0.5));
252    visited[start_y * width + start_x] = true;
253
254    let mut cx = start_x;
255    let mut cy = start_y;
256
257    // Find initial search direction: start by looking for the first non-target
258    // neighbor going clockwise, then the next target neighbor after that.
259    let mut dir = 0usize; // start searching from direction 0 (East)
260
261    // Find the first non-target neighbor to establish entry direction
262    for d in 0..num_dirs {
263        let nx = cx as i32 + dx_table[d];
264        let ny = cy as i32 + dy_table[d];
265        if !in_bounds_and_target(labels, width, height, nx, ny, target) {
266            // The backtrack direction is (d + num_dirs/2) mod num_dirs
267            dir = (d + num_dirs / 2) % num_dirs;
268            break;
269        }
270    }
271
272    // Safety: limit iterations to prevent infinite loops on degenerate grids
273    let max_iter = width * height * 4;
274    let mut iter_count = 0;
275
276    loop {
277        iter_count += 1;
278        if iter_count > max_iter {
279            break;
280        }
281
282        // Search for the next boundary pixel in the Moore neighborhood
283        let mut found = false;
284        let search_start = (dir + 1) % num_dirs;
285
286        for step in 0..num_dirs {
287            let d = (search_start + step) % num_dirs;
288            let nx = cx as i32 + dx_table[d];
289            let ny = cy as i32 + dy_table[d];
290
291            if in_bounds_and_target(labels, width, height, nx, ny, target) {
292                let nxu = nx as usize;
293                let nyu = ny as usize;
294
295                // Check if we've returned to start
296                if nxu == start_x && nyu == start_y && coords.len() > 2 {
297                    // Close the ring
298                    coords.push((start_x as f64 + 0.5, start_y as f64 + 0.5));
299                    let signed_area = compute_signed_area(&coords);
300                    return Some(PixelRing {
301                        label: target,
302                        coords,
303                        signed_area,
304                    });
305                }
306
307                // Move to this neighbor
308                cx = nxu;
309                cy = nyu;
310                visited[cy * width + cx] = true;
311                coords.push((cx as f64 + 0.5, cy as f64 + 0.5));
312
313                // Set backtrack direction: opposite of the direction we came from
314                dir = (d + num_dirs / 2) % num_dirs;
315                found = true;
316                break;
317            }
318        }
319
320        if !found {
321            // Isolated single pixel
322            break;
323        }
324    }
325
326    // If we didn't close the ring via the start pixel, try to close it anyway
327    if coords.len() >= 3 {
328        let first = coords[0];
329        let last = coords[coords.len() - 1];
330        if (first.0 - last.0).abs() > f64::EPSILON || (first.1 - last.1).abs() > f64::EPSILON {
331            coords.push(first);
332        }
333        let signed_area = compute_signed_area(&coords);
334        return Some(PixelRing {
335            label: target,
336            coords,
337            signed_area,
338        });
339    }
340
341    // Single pixel or degenerate: create a unit square ring
342    if coords.len() <= 2 {
343        let px = start_x as f64;
344        let py = start_y as f64;
345        let square = vec![
346            (px, py),
347            (px + 1.0, py),
348            (px + 1.0, py + 1.0),
349            (px, py + 1.0),
350            (px, py),
351        ];
352        let signed_area = compute_signed_area(&square);
353        return Some(PixelRing {
354            label: target,
355            coords: square,
356            signed_area,
357        });
358    }
359
360    None
361}
362
363/// Check if (nx, ny) is within bounds and belongs to `target`.
364#[inline]
365fn in_bounds_and_target(
366    labels: &[u32],
367    width: usize,
368    height: usize,
369    nx: i32,
370    ny: i32,
371    target: u32,
372) -> bool {
373    if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
374        return false;
375    }
376    labels[ny as usize * width + nx as usize] == target
377}
378
379// ---------------------------------------------------------------------------
380// Signed area and ring classification
381// ---------------------------------------------------------------------------
382
383/// Compute the signed area of a closed ring using the shoelace formula.
384///
385/// Positive area = counter-clockwise (exterior ring).
386/// Negative area = clockwise (hole ring).
387pub(crate) fn compute_signed_area(coords: &[(f64, f64)]) -> f64 {
388    if coords.len() < 3 {
389        return 0.0;
390    }
391    let mut area = 0.0_f64;
392    let n = coords.len();
393    for i in 0..n {
394        let j = (i + 1) % n;
395        area += coords[i].0 * coords[j].1;
396        area -= coords[j].0 * coords[i].1;
397    }
398    area * 0.5
399}
400
401/// Classify rings and assemble polygons (exterior + holes).
402///
403/// For each label, the largest (by absolute area) CCW ring is the exterior.
404/// CW rings are holes. Each hole is assigned to the smallest containing
405/// exterior ring of the same label using point-in-polygon tests.
406fn classify_and_assemble(rings: Vec<PixelRing>) -> Result<Vec<ClassifiedPolygon>> {
407    // Group rings by label
408    let mut by_label: HashMap<u32, Vec<PixelRing>> = HashMap::new();
409    for ring in rings {
410        by_label.entry(ring.label).or_default().push(ring);
411    }
412
413    let mut polygons = Vec::new();
414
415    for (label, mut label_rings) in by_label {
416        // Separate exteriors (positive signed area) and holes (negative)
417        let mut exteriors: Vec<Vec<(f64, f64)>> = Vec::new();
418        let mut holes: Vec<Vec<(f64, f64)>> = Vec::new();
419
420        // Sort by absolute area descending so largest exterior comes first
421        label_rings.sort_by(|a, b| {
422            b.signed_area
423                .abs()
424                .partial_cmp(&a.signed_area.abs())
425                .unwrap_or(std::cmp::Ordering::Equal)
426        });
427
428        for ring in &label_rings {
429            if ring.is_exterior() {
430                // Ensure CCW orientation (positive signed area)
431                let mut coords = ring.coords.clone();
432                if compute_signed_area(&coords) < 0.0 {
433                    coords.reverse();
434                }
435                exteriors.push(coords);
436            } else {
437                // Ensure CW orientation (negative signed area) for holes
438                let mut coords = ring.coords.clone();
439                if compute_signed_area(&coords) > 0.0 {
440                    coords.reverse();
441                }
442                holes.push(coords);
443            }
444        }
445
446        // If no exteriors found, treat the largest ring as exterior
447        if exteriors.is_empty() && !holes.is_empty() {
448            let mut largest = holes.remove(0);
449            largest.reverse(); // Flip to CCW
450            exteriors.push(largest);
451        }
452
453        // Simple case: one exterior, assign all holes to it
454        if exteriors.len() == 1 {
455            polygons.push(ClassifiedPolygon {
456                label,
457                exterior: exteriors.into_iter().next().unwrap_or_default(),
458                holes,
459            });
460        } else {
461            // Multiple exteriors: assign each hole to the smallest containing exterior
462            let mut poly_holes: Vec<Vec<Vec<(f64, f64)>>> = vec![Vec::new(); exteriors.len()];
463
464            for hole in holes {
465                // Use a point from the hole to test containment
466                if let Some(test_point) = hole.first() {
467                    let mut best_idx = 0usize;
468                    let mut best_area = f64::MAX;
469                    for (i, ext) in exteriors.iter().enumerate() {
470                        if point_in_ring(test_point.0, test_point.1, ext) {
471                            let area = compute_signed_area(ext).abs();
472                            if area < best_area {
473                                best_area = area;
474                                best_idx = i;
475                            }
476                        }
477                    }
478                    poly_holes[best_idx].push(hole);
479                }
480            }
481
482            for (i, ext) in exteriors.into_iter().enumerate() {
483                let h = if i < poly_holes.len() {
484                    std::mem::take(&mut poly_holes[i])
485                } else {
486                    Vec::new()
487                };
488                polygons.push(ClassifiedPolygon {
489                    label,
490                    exterior: ext,
491                    holes: h,
492                });
493            }
494        }
495    }
496
497    // Sort output by label for deterministic ordering
498    polygons.sort_by_key(|p| p.label);
499
500    Ok(polygons)
501}
502
503/// Point-in-polygon test using ray casting.
504///
505/// Tests whether the point (px, py) is inside the closed ring.
506fn point_in_ring(px: f64, py: f64, ring: &[(f64, f64)]) -> bool {
507    if ring.len() < 3 {
508        return false;
509    }
510    let mut inside = false;
511    let n = ring.len();
512    let mut j = n - 1;
513    for i in 0..n {
514        let (xi, yi) = ring[i];
515        let (xj, yj) = ring[j];
516        if ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
517            inside = !inside;
518        }
519        j = i;
520    }
521    inside
522}
523
524// ---------------------------------------------------------------------------
525// Pixel-edge boundary extraction (alternative approach)
526// ---------------------------------------------------------------------------
527
528/// Extract polygon boundaries as pixel-edge paths.
529///
530/// Instead of tracing pixel centers, this produces boundaries along the edges
531/// between pixels, giving exact rectilinear polygon boundaries. This is the
532/// approach used by GDAL's GDALPolygonize.
533///
534/// For each labeled region, the exterior boundary runs along edges where the
535/// label meets a different label (or the grid boundary). The result is a set
536/// of closed coordinate rings in pixel-corner space.
537pub(crate) fn extract_pixel_edge_boundaries(
538    labels: &[u32],
539    width: usize,
540    height: usize,
541) -> Result<Vec<ClassifiedPolygon>> {
542    if labels.len() != width * height {
543        return Err(AlgorithmError::InvalidInput(
544            "label grid size does not match width*height".to_string(),
545        ));
546    }
547
548    // Collect unique non-zero labels
549    let mut label_set: Vec<u32> = Vec::new();
550    {
551        let mut seen = std::collections::HashSet::new();
552        for &lbl in labels {
553            if lbl != 0 && seen.insert(lbl) {
554                label_set.push(lbl);
555            }
556        }
557    }
558    label_set.sort_unstable();
559
560    let mut all_polygons = Vec::new();
561
562    for &target in &label_set {
563        // Build a binary mask for this label
564        let mask: Vec<bool> = labels.iter().map(|&l| l == target).collect();
565
566        // Extract boundary edges
567        let edges = extract_boundary_edges(&mask, width, height);
568
569        // Assemble edges into closed rings
570        let rings = assemble_edge_rings(&edges);
571
572        // Classify rings
573        let mut exteriors: Vec<Vec<(f64, f64)>> = Vec::new();
574        let mut holes: Vec<Vec<(f64, f64)>> = Vec::new();
575
576        for ring in &rings {
577            let area = compute_signed_area(ring);
578            if area >= 0.0 {
579                exteriors.push(ring.clone());
580            } else {
581                holes.push(ring.clone());
582            }
583        }
584
585        // If no exteriors, promote the largest hole
586        if exteriors.is_empty() && !holes.is_empty() {
587            // Sort by absolute area descending
588            holes.sort_by(|a, b| {
589                compute_signed_area(b)
590                    .abs()
591                    .partial_cmp(&compute_signed_area(a).abs())
592                    .unwrap_or(std::cmp::Ordering::Equal)
593            });
594            let mut largest = holes.remove(0);
595            largest.reverse();
596            exteriors.push(largest);
597        }
598
599        if exteriors.len() == 1 {
600            all_polygons.push(ClassifiedPolygon {
601                label: target,
602                exterior: exteriors.into_iter().next().unwrap_or_default(),
603                holes,
604            });
605        } else {
606            // Multiple exteriors: assign holes
607            let mut poly_holes: Vec<Vec<Vec<(f64, f64)>>> = vec![Vec::new(); exteriors.len()];
608
609            for hole in holes {
610                if let Some(test_point) = hole.first() {
611                    let mut best_idx = 0usize;
612                    let mut best_area = f64::MAX;
613                    for (i, ext) in exteriors.iter().enumerate() {
614                        if point_in_ring(test_point.0, test_point.1, ext) {
615                            let area = compute_signed_area(ext).abs();
616                            if area < best_area {
617                                best_area = area;
618                                best_idx = i;
619                            }
620                        }
621                    }
622                    poly_holes[best_idx].push(hole);
623                }
624            }
625
626            for (i, ext) in exteriors.into_iter().enumerate() {
627                let h = if i < poly_holes.len() {
628                    std::mem::take(&mut poly_holes[i])
629                } else {
630                    Vec::new()
631                };
632                all_polygons.push(ClassifiedPolygon {
633                    label: target,
634                    exterior: ext,
635                    holes: h,
636                });
637            }
638        }
639    }
640
641    all_polygons.sort_by_key(|p| p.label);
642    Ok(all_polygons)
643}
644
645/// An edge segment between two pixel corners.
646#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
647struct EdgeSegment {
648    /// Start corner (col, row) in pixel-corner coordinates
649    x0: i32,
650    y0: i32,
651    /// End corner (col, row)
652    x1: i32,
653    y1: i32,
654}
655
656/// Extract all boundary edge segments for a binary mask.
657///
658/// An edge exists where a `true` pixel meets a `false` pixel (or the grid
659/// boundary). Edge segments run along pixel corners, oriented so the `true`
660/// region is to the left (producing CCW exterior rings).
661fn extract_boundary_edges(mask: &[bool], width: usize, height: usize) -> Vec<EdgeSegment> {
662    let mut edges = Vec::new();
663
664    for y in 0..height {
665        for x in 0..width {
666            if !mask[y * width + x] {
667                continue;
668            }
669
670            // Top edge: between row y and row y-1
671            if y == 0 || !mask[(y - 1) * width + x] {
672                // Edge from (x, y) to (x+1, y), left-to-right means interior is below
673                edges.push(EdgeSegment {
674                    x0: x as i32,
675                    y0: y as i32,
676                    x1: x as i32 + 1,
677                    y1: y as i32,
678                });
679            }
680
681            // Bottom edge: between row y and row y+1
682            if y == height - 1 || !mask[(y + 1) * width + x] {
683                // Edge from (x+1, y+1) to (x, y+1), right-to-left means interior is above
684                edges.push(EdgeSegment {
685                    x0: x as i32 + 1,
686                    y0: y as i32 + 1,
687                    x1: x as i32,
688                    y1: y as i32 + 1,
689                });
690            }
691
692            // Left edge: between col x and col x-1
693            if x == 0 || !mask[y * width + x - 1] {
694                // Edge from (x, y+1) to (x, y), bottom-to-top means interior is right
695                edges.push(EdgeSegment {
696                    x0: x as i32,
697                    y0: y as i32 + 1,
698                    x1: x as i32,
699                    y1: y as i32,
700                });
701            }
702
703            // Right edge: between col x and col x+1
704            if x == width - 1 || !mask[y * width + x + 1] {
705                // Edge from (x+1, y) to (x+1, y+1), top-to-bottom means interior is left
706                edges.push(EdgeSegment {
707                    x0: x as i32 + 1,
708                    y0: y as i32,
709                    x1: x as i32 + 1,
710                    y1: y as i32 + 1,
711                });
712            }
713        }
714    }
715
716    edges
717}
718
719/// Assemble edge segments into closed rings by chaining endpoint matches.
720fn assemble_edge_rings(edges: &[EdgeSegment]) -> Vec<Vec<(f64, f64)>> {
721    if edges.is_empty() {
722        return Vec::new();
723    }
724
725    // Build adjacency: start_point -> list of (edge_index)
726    let mut adjacency: HashMap<(i32, i32), Vec<usize>> = HashMap::with_capacity(edges.len());
727    for (i, edge) in edges.iter().enumerate() {
728        adjacency.entry((edge.x0, edge.y0)).or_default().push(i);
729    }
730
731    let mut used = vec![false; edges.len()];
732    let mut rings: Vec<Vec<(f64, f64)>> = Vec::new();
733
734    for start_idx in 0..edges.len() {
735        if used[start_idx] {
736            continue;
737        }
738
739        let mut ring = Vec::new();
740        let mut current_idx = start_idx;
741
742        loop {
743            if used[current_idx] {
744                break;
745            }
746            used[current_idx] = true;
747
748            let edge = &edges[current_idx];
749            ring.push((edge.x0 as f64, edge.y0 as f64));
750
751            // Find next edge that starts where this one ends
752            let end_point = (edge.x1, edge.y1);
753            let mut found_next = false;
754
755            if let Some(candidates) = adjacency.get(&end_point) {
756                for &cand_idx in candidates {
757                    if !used[cand_idx] {
758                        current_idx = cand_idx;
759                        found_next = true;
760                        break;
761                    }
762                }
763            }
764
765            if !found_next {
766                break;
767            }
768        }
769
770        // Close the ring
771        if ring.len() >= 3 {
772            let first = ring[0];
773            ring.push(first);
774            rings.push(ring);
775        }
776    }
777
778    rings
779}
780
781// ---------------------------------------------------------------------------
782// Coordinate conversion
783// ---------------------------------------------------------------------------
784
785/// Convert pixel-space coordinates to world coordinates using a GeoTransform.
786pub(crate) fn transform_coords(
787    coords: &[(f64, f64)],
788    gt: &oxigdal_core::types::GeoTransform,
789) -> Vec<Coordinate> {
790    coords
791        .iter()
792        .map(|&(px, py)| {
793            let (wx, wy) = gt.pixel_to_world(px, py);
794            Coordinate::new_2d(wx, wy)
795        })
796        .collect()
797}
798
799/// Convert pixel-space coordinates to Coordinate without transformation.
800pub(crate) fn pixel_coords_to_coordinates(coords: &[(f64, f64)]) -> Vec<Coordinate> {
801    coords
802        .iter()
803        .map(|&(px, py)| Coordinate::new_2d(px, py))
804        .collect()
805}
806
807// ---------------------------------------------------------------------------
808// Tests
809// ---------------------------------------------------------------------------
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn test_signed_area_ccw_square() {
817        // CCW square: (0,0) -> (1,0) -> (1,1) -> (0,1) -> (0,0)
818        let coords = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)];
819        let area = compute_signed_area(&coords);
820        assert!(
821            area > 0.0,
822            "CCW square should have positive area, got {area}"
823        );
824        assert!((area - 1.0).abs() < 1e-10);
825    }
826
827    #[test]
828    fn test_signed_area_cw_square() {
829        // CW square: (0,0) -> (0,1) -> (1,1) -> (1,0) -> (0,0)
830        let coords = vec![(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)];
831        let area = compute_signed_area(&coords);
832        assert!(
833            area < 0.0,
834            "CW square should have negative area, got {area}"
835        );
836        assert!((area + 1.0).abs() < 1e-10);
837    }
838
839    #[test]
840    fn test_signed_area_degenerate() {
841        let coords = vec![(0.0, 0.0), (1.0, 0.0)];
842        assert!((compute_signed_area(&coords)).abs() < 1e-10);
843    }
844
845    #[test]
846    fn test_signed_area_empty() {
847        assert!((compute_signed_area(&[])).abs() < 1e-10);
848    }
849
850    #[test]
851    fn test_point_in_ring_inside() {
852        let ring = vec![
853            (0.0, 0.0),
854            (10.0, 0.0),
855            (10.0, 10.0),
856            (0.0, 10.0),
857            (0.0, 0.0),
858        ];
859        assert!(point_in_ring(5.0, 5.0, &ring));
860    }
861
862    #[test]
863    fn test_point_in_ring_outside() {
864        let ring = vec![
865            (0.0, 0.0),
866            (10.0, 0.0),
867            (10.0, 10.0),
868            (0.0, 10.0),
869            (0.0, 0.0),
870        ];
871        assert!(!point_in_ring(15.0, 5.0, &ring));
872    }
873
874    #[test]
875    fn test_point_in_ring_degenerate() {
876        assert!(!point_in_ring(0.0, 0.0, &[]));
877        assert!(!point_in_ring(0.0, 0.0, &[(0.0, 0.0), (1.0, 0.0)]));
878    }
879
880    #[test]
881    fn test_is_boundary_pixel_center() {
882        // 3x3 grid, center pixel is NOT boundary (surrounded by same label)
883        let labels = vec![1, 1, 1, 1, 1, 1, 1, 1, 1];
884        assert!(!is_boundary_pixel(&labels, 3, 3, 1, 1, 1));
885    }
886
887    #[test]
888    fn test_is_boundary_pixel_edge() {
889        // Corner pixel is always boundary
890        let labels = vec![1, 1, 1, 1, 1, 1, 1, 1, 1];
891        assert!(is_boundary_pixel(&labels, 3, 3, 0, 0, 1));
892    }
893
894    #[test]
895    fn test_is_boundary_pixel_adjacent_to_different() {
896        let labels = vec![1, 2, 1, 1, 1, 1, 1, 1, 1];
897        // Pixel (0,0) is adjacent to (1,0)=2, so it's a boundary
898        assert!(is_boundary_pixel(&labels, 3, 3, 0, 0, 1));
899    }
900
901    #[test]
902    fn test_extract_edges_single_pixel() {
903        let mask = vec![true];
904        let edges = extract_boundary_edges(&mask, 1, 1);
905        // A single pixel should have 4 boundary edges
906        assert_eq!(edges.len(), 4);
907    }
908
909    #[test]
910    fn test_extract_edges_2x2_solid() {
911        // All 4 pixels are true
912        let mask = vec![true, true, true, true];
913        let edges = extract_boundary_edges(&mask, 2, 2);
914        // Perimeter edges only: 2+2+2+2 = 8
915        assert_eq!(edges.len(), 8);
916    }
917
918    #[test]
919    fn test_assemble_edge_rings_square() {
920        // Manually create 4 edges forming a unit square
921        let edges = vec![
922            EdgeSegment {
923                x0: 0,
924                y0: 0,
925                x1: 1,
926                y1: 0,
927            },
928            EdgeSegment {
929                x0: 1,
930                y0: 0,
931                x1: 1,
932                y1: 1,
933            },
934            EdgeSegment {
935                x0: 1,
936                y0: 1,
937                x1: 0,
938                y1: 1,
939            },
940            EdgeSegment {
941                x0: 0,
942                y0: 1,
943                x1: 0,
944                y1: 0,
945            },
946        ];
947        let rings = assemble_edge_rings(&edges);
948        assert_eq!(rings.len(), 1);
949        assert_eq!(rings[0].len(), 5); // 4 corners + close
950    }
951
952    #[test]
953    fn test_pixel_edge_single_pixel() {
954        let labels = vec![1u32];
955        let result = extract_pixel_edge_boundaries(&labels, 1, 1);
956        assert!(result.is_ok());
957        let polys = result.ok();
958        assert!(polys.is_some());
959        let polys = polys.as_ref();
960        assert!(polys.is_some_and(|p| p.len() == 1));
961        if let Some(polys) = polys {
962            assert_eq!(polys[0].label, 1);
963            assert!(polys[0].exterior.len() >= 4);
964        }
965    }
966
967    #[test]
968    fn test_pixel_edge_two_regions() {
969        // 2x1 grid: [1, 2]
970        let labels = vec![1u32, 2];
971        let result = extract_pixel_edge_boundaries(&labels, 2, 1);
972        assert!(result.is_ok());
973        let polys = result.ok();
974        assert!(polys.is_some());
975        let polys = polys.as_ref();
976        assert!(polys.is_some_and(|p| p.len() == 2));
977    }
978
979    #[test]
980    fn test_pixel_edge_with_hole() {
981        // 5x5 grid with a hole:
982        // 1 1 1 1 1
983        // 1 0 0 0 1
984        // 1 0 0 0 1
985        // 1 0 0 0 1
986        // 1 1 1 1 1
987        #[rustfmt::skip]
988        let labels = vec![
989            1, 1, 1, 1, 1,
990            1, 0, 0, 0, 1,
991            1, 0, 0, 0, 1,
992            1, 0, 0, 0, 1,
993            1, 1, 1, 1, 1,
994        ];
995        let result = extract_pixel_edge_boundaries(&labels, 5, 5);
996        assert!(result.is_ok());
997        let polys = result.ok();
998        assert!(polys.is_some());
999        if let Some(polys) = polys {
1000            assert_eq!(polys.len(), 1);
1001            assert_eq!(polys[0].label, 1);
1002            // Should have one exterior and one hole
1003            assert!(!polys[0].exterior.is_empty());
1004            assert_eq!(polys[0].holes.len(), 1);
1005        }
1006    }
1007
1008    #[test]
1009    fn test_pixel_edge_empty_grid() {
1010        let labels = vec![0u32; 9];
1011        let result = extract_pixel_edge_boundaries(&labels, 3, 3);
1012        assert!(result.is_ok());
1013        let polys = result.ok();
1014        assert!(polys.is_some_and(|p| p.is_empty()));
1015    }
1016
1017    #[test]
1018    fn test_pixel_edge_size_mismatch() {
1019        let labels = vec![1u32, 2, 3]; // 3 elements
1020        let result = extract_pixel_edge_boundaries(&labels, 2, 2); // expects 4
1021        assert!(result.is_err());
1022    }
1023
1024    #[test]
1025    fn test_transform_coords() {
1026        let gt = oxigdal_core::types::GeoTransform::north_up(100.0, 200.0, 10.0, -10.0);
1027        let pixel_coords = vec![(0.0, 0.0), (1.0, 1.0)];
1028        let world_coords = transform_coords(&pixel_coords, &gt);
1029        assert_eq!(world_coords.len(), 2);
1030        assert!((world_coords[0].x - 100.0).abs() < 1e-10);
1031        assert!((world_coords[0].y - 200.0).abs() < 1e-10);
1032        assert!((world_coords[1].x - 110.0).abs() < 1e-10);
1033        assert!((world_coords[1].y - 190.0).abs() < 1e-10);
1034    }
1035
1036    #[test]
1037    fn test_pixel_coords_to_coordinates() {
1038        let coords = vec![(1.5, 2.5), (3.0, 4.0)];
1039        let result = pixel_coords_to_coordinates(&coords);
1040        assert_eq!(result.len(), 2);
1041        assert!((result[0].x - 1.5).abs() < 1e-10);
1042        assert!((result[0].y - 2.5).abs() < 1e-10);
1043    }
1044
1045    #[test]
1046    fn test_connectivity_default() {
1047        assert_eq!(Connectivity::default(), Connectivity::Eight);
1048    }
1049
1050    #[test]
1051    fn test_trace_boundaries_single_region() {
1052        // 3x3 grid, all label=1
1053        let labels = vec![1u32; 9];
1054        let result = trace_boundaries(&labels, 3, 3, Connectivity::Eight);
1055        assert!(result.is_ok());
1056        let polys = result.ok();
1057        assert!(polys.is_some());
1058        if let Some(polys) = polys {
1059            assert!(!polys.is_empty());
1060            assert_eq!(polys[0].label, 1);
1061        }
1062    }
1063
1064    #[test]
1065    fn test_trace_boundaries_two_separate_regions() {
1066        // 5x1 grid: [1, 1, 0, 2, 2]
1067        let labels = vec![1u32, 1, 0, 2, 2];
1068        let result = trace_boundaries(&labels, 5, 1, Connectivity::Four);
1069        assert!(result.is_ok());
1070        let polys = result.ok();
1071        assert!(polys.is_some());
1072        if let Some(polys) = polys {
1073            assert_eq!(polys.len(), 2);
1074        }
1075    }
1076
1077    #[test]
1078    fn test_trace_boundaries_size_mismatch() {
1079        let labels = vec![1u32; 5];
1080        let result = trace_boundaries(&labels, 3, 3, Connectivity::Eight);
1081        assert!(result.is_err());
1082    }
1083
1084    #[test]
1085    fn test_pixel_ring_is_exterior() {
1086        let ring = PixelRing {
1087            label: 1,
1088            coords: vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)],
1089            signed_area: 1.0,
1090        };
1091        assert!(ring.is_exterior());
1092
1093        let hole = PixelRing {
1094            label: 1,
1095            coords: vec![(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)],
1096            signed_area: -1.0,
1097        };
1098        assert!(!hole.is_exterior());
1099    }
1100}