Skip to main content

oxigdal_algorithms/vector/topology/
split.rs

1//! Split operations for dividing geometric features
2//!
3//! This module implements splitting operations that divide geometries using
4//! other geometries as cutting tools.
5
6use crate::error::{AlgorithmError, Result};
7use oxigdal_core::vector::{Coordinate, LineString, Point, Polygon};
8
9/// Options for split operations
10#[derive(Debug, Clone)]
11pub struct SplitOptions {
12    /// Tolerance for coordinate comparison
13    pub tolerance: f64,
14    /// Whether to snap split points to grid
15    pub snap_to_grid: bool,
16    /// Grid size for snapping (if enabled)
17    pub grid_size: f64,
18    /// Minimum length for resulting line segments
19    pub min_segment_length: f64,
20    /// Whether to preserve all split parts (even very small ones)
21    pub preserve_all: bool,
22    /// Minimum area threshold for resulting polygons (None = no filter)
23    pub min_area: Option<f64>,
24    /// Whether to ensure counter-clockwise orientation on output polygons
25    pub preserve_orientation: bool,
26    /// Whether to attach holes to their enclosing face after splitting
27    pub keep_holes: bool,
28}
29
30impl Default for SplitOptions {
31    fn default() -> Self {
32        Self {
33            tolerance: 1e-10,
34            snap_to_grid: false,
35            grid_size: 1e-6,
36            min_segment_length: 0.0,
37            preserve_all: true,
38            min_area: None,
39            preserve_orientation: false,
40            keep_holes: true,
41        }
42    }
43}
44
45/// Result of a split operation
46#[derive(Debug, Clone)]
47pub struct SplitResult {
48    /// The resulting geometries from the split
49    pub geometries: Vec<SplitGeometry>,
50    /// Number of split points used
51    pub num_splits: usize,
52    /// Whether all splits were successful
53    pub complete: bool,
54}
55
56/// A geometry resulting from a split operation
57#[derive(Debug, Clone)]
58pub enum SplitGeometry {
59    /// A linestring segment
60    LineString(LineString),
61    /// A polygon
62    Polygon(Polygon),
63}
64
65/// Split a linestring by point locations
66///
67/// Divides a linestring into multiple segments at specified points.
68///
69/// # Arguments
70///
71/// * `linestring` - The linestring to split
72/// * `split_points` - Points where the linestring should be split
73/// * `options` - Split options
74///
75/// # Returns
76///
77/// Result containing the split result with multiple linestring segments
78///
79/// # Examples
80///
81/// ```
82/// use oxigdal_algorithms::vector::topology::{split_linestring_by_points, SplitOptions};
83/// use oxigdal_algorithms::{Coordinate, LineString, Point};
84///
85/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
86/// let coords = vec![
87///     Coordinate::new_2d(0.0, 0.0),
88///     Coordinate::new_2d(10.0, 0.0),
89/// ];
90/// let linestring = LineString::new(coords)?;
91///
92/// let split_points = vec![
93///     Point::new(5.0, 0.0),
94/// ];
95///
96/// let result = split_linestring_by_points(&linestring, &split_points, &SplitOptions::default())?;
97/// assert_eq!(result.num_splits, 1);
98/// assert!(result.geometries.len() >= 2);
99/// # Ok(())
100/// # }
101/// ```
102pub fn split_linestring_by_points(
103    linestring: &LineString,
104    split_points: &[Point],
105    options: &SplitOptions,
106) -> Result<SplitResult> {
107    if split_points.is_empty() {
108        return Ok(SplitResult {
109            geometries: vec![SplitGeometry::LineString(linestring.clone())],
110            num_splits: 0,
111            complete: true,
112        });
113    }
114
115    let coords = &linestring.coords;
116    if coords.len() < 2 {
117        return Err(AlgorithmError::InvalidGeometry(
118            "Linestring must have at least 2 coordinates".to_string(),
119        ));
120    }
121
122    // Find all split locations along the linestring
123    let mut split_locations = Vec::new();
124
125    for point in split_points {
126        if let Some(location) = find_point_on_linestring(linestring, point, options.tolerance)? {
127            split_locations.push(location);
128        }
129    }
130
131    // Sort split locations by segment index and parameter
132    // Validate for NaN values before sorting
133    for loc in &split_locations {
134        if loc.parameter.is_nan() {
135            return Err(AlgorithmError::InvalidGeometry(
136                "Split location parameter contains NaN value".to_string(),
137            ));
138        }
139    }
140
141    split_locations.sort_by(|a, b| {
142        a.segment_index.cmp(&b.segment_index).then(
143            a.parameter
144                .partial_cmp(&b.parameter)
145                .unwrap_or(std::cmp::Ordering::Equal),
146        )
147    });
148
149    // Remove duplicates
150    split_locations.dedup_by(|a, b| {
151        a.segment_index == b.segment_index && (a.parameter - b.parameter).abs() < options.tolerance
152    });
153
154    if split_locations.is_empty() {
155        return Ok(SplitResult {
156            geometries: vec![SplitGeometry::LineString(linestring.clone())],
157            num_splits: 0,
158            complete: false,
159        });
160    }
161
162    // Build split segments
163    let mut result_geometries = Vec::new();
164    let mut current_coords = Vec::new();
165    let mut current_segment_idx = 0;
166    let mut split_idx = 0;
167
168    current_coords.push(coords[0]);
169
170    for i in 0..coords.len().saturating_sub(1) {
171        // Add splits on this segment
172        while split_idx < split_locations.len() && split_locations[split_idx].segment_index == i {
173            let split_loc = &split_locations[split_idx];
174            let split_coord = split_loc.coordinate;
175
176            // Add split point
177            current_coords.push(split_coord);
178
179            // Create a new linestring if we have enough points
180            if current_coords.len() >= 2 {
181                if let Ok(ls) = LineString::new(current_coords.clone()) {
182                    if options.preserve_all
183                        || compute_linestring_length(&ls) >= options.min_segment_length
184                    {
185                        result_geometries.push(SplitGeometry::LineString(ls));
186                    }
187                }
188            }
189
190            // Start new segment
191            current_coords.clear();
192            current_coords.push(split_coord);
193
194            split_idx += 1;
195        }
196
197        // Add end point of current segment
198        current_coords.push(coords[i + 1]);
199        current_segment_idx = i + 1;
200    }
201
202    // Add final segment
203    if current_coords.len() >= 2 {
204        if let Ok(ls) = LineString::new(current_coords) {
205            if options.preserve_all || compute_linestring_length(&ls) >= options.min_segment_length
206            {
207                result_geometries.push(SplitGeometry::LineString(ls));
208            }
209        }
210    }
211
212    Ok(SplitResult {
213        geometries: result_geometries,
214        num_splits: split_locations.len(),
215        complete: true,
216    })
217}
218
219/// Location of a point on a linestring
220#[derive(Debug, Clone)]
221struct PointLocation {
222    /// Index of the segment (0-based)
223    segment_index: usize,
224    /// Parameter along the segment (0.0 to 1.0)
225    parameter: f64,
226    /// The coordinate at this location
227    coordinate: Coordinate,
228}
229
230/// Find a point on a linestring
231fn find_point_on_linestring(
232    linestring: &LineString,
233    point: &Point,
234    tolerance: f64,
235) -> Result<Option<PointLocation>> {
236    let coords = &linestring.coords;
237
238    for i in 0..coords.len().saturating_sub(1) {
239        let p1 = coords[i];
240        let p2 = coords[i + 1];
241
242        // Check if point is on this segment
243        if let Some((param, coord)) = point_on_segment(point, &p1, &p2, tolerance) {
244            return Ok(Some(PointLocation {
245                segment_index: i,
246                parameter: param,
247                coordinate: coord,
248            }));
249        }
250    }
251
252    Ok(None)
253}
254
255/// Check if a point is on a line segment
256fn point_on_segment(
257    point: &Point,
258    p1: &Coordinate,
259    p2: &Coordinate,
260    tolerance: f64,
261) -> Option<(f64, Coordinate)> {
262    let px = point.coord.x;
263    let py = point.coord.y;
264
265    // Vector from p1 to p2
266    let dx = p2.x - p1.x;
267    let dy = p2.y - p1.y;
268
269    // Length squared of segment
270    let len_sq = dx * dx + dy * dy;
271
272    if len_sq < tolerance * tolerance {
273        // Degenerate segment
274        return None;
275    }
276
277    // Parameter t along the segment
278    let t = ((px - p1.x) * dx + (py - p1.y) * dy) / len_sq;
279
280    // Check if point projects onto the segment (not before or after)
281    if t < -tolerance || t > 1.0 + tolerance {
282        return None;
283    }
284
285    // Clamp t to [0, 1]
286    let t = t.clamp(0.0, 1.0);
287
288    // Compute closest point on segment
289    let closest_x = p1.x + t * dx;
290    let closest_y = p1.y + t * dy;
291
292    // Check distance from point to closest point
293    let dist_sq = (px - closest_x).powi(2) + (py - closest_y).powi(2);
294
295    if dist_sq < tolerance * tolerance {
296        Some((t, Coordinate::new_2d(closest_x, closest_y)))
297    } else {
298        None
299    }
300}
301
302/// Compute the length of a linestring
303fn compute_linestring_length(linestring: &LineString) -> f64 {
304    let coords = &linestring.coords;
305    let mut length = 0.0;
306
307    for i in 0..coords.len().saturating_sub(1) {
308        let dx = coords[i + 1].x - coords[i].x;
309        let dy = coords[i + 1].y - coords[i].y;
310        length += (dx * dx + dy * dy).sqrt();
311    }
312
313    length
314}
315
316/// Split a polygon by a linestring
317///
318/// Divides a polygon into multiple parts using a linestring as a cutting tool.
319///
320/// # Arguments
321///
322/// * `polygon` - The polygon to split
323/// * `split_line` - The linestring to use for splitting
324/// * `options` - Split options
325///
326/// # Returns
327///
328/// Result containing the split polygons
329///
330/// # Examples
331///
332/// ```no_run
333/// use oxigdal_algorithms::vector::topology::{split_polygon_by_line, SplitOptions};
334/// use oxigdal_algorithms::{Coordinate, LineString, Polygon};
335///
336/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
337/// let coords = vec![
338///     Coordinate::new_2d(0.0, 0.0),
339///     Coordinate::new_2d(10.0, 0.0),
340///     Coordinate::new_2d(10.0, 10.0),
341///     Coordinate::new_2d(0.0, 10.0),
342///     Coordinate::new_2d(0.0, 0.0),
343/// ];
344/// let exterior = LineString::new(coords)?;
345/// let polygon = Polygon::new(exterior, vec![])?;
346///
347/// let split_coords = vec![
348///     Coordinate::new_2d(0.0, 5.0),
349///     Coordinate::new_2d(10.0, 5.0),
350/// ];
351/// let split_line = LineString::new(split_coords)?;
352///
353/// let result = split_polygon_by_line(&polygon, &split_line, &SplitOptions::default())?;
354/// assert!(result.geometries.len() >= 1);
355/// # Ok(())
356/// # }
357/// ```
358pub fn split_polygon_by_line(
359    polygon: &Polygon,
360    split_line: &LineString,
361    options: &SplitOptions,
362) -> Result<SplitResult> {
363    // Find intersection points between polygon boundary and split line
364    let intersection_points = find_polygon_line_intersections(polygon, split_line, options)?;
365
366    if intersection_points.len() < 2 {
367        // Not enough intersections to split
368        return Ok(SplitResult {
369            geometries: vec![SplitGeometry::Polygon(polygon.clone())],
370            num_splits: 0,
371            complete: false,
372        });
373    }
374
375    // Create polygons from split
376    let result_polygons =
377        create_split_polygons(polygon, split_line, &intersection_points, options)?;
378
379    let geometries = result_polygons
380        .into_iter()
381        .map(SplitGeometry::Polygon)
382        .collect();
383
384    Ok(SplitResult {
385        geometries,
386        num_splits: intersection_points.len(),
387        complete: true,
388    })
389}
390
391/// Find intersection points between polygon boundary and a linestring
392fn find_polygon_line_intersections(
393    polygon: &Polygon,
394    line: &LineString,
395    options: &SplitOptions,
396) -> Result<Vec<Coordinate>> {
397    let mut intersections = Vec::new();
398
399    // Check exterior ring
400    let exterior_intersections = find_linestring_intersections(&polygon.exterior, line, options)?;
401    intersections.extend(exterior_intersections);
402
403    // Check interior rings (holes)
404    for interior in &polygon.interiors {
405        let interior_intersections = find_linestring_intersections(interior, line, options)?;
406        intersections.extend(interior_intersections);
407    }
408
409    // Remove duplicates
410    // Validate for NaN values before sorting
411    for coord in &intersections {
412        if coord.x.is_nan() || coord.y.is_nan() {
413            return Err(AlgorithmError::InvalidGeometry(
414                "Intersection coordinate contains NaN value".to_string(),
415            ));
416        }
417    }
418
419    intersections.sort_by(|a, b| {
420        a.x.partial_cmp(&b.x)
421            .unwrap_or(std::cmp::Ordering::Equal)
422            .then(a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal))
423    });
424
425    intersections.dedup_by(|a, b| {
426        (a.x - b.x).abs() < options.tolerance && (a.y - b.y).abs() < options.tolerance
427    });
428
429    Ok(intersections)
430}
431
432/// Find intersections between two linestrings
433fn find_linestring_intersections(
434    line1: &LineString,
435    line2: &LineString,
436    options: &SplitOptions,
437) -> Result<Vec<Coordinate>> {
438    let mut intersections = Vec::new();
439    let coords1 = &line1.coords;
440    let coords2 = &line2.coords;
441
442    for i in 0..coords1.len().saturating_sub(1) {
443        for j in 0..coords2.len().saturating_sub(1) {
444            if let Some(intersection) = compute_segment_intersection(
445                &coords1[i],
446                &coords1[i + 1],
447                &coords2[j],
448                &coords2[j + 1],
449                options.tolerance,
450            ) {
451                intersections.push(intersection);
452            }
453        }
454    }
455
456    Ok(intersections)
457}
458
459/// Compute intersection point between two line segments
460fn compute_segment_intersection(
461    p1: &Coordinate,
462    p2: &Coordinate,
463    p3: &Coordinate,
464    p4: &Coordinate,
465    tolerance: f64,
466) -> Option<Coordinate> {
467    let d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x);
468
469    if d.abs() < tolerance {
470        // Parallel or coincident
471        return None;
472    }
473
474    let t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d;
475    let u = -((p1.x - p2.x) * (p1.y - p3.y) - (p1.y - p2.y) * (p1.x - p3.x)) / d;
476
477    if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
478        let x = p1.x + t * (p2.x - p1.x);
479        let y = p1.y + t * (p2.y - p1.y);
480        Some(Coordinate::new_2d(x, y))
481    } else {
482        None
483    }
484}
485
486// ─── DCEL (Doubly-Connected Edge List) data structures ───────────────────────
487
488/// A single half-edge in the DCEL.
489///
490/// Each undirected edge of the planar subdivision is represented by two
491/// half-edges (a "twin" pair) that travel in opposite directions.
492/// `next` follows the face boundary counter-clockwise.
493#[derive(Clone, Debug)]
494struct HalfEdge {
495    /// Index into `Dcel::vertices` — the tail (origin) vertex.
496    origin: usize,
497    /// Index of the twin half-edge (same undirected edge, opposite direction).
498    twin: usize,
499    /// Index of the next half-edge around the face.
500    next: usize,
501    /// Index of the previous half-edge around the face.
502    prev: usize,
503}
504
505/// The Doubly-Connected Edge List.
506struct Dcel {
507    /// All vertex positions, indexed by vertex id.
508    vertices: Vec<[f64; 2]>,
509    /// All half-edges, indexed by half-edge id.
510    half_edges: Vec<HalfEdge>,
511}
512
513impl Dcel {
514    fn new() -> Self {
515        Self {
516            vertices: Vec::new(),
517            half_edges: Vec::new(),
518        }
519    }
520
521    fn add_vertex(&mut self, x: f64, y: f64) -> usize {
522        let idx = self.vertices.len();
523        self.vertices.push([x, y]);
524        idx
525    }
526
527    /// Allocate a twin pair A→B (index `he`) and B→A (index `he+1`).
528    /// `next`/`prev` are left uninitialised (set to self) — caller fixes them.
529    fn add_half_edge_pair(&mut self, a: usize, b: usize) -> (usize, usize) {
530        let he = self.half_edges.len();
531        let te = he + 1;
532        self.half_edges.push(HalfEdge {
533            origin: a,
534            twin: te,
535            next: he,
536            prev: he,
537        });
538        self.half_edges.push(HalfEdge {
539            origin: b,
540            twin: he,
541            next: te,
542            prev: te,
543        });
544        (he, te)
545    }
546}
547
548// ─── Geometry helpers ─────────────────────────────────────────────────────────
549
550/// Signed area using the shoelace formula on an open (non-closing) ring.
551/// Positive → CCW, negative → CW.
552fn signed_area_ring(coords: &[[f64; 2]]) -> f64 {
553    let n = coords.len();
554    if n < 3 {
555        return 0.0;
556    }
557    let mut area = 0.0f64;
558    for i in 0..n {
559        let j = (i + 1) % n;
560        area += coords[i][0] * coords[j][1];
561        area -= coords[j][0] * coords[i][1];
562    }
563    area * 0.5
564}
565
566/// Ray-casting point-in-polygon test on an open ring (no closing duplicate).
567fn point_in_ring(px: f64, py: f64, ring: &[[f64; 2]]) -> bool {
568    let mut inside = false;
569    let n = ring.len();
570    let mut j = n.wrapping_sub(1);
571    for i in 0..n {
572        let xi = ring[i][0];
573        let yi = ring[i][1];
574        let xj = ring[j][0];
575        let yj = ring[j][1];
576        if ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
577            inside = !inside;
578        }
579        j = i;
580    }
581    inside
582}
583
584/// Squared distance between two 2-D points.
585#[inline]
586fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 {
587    let dx = a[0] - b[0];
588    let dy = a[1] - b[1];
589    dx * dx + dy * dy
590}
591
592// ─── Core DCEL polygon-split implementation ───────────────────────────────────
593
594/// Find (or reuse) a vertex within `tol_sq` of `[x, y]`.
595fn find_or_add_vertex(dcel: &mut Dcel, x: f64, y: f64, tol_sq: f64) -> usize {
596    let pt = [x, y];
597    if let Some(idx) = dcel.vertices.iter().position(|v| dist2(*v, pt) <= tol_sq) {
598        return idx;
599    }
600    dcel.add_vertex(x, y)
601}
602
603/// Walk a face cycle starting from `start_he` and collect vertex positions.
604fn walk_face_cycle(dcel: &Dcel, start_he: usize) -> Vec<[f64; 2]> {
605    let max_steps = dcel.half_edges.len() + 1;
606    let mut coords = Vec::new();
607    let mut cur = start_he;
608    for _ in 0..max_steps {
609        coords.push(dcel.vertices[dcel.half_edges[cur].origin]);
610        cur = dcel.half_edges[cur].next;
611        if cur == start_he {
612            break;
613        }
614    }
615    coords
616}
617
618/// Collect all distinct face cycles (each half-edge is in exactly one cycle).
619fn collect_face_cycles(dcel: &Dcel) -> Vec<Vec<[f64; 2]>> {
620    let n = dcel.half_edges.len();
621    let mut visited = vec![false; n];
622    let mut faces = Vec::new();
623    for start in 0..n {
624        if visited[start] {
625            continue;
626        }
627        let cycle = walk_face_cycle(dcel, start);
628        let mut cur = start;
629        for _ in 0..cycle.len() {
630            visited[cur] = true;
631            cur = dcel.half_edges[cur].next;
632        }
633        if cycle.len() >= 3 {
634            faces.push(cycle);
635        }
636    }
637    faces
638}
639
640/// Split one half-edge `he_ab` (A→B) at midpoint vertex `m`.
641///
642/// This replaces the A→B edge with A→M, and inserts M→B immediately after.
643/// The twin B→A is similarly replaced with B→M / M→A.
644///
645/// `ring_hes` is the ordered list of forward ring half-edges; the split half-edge
646/// at `ring_pos` is updated in place and M→B is inserted after it.
647fn split_half_edge(dcel: &mut Dcel, ring_hes: &mut Vec<usize>, ring_pos: usize, m: usize) {
648    let he_ab = ring_hes[ring_pos];
649    let he_ba = dcel.half_edges[he_ab].twin;
650    let b = dcel.half_edges[he_ba].origin;
651
652    // Save neighbourhood.
653    let next_ab = dcel.half_edges[he_ab].next;
654    let prev_ba = dcel.half_edges[he_ba].prev;
655
656    // Allocate M→B and B→M.
657    let (he_mb, he_bm) = dcel.add_half_edge_pair(m, b);
658
659    // Rewire he_ab → now A→M (destination changes to M via twin).
660    dcel.half_edges[he_ba].origin = m; // twin (M→A) now starts at M
661
662    // Forward ring: A→M → M→B → old_next_of_AB
663    dcel.half_edges[he_ab].next = he_mb;
664    dcel.half_edges[he_mb].prev = he_ab;
665    dcel.half_edges[he_mb].next = next_ab;
666    dcel.half_edges[next_ab].prev = he_mb;
667
668    // Backward ring: old_prev_of_BA → B→M → M→A
669    dcel.half_edges[he_bm].prev = prev_ba;
670    dcel.half_edges[prev_ba].next = he_bm;
671    dcel.half_edges[he_bm].next = he_ba;
672    dcel.half_edges[he_ba].prev = he_bm;
673
674    // Update ring_hes: he_ab stays (now A→M), insert he_mb after.
675    ring_hes.insert(ring_pos + 1, he_mb);
676}
677
678/// The standard DCEL "split face" operation:
679/// insert a diagonal from vertex A (arrived at by `he_into_a`) to vertex B
680/// (arrived at by `he_into_b`), subdividing one face into two.
681///
682/// `he_ab` / `he_ba` are the freshly-allocated diagonal half-edges.
683fn splice_diagonal(
684    dcel: &mut Dcel,
685    he_into_a: usize,
686    he_into_b: usize,
687    he_ab: usize,
688    he_ba: usize,
689) {
690    // The "next" pointers after he_into_a and he_into_b (i.e., what currently
691    // follows A and B along the ring).
692    let after_a = dcel.half_edges[he_into_a].next;
693    let after_b = dcel.half_edges[he_into_b].next;
694
695    // Face 1: … → into_a → he_ab → after_b → … → into_b (loop back to he_ba side)
696    dcel.half_edges[he_into_a].next = he_ab;
697    dcel.half_edges[he_ab].prev = he_into_a;
698    dcel.half_edges[he_ab].next = after_b;
699    dcel.half_edges[after_b].prev = he_ab;
700
701    // Face 2: … → into_b → he_ba → after_a → … → into_a (loop back)
702    dcel.half_edges[he_into_b].next = he_ba;
703    dcel.half_edges[he_ba].prev = he_into_b;
704    dcel.half_edges[he_ba].next = after_a;
705    dcel.half_edges[after_a].prev = he_ba;
706}
707
708/// Find the ring half-edge whose **destination** is `vid` (i.e., whose twin's
709/// origin is `vid`).  We first look in `ring_hes`, then fall back to a full
710/// scan.
711fn find_incoming_he(dcel: &Dcel, vid: usize, ring_hes: &[usize]) -> Option<usize> {
712    // Primary: ring forward half-edges.
713    for &he in ring_hes {
714        if dcel.half_edges[dcel.half_edges[he].twin].origin == vid {
715            return Some(he);
716        }
717    }
718    // Fallback: check all half-edges' twins.
719    dcel.half_edges
720        .iter()
721        .enumerate()
722        .find(|(_, he)| dcel.half_edges[he.twin].origin == vid)
723        .map(|(i, _)| i)
724}
725
726/// Create split polygons from intersection points using a DCEL.
727///
728/// # Algorithm (de Berg et al., *Computational Geometry*)
729///
730/// 1. Build a half-edge ring from the exterior polygon ring.
731/// 2. Sort intersection points along the splitting line by parametric arc-length `t`.
732/// 3. Insert each intersection vertex into the ring by subdividing the ring edge
733///    it lies on (split-edge operation on the DCEL).
734/// 4. For each consecutive pair of intersections whose midpoint lies inside the
735///    polygon, splice a diagonal half-edge pair to split the face.
736/// 5. Walk all resulting face cycles; identify the outer (unbounded) face by its
737///    most-negative signed area and discard it.
738/// 6. Apply `SplitOptions` filters (`min_area`, `preserve_orientation`) and return.
739fn create_split_polygons(
740    polygon: &Polygon,
741    split_line: &LineString,
742    intersections: &[Coordinate],
743    options: &SplitOptions,
744) -> Result<Vec<Polygon>> {
745    let tol = options.tolerance;
746    let tol_sq = tol * tol;
747
748    // ── Step 1: Build DCEL ring from polygon exterior ────────────────────────
749    let ext_raw: Vec<[f64; 2]> = polygon.exterior.coords.iter().map(|c| [c.x, c.y]).collect();
750
751    // Exterior ring: n coords with first == last.  We use n-1 distinct vertices.
752    let n_ring = ext_raw.len();
753    if n_ring < 4 {
754        return Ok(vec![polygon.clone()]);
755    }
756    let n_verts = n_ring - 1; // distinct ring vertices
757
758    let mut dcel = Dcel::new();
759
760    // Add vertices (skip the closing duplicate).
761    let ring_vids: Vec<usize> = (0..n_verts)
762        .map(|i| dcel.add_vertex(ext_raw[i][0], ext_raw[i][1]))
763        .collect();
764
765    // Add half-edge pairs for each ring edge and wire them into a cycle.
766    // `ring_hes[i]` is the forward half-edge v[i] → v[(i+1)%n].
767    let mut ring_hes: Vec<usize> = Vec::with_capacity(n_verts);
768    let mut twin_hes: Vec<usize> = Vec::with_capacity(n_verts);
769    for i in 0..n_verts {
770        let a = ring_vids[i];
771        let b = ring_vids[(i + 1) % n_verts];
772        let (he, te) = dcel.add_half_edge_pair(a, b);
773        ring_hes.push(he);
774        twin_hes.push(te);
775    }
776    // Wire forward cycle: ring_hes[i].next = ring_hes[(i+1)%n].
777    for i in 0..n_verts {
778        let next_i = (i + 1) % n_verts;
779        let prev_i = (i + n_verts - 1) % n_verts;
780        dcel.half_edges[ring_hes[i]].next = ring_hes[next_i];
781        dcel.half_edges[ring_hes[i]].prev = ring_hes[prev_i];
782    }
783    // Wire backward cycle: twin_hes[i].next = twin_hes[(i-1+n)%n].
784    for i in 0..n_verts {
785        let next_i = (i + n_verts - 1) % n_verts; // backward cycle goes the other way
786        let prev_i = (i + 1) % n_verts;
787        dcel.half_edges[twin_hes[i]].next = twin_hes[next_i];
788        dcel.half_edges[twin_hes[i]].prev = twin_hes[prev_i];
789    }
790
791    // ── Step 2: Sort intersections along the split line by arc-length `t` ───
792    let split_raw: Vec<[f64; 2]> = split_line.coords.iter().map(|c| [c.x, c.y]).collect();
793    let split_n = split_raw.len();
794
795    // Cumulative arc-length parameter at each split-line vertex.
796    let mut cumlen = vec![0.0f64; split_n];
797    for i in 1..split_n {
798        let dx = split_raw[i][0] - split_raw[i - 1][0];
799        let dy = split_raw[i][1] - split_raw[i - 1][1];
800        cumlen[i] = cumlen[i - 1] + (dx * dx + dy * dy).sqrt();
801    }
802
803    // Each intersection point gets a parametric `t` along the split line.
804    struct IntPt {
805        coord: [f64; 2],
806        t: f64,
807    }
808
809    let mut int_pts: Vec<IntPt> = Vec::new();
810    'classify: for isect in intersections {
811        let ix = isect.x;
812        let iy = isect.y;
813        for seg in 0..split_n.saturating_sub(1) {
814            let [ax, ay] = split_raw[seg];
815            let [bx, by] = split_raw[seg + 1];
816            let ddx = bx - ax;
817            let ddy = by - ay;
818            let seg_len_sq = ddx * ddx + ddy * ddy;
819            if seg_len_sq < tol_sq {
820                continue;
821            }
822            let s = ((ix - ax) * ddx + (iy - ay) * ddy) / seg_len_sq;
823            if s < -tol || s > 1.0 + tol {
824                continue;
825            }
826            let proj_x = ax + s * ddx;
827            let proj_y = ay + s * ddy;
828            if (ix - proj_x).powi(2) + (iy - proj_y).powi(2) > tol_sq * 100.0 {
829                continue;
830            }
831            let s_clamped = s.clamp(0.0, 1.0);
832            let t = cumlen[seg] + s_clamped * (cumlen[seg + 1] - cumlen[seg]);
833            int_pts.push(IntPt { coord: [ix, iy], t });
834            continue 'classify;
835        }
836    }
837
838    int_pts.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap_or(std::cmp::Ordering::Equal));
839    int_pts.dedup_by(|a, b| dist2(a.coord, b.coord) < tol_sq * 100.0);
840
841    if int_pts.len() < 2 {
842        return Ok(vec![polygon.clone()]);
843    }
844
845    // ── Step 3: Insert intersection vertices into the ring ───────────────────
846    // For each intersection point, find the ring edge it lies on and split that
847    // edge at the intersection vertex.
848
849    // intersection vertex ids, parallel to int_pts.
850    let mut int_vids: Vec<usize> = Vec::with_capacity(int_pts.len());
851
852    for ip in &int_pts {
853        let [ix, iy] = ip.coord;
854
855        // Find the ring edge position this point lies on.
856        let mut found_pos: Option<usize> = None;
857        'find_edge: for pos in 0..ring_hes.len() {
858            let he = ring_hes[pos];
859            let a_vid = dcel.half_edges[he].origin;
860            let b_vid = dcel.half_edges[dcel.half_edges[he].twin].origin;
861            let [ax, ay] = dcel.vertices[a_vid];
862            let [bx, by] = dcel.vertices[b_vid];
863            let ddx = bx - ax;
864            let ddy = by - ay;
865            let seg_len_sq = ddx * ddx + ddy * ddy;
866            if seg_len_sq < tol_sq {
867                continue;
868            }
869            let s = ((ix - ax) * ddx + (iy - ay) * ddy) / seg_len_sq;
870            if s < -tol || s > 1.0 + tol {
871                continue;
872            }
873            let proj_x = ax + s * ddx;
874            let proj_y = ay + s * ddy;
875            if (ix - proj_x).powi(2) + (iy - proj_y).powi(2) <= tol_sq * 100.0 {
876                found_pos = Some(pos);
877                break 'find_edge;
878            }
879        }
880
881        // Find or create the intersection vertex.
882        let m_vid = find_or_add_vertex(&mut dcel, ix, iy, tol_sq * 100.0);
883        int_vids.push(m_vid);
884
885        if let Some(pos) = found_pos {
886            // Skip if intersection coincides with an existing ring vertex.
887            let he = ring_hes[pos];
888            let a_vid = dcel.half_edges[he].origin;
889            let b_vid = dcel.half_edges[dcel.half_edges[he].twin].origin;
890            if m_vid == a_vid || m_vid == b_vid {
891                continue; // already a ring vertex
892            }
893            split_half_edge(&mut dcel, &mut ring_hes, pos, m_vid);
894        }
895    }
896
897    // ── Step 4: Add diagonal half-edges along the split line ────────────────
898    // For each consecutive pair of intersections whose midpoint is inside the
899    // polygon, splice a new diagonal to subdivide the face.
900
901    for k in 0..int_pts.len().saturating_sub(1) {
902        let [ax, ay] = int_pts[k].coord;
903        let [bx, by] = int_pts[k + 1].coord;
904        let mx = (ax + bx) * 0.5;
905        let my = (ay + by) * 0.5;
906
907        // Midpoint must be strictly inside the polygon exterior.
908        if !point_in_ring(mx, my, &ext_raw[..n_verts]) {
909            continue;
910        }
911        // Midpoint must not be inside any hole.
912        let in_hole = polygon.interiors.iter().any(|h| {
913            let hole_raw: Vec<[f64; 2]> = h.coords.iter().map(|c| [c.x, c.y]).collect();
914            let n_h = hole_raw.len().saturating_sub(1); // drop closing duplicate
915            point_in_ring(mx, my, &hole_raw[..n_h])
916        });
917        if in_hole {
918            continue;
919        }
920
921        let vid_a = int_vids[k];
922        let vid_b = int_vids[k + 1];
923        if vid_a == vid_b {
924            continue;
925        }
926
927        // Find incoming ring half-edges at A and B.
928        let into_a = match find_incoming_he(&dcel, vid_a, &ring_hes) {
929            Some(h) => h,
930            None => continue,
931        };
932        let into_b = match find_incoming_he(&dcel, vid_b, &ring_hes) {
933            Some(h) => h,
934            None => continue,
935        };
936
937        // Allocate diagonal A→B and B→A.
938        let (he_ab, he_ba) = dcel.add_half_edge_pair(vid_a, vid_b);
939
940        // Splice into the face cycle.
941        splice_diagonal(&mut dcel, into_a, into_b, he_ab, he_ba);
942    }
943
944    // ── Step 5: Extract face cycles and identify inner faces ─────────────────
945    let face_cycles = collect_face_cycles(&dcel);
946    if face_cycles.is_empty() {
947        return Ok(vec![polygon.clone()]);
948    }
949
950    // The outer (unbounded) face is the cycle with the most-negative signed area.
951    let outer_idx = face_cycles
952        .iter()
953        .enumerate()
954        .min_by(|(_, a), (_, b)| {
955            signed_area_ring(a)
956                .partial_cmp(&signed_area_ring(b))
957                .unwrap_or(std::cmp::Ordering::Equal)
958        })
959        .map(|(i, _)| i)
960        .unwrap_or(usize::MAX);
961
962    // ── Step 6: Build result polygons from inner faces ───────────────────────
963    let mut result_polygons: Vec<Polygon> = Vec::new();
964
965    for (i, cycle) in face_cycles.iter().enumerate() {
966        if i == outer_idx {
967            continue;
968        }
969        let sa = signed_area_ring(cycle);
970        if sa.abs() < tol * tol {
971            continue; // degenerate
972        }
973
974        // Close the ring.
975        let mut ring_pts = cycle.clone();
976        ring_pts.push(ring_pts[0]);
977
978        if ring_pts.len() < 4 {
979            continue;
980        }
981
982        // Ensure correct orientation if requested.
983        if options.preserve_orientation && sa < 0.0 {
984            ring_pts.reverse();
985        }
986
987        let coords: Vec<Coordinate> = ring_pts
988            .iter()
989            .map(|&[x, y]| Coordinate::new_2d(x, y))
990            .collect();
991
992        let exterior = match LineString::new(coords) {
993            Ok(ls) => ls,
994            Err(_) => continue,
995        };
996
997        let poly_area = sa.abs();
998        if let Some(threshold) = options.min_area {
999            if poly_area < threshold {
1000                continue;
1001            }
1002        }
1003
1004        // Assign polygon holes that lie inside this face.
1005        let mut interior_rings: Vec<LineString> = Vec::new();
1006        if options.keep_holes {
1007            for hole in &polygon.interiors {
1008                if hole.coords.len() < 4 {
1009                    continue;
1010                }
1011                let hx = hole.coords[0].x;
1012                let hy = hole.coords[0].y;
1013                if point_in_ring(hx, hy, cycle) {
1014                    interior_rings.push(hole.clone());
1015                }
1016            }
1017        }
1018
1019        let poly = match Polygon::new(exterior, interior_rings) {
1020            Ok(p) => p,
1021            Err(_) => continue,
1022        };
1023        result_polygons.push(poly);
1024    }
1025
1026    if result_polygons.is_empty() {
1027        return Ok(vec![polygon.clone()]);
1028    }
1029
1030    Ok(result_polygons)
1031}
1032
1033/// Split a polygon by another polygon
1034///
1035/// Divides a polygon using another polygon's boundary as cutting edges.
1036pub fn split_polygon_by_polygon(
1037    target_poly: &Polygon,
1038    split_poly: &Polygon,
1039    options: &SplitOptions,
1040) -> Result<SplitResult> {
1041    // Use the exterior ring of the split polygon as the splitting linestring
1042    let split_line = &split_poly.exterior.clone();
1043
1044    split_polygon_by_line(target_poly, split_line, options)
1045}
1046
1047/// Batch split operation for multiple polygons
1048pub fn split_polygons_batch(
1049    polygons: &[Polygon],
1050    split_line: &LineString,
1051    options: &SplitOptions,
1052) -> Result<Vec<SplitResult>> {
1053    polygons
1054        .iter()
1055        .map(|poly| split_polygon_by_line(poly, split_line, options))
1056        .collect()
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062
1063    fn create_linestring(coords: Vec<(f64, f64)>) -> LineString {
1064        let coords: Vec<Coordinate> = coords
1065            .iter()
1066            .map(|(x, y)| Coordinate::new_2d(*x, *y))
1067            .collect();
1068        LineString::new(coords).expect("Failed to create linestring")
1069    }
1070
1071    fn create_polygon(coords: Vec<(f64, f64)>) -> Polygon {
1072        let coords: Vec<Coordinate> = coords
1073            .iter()
1074            .map(|(x, y)| Coordinate::new_2d(*x, *y))
1075            .collect();
1076        let exterior = LineString::new(coords).expect("Failed to create linestring");
1077        Polygon::new(exterior, vec![]).expect("Failed to create polygon")
1078    }
1079
1080    #[test]
1081    fn test_split_linestring_single_point() {
1082        let linestring = create_linestring(vec![(0.0, 0.0), (10.0, 0.0)]);
1083        let split_points = vec![Point::new(5.0, 0.0)];
1084
1085        let result =
1086            split_linestring_by_points(&linestring, &split_points, &SplitOptions::default());
1087        assert!(result.is_ok());
1088
1089        let split_result = result.expect("Split failed");
1090        assert_eq!(split_result.num_splits, 1);
1091        assert!(split_result.geometries.len() >= 2);
1092    }
1093
1094    #[test]
1095    fn test_split_linestring_multiple_points() {
1096        let linestring = create_linestring(vec![(0.0, 0.0), (10.0, 0.0)]);
1097        let split_points = vec![Point::new(3.0, 0.0), Point::new(7.0, 0.0)];
1098
1099        let result =
1100            split_linestring_by_points(&linestring, &split_points, &SplitOptions::default());
1101        assert!(result.is_ok());
1102
1103        let split_result = result.expect("Split failed");
1104        assert_eq!(split_result.num_splits, 2);
1105    }
1106
1107    #[test]
1108    fn test_split_linestring_no_intersection() {
1109        let linestring = create_linestring(vec![(0.0, 0.0), (10.0, 0.0)]);
1110        let split_points = vec![Point::new(5.0, 5.0)]; // Not on line
1111
1112        let result =
1113            split_linestring_by_points(&linestring, &split_points, &SplitOptions::default());
1114        assert!(result.is_ok());
1115
1116        let split_result = result.expect("Split failed");
1117        assert_eq!(split_result.num_splits, 0);
1118        assert_eq!(split_result.geometries.len(), 1); // Unchanged
1119    }
1120
1121    #[test]
1122    fn test_split_linestring_empty_splits() {
1123        let linestring = create_linestring(vec![(0.0, 0.0), (10.0, 0.0)]);
1124        let split_points = vec![];
1125
1126        let result =
1127            split_linestring_by_points(&linestring, &split_points, &SplitOptions::default());
1128        assert!(result.is_ok());
1129
1130        let split_result = result.expect("Split failed");
1131        assert_eq!(split_result.num_splits, 0);
1132        assert_eq!(split_result.geometries.len(), 1);
1133    }
1134
1135    #[test]
1136    fn test_point_on_segment() {
1137        let p1 = Coordinate::new_2d(0.0, 0.0);
1138        let p2 = Coordinate::new_2d(10.0, 0.0);
1139        let point = Point::new(5.0, 0.0);
1140
1141        let result = point_on_segment(&point, &p1, &p2, 1e-10);
1142        assert!(result.is_some());
1143
1144        if let Some((param, coord)) = result {
1145            assert!((param - 0.5).abs() < 1e-10);
1146            assert!((coord.x - 5.0).abs() < 1e-10);
1147            assert!((coord.y - 0.0).abs() < 1e-10);
1148        }
1149    }
1150
1151    #[test]
1152    fn test_point_not_on_segment() {
1153        let p1 = Coordinate::new_2d(0.0, 0.0);
1154        let p2 = Coordinate::new_2d(10.0, 0.0);
1155        let point = Point::new(5.0, 5.0); // Off the line
1156
1157        let result = point_on_segment(&point, &p1, &p2, 1e-10);
1158        assert!(result.is_none());
1159    }
1160
1161    #[test]
1162    fn test_compute_segment_intersection() {
1163        let p1 = Coordinate::new_2d(0.0, 0.0);
1164        let p2 = Coordinate::new_2d(10.0, 10.0);
1165        let p3 = Coordinate::new_2d(0.0, 10.0);
1166        let p4 = Coordinate::new_2d(10.0, 0.0);
1167
1168        let result = compute_segment_intersection(&p1, &p2, &p3, &p4, 1e-10);
1169        assert!(result.is_some());
1170
1171        if let Some(intersection) = result {
1172            // Intersection should be at (5, 5)
1173            assert!((intersection.x - 5.0).abs() < 1e-6);
1174            assert!((intersection.y - 5.0).abs() < 1e-6);
1175        }
1176    }
1177
1178    #[test]
1179    fn test_split_polygon_by_line() {
1180        let polygon = create_polygon(vec![
1181            (0.0, 0.0),
1182            (10.0, 0.0),
1183            (10.0, 10.0),
1184            (0.0, 10.0),
1185            (0.0, 0.0),
1186        ]);
1187
1188        let split_line = create_linestring(vec![(0.0, 5.0), (10.0, 5.0)]);
1189
1190        let result = split_polygon_by_line(&polygon, &split_line, &SplitOptions::default());
1191        assert!(result.is_ok());
1192
1193        let split_result = result.expect("Split failed");
1194        assert_eq!(split_result.num_splits, 2); // Two intersection points
1195    }
1196
1197    #[test]
1198    fn test_compute_linestring_length() {
1199        let linestring = create_linestring(vec![(0.0, 0.0), (3.0, 0.0), (3.0, 4.0)]);
1200        let length = compute_linestring_length(&linestring);
1201
1202        // Length is 3 + 4 = 7
1203        assert!((length - 7.0).abs() < 1e-6);
1204    }
1205
1206    // ─── DCEL polygon-split tests ─────────────────────────────────────────────
1207
1208    /// Helper: compute unsigned area of a polygon using the shoelace formula.
1209    fn polygon_area(poly: &Polygon) -> f64 {
1210        let coords = &poly.exterior.coords;
1211        let n = coords.len();
1212        if n < 3 {
1213            return 0.0;
1214        }
1215        let mut area = 0.0f64;
1216        let mut j = n - 1;
1217        for i in 0..n {
1218            area += (coords[j].x + coords[i].x) * (coords[j].y - coords[i].y);
1219            j = i;
1220        }
1221        (area * 0.5).abs()
1222    }
1223
1224    #[test]
1225    fn test_split_square_by_vertical_returns_two_halves() {
1226        // 1×1 square split at x = 0.5 should produce two rectangles each of area ≈ 0.5.
1227        let polygon = create_polygon(vec![
1228            (0.0, 0.0),
1229            (1.0, 0.0),
1230            (1.0, 1.0),
1231            (0.0, 1.0),
1232            (0.0, 0.0),
1233        ]);
1234        let split_line = create_linestring(vec![(-0.5, 0.5), (1.5, 0.5)]);
1235
1236        let result = split_polygon_by_line(&polygon, &split_line, &SplitOptions::default())
1237            .expect("split must succeed for vertical line on unit square");
1238
1239        // We expect two polygon pieces.
1240        assert_eq!(
1241            result.geometries.len(),
1242            2,
1243            "vertical split of unit square must yield exactly 2 pieces, got {}",
1244            result.geometries.len()
1245        );
1246
1247        let areas: Vec<f64> = result
1248            .geometries
1249            .iter()
1250            .filter_map(|g| {
1251                if let SplitGeometry::Polygon(p) = g {
1252                    Some(polygon_area(p))
1253                } else {
1254                    None
1255                }
1256            })
1257            .collect();
1258        assert_eq!(
1259            areas.len(),
1260            result.geometries.len(),
1261            "all geometries must be polygons"
1262        );
1263        for area in &areas {
1264            assert!(
1265                (area - 0.5).abs() < 1e-6,
1266                "each half must have area ≈ 0.5, got {area}"
1267            );
1268        }
1269    }
1270
1271    #[test]
1272    fn test_split_square_by_diagonal_returns_two_triangles() {
1273        // 1×1 square split along the diagonal (0,0)→(1,1) yields two triangles
1274        // each with area ≈ 0.5.
1275        let polygon = create_polygon(vec![
1276            (0.0, 0.0),
1277            (1.0, 0.0),
1278            (1.0, 1.0),
1279            (0.0, 1.0),
1280            (0.0, 0.0),
1281        ]);
1282        let split_line = create_linestring(vec![(-0.1, -0.1), (1.1, 1.1)]);
1283
1284        let result = split_polygon_by_line(&polygon, &split_line, &SplitOptions::default())
1285            .expect("split must succeed for diagonal split");
1286
1287        assert_eq!(
1288            result.geometries.len(),
1289            2,
1290            "diagonal split must yield 2 triangles, got {}",
1291            result.geometries.len()
1292        );
1293
1294        let areas: Vec<f64> = result
1295            .geometries
1296            .iter()
1297            .filter_map(|g| {
1298                if let SplitGeometry::Polygon(p) = g {
1299                    Some(polygon_area(p))
1300                } else {
1301                    None
1302                }
1303            })
1304            .collect();
1305        assert_eq!(areas.len(), 2, "both geometries must be polygons");
1306        for area in &areas {
1307            assert!(
1308                (area - 0.5).abs() < 1e-5,
1309                "each triangle must have area ≈ 0.5, got {area}"
1310            );
1311        }
1312    }
1313
1314    #[test]
1315    fn test_split_line_misses_polygon_returns_original() {
1316        // A line that does not intersect the polygon at all must return the original.
1317        let polygon = create_polygon(vec![
1318            (0.0, 0.0),
1319            (1.0, 0.0),
1320            (1.0, 1.0),
1321            (0.0, 1.0),
1322            (0.0, 0.0),
1323        ]);
1324        let split_line = create_linestring(vec![(5.0, 0.0), (5.0, 1.0)]);
1325
1326        let result = split_polygon_by_line(&polygon, &split_line, &SplitOptions::default())
1327            .expect("split must succeed even when line misses polygon");
1328
1329        assert_eq!(
1330            result.geometries.len(),
1331            1,
1332            "missed line must return exactly the original polygon"
1333        );
1334        assert_eq!(
1335            result.num_splits, 0,
1336            "no split points should be recorded for a miss"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_split_concave_polygon_three_pieces() {
1342        // L-shaped concave polygon (vertices in CCW order):
1343        //   (0,0)→(2,0)→(2,1)→(1,1)→(1,2)→(0,2)→(0,0)
1344        // A horizontal split at y = 1 cuts across the notch and can yield
1345        // multiple pieces.  We just verify every piece has positive area.
1346        let polygon = create_polygon(vec![
1347            (0.0, 0.0),
1348            (2.0, 0.0),
1349            (2.0, 1.0),
1350            (1.0, 1.0),
1351            (1.0, 2.0),
1352            (0.0, 2.0),
1353            (0.0, 0.0),
1354        ]);
1355        let split_line = create_linestring(vec![(-0.5, 1.0), (2.5, 1.0)]);
1356
1357        let result = split_polygon_by_line(&polygon, &split_line, &SplitOptions::default())
1358            .expect("split must succeed for L-shaped polygon");
1359
1360        assert!(
1361            !result.geometries.is_empty(),
1362            "split must return at least one polygon piece"
1363        );
1364
1365        for geom in &result.geometries {
1366            if let SplitGeometry::Polygon(poly) = geom {
1367                let area = polygon_area(poly);
1368                assert!(
1369                    area > 1e-10,
1370                    "all result pieces must have positive area, got {area}"
1371                );
1372            }
1373            // Non-polygon variants are ignored: split_polygon_by_line only produces Polygon results.
1374        }
1375    }
1376
1377    #[test]
1378    fn test_split_respects_min_area_filter() {
1379        // Split a 1×1 square at y = 0.5, then apply min_area = 1.0 (larger than either half).
1380        // No piece should survive the filter.
1381        let polygon = create_polygon(vec![
1382            (0.0, 0.0),
1383            (1.0, 0.0),
1384            (1.0, 1.0),
1385            (0.0, 1.0),
1386            (0.0, 0.0),
1387        ]);
1388        let split_line = create_linestring(vec![(-0.5, 0.5), (1.5, 0.5)]);
1389
1390        let options = SplitOptions {
1391            min_area: Some(1.0), // both halves have area 0.5 < 1.0
1392            ..SplitOptions::default()
1393        };
1394
1395        let result = split_polygon_by_line(&polygon, &split_line, &options)
1396            .expect("split must succeed with min_area filter");
1397
1398        // If the split worked correctly and filter is applied, we get 0 pieces.
1399        // (If the DCEL returned the original on failure, area = 1.0 which equals the
1400        //  threshold, so we test ≤ 1 to be safe.)
1401        for geom in &result.geometries {
1402            if let SplitGeometry::Polygon(poly) = geom {
1403                let area = polygon_area(poly);
1404                assert!(
1405                    area >= 1.0,
1406                    "min_area filter must remove all pieces smaller than 1.0, piece area = {area}"
1407                );
1408            }
1409        }
1410    }
1411}