oxigdal-algorithms 0.1.6

High-performance SIMD-optimized raster and vector algorithms for OxiGDAL - Pure Rust geospatial processing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
//! Hobby snap rounding for robust geometric operations.
//!
//! Snap rounding is a technique to achieve robustness in computational geometry
//! by snapping all coordinates (and intersection points) to a regular grid.
//! This eliminates floating-point inconsistencies and ensures topological
//! validity of noded segment arrangements.
//!
//! # Algorithm
//!
//! The iterative Hobby snap rounding algorithm proceeds as:
//! 1. Snap all input coordinates to the precision grid.
//! 2. Collect all pairwise segment-segment intersections (cross-line).
//! 3. Snap each intersection to the grid.
//! 4. Split every segment that an intersection point lies on.
//! 5. Repeat steps 2-4 until convergence or `max_iterations` is reached.
//! 6. Remove zero-length segments.
//!
//! # References
//!
//! - J. Hobby, "Practical segment intersection with finite precision output",
//!   *Computational Geometry*, 13(4):199-214, 1999.

use crate::error::{AlgorithmError, Result};
use oxigdal_core::vector::Coordinate;

/// Options controlling snap rounding behaviour.
///
/// All output coordinates produced by [`snap_round`] are exact multiples of
/// `precision` on both axes.
#[derive(Debug, Clone)]
pub struct SnapRoundingOptions {
    /// Grid cell size (epsilon).
    ///
    /// All output coordinates are multiples of this value.
    /// Must be strictly positive. Typical values:
    /// - `1e-6` for geographic (degree) coordinates
    /// - `0.01` for centimetre-precision projected coordinates
    /// - `1.0` for integer grid coordinates
    pub precision: f64,

    /// Maximum propagation iterations (default `8`).
    ///
    /// Each pass may introduce new intersection points that themselves need
    /// snapping and may lie on other segments. The algorithm terminates when
    /// no new points are found or this limit is reached.
    pub max_iterations: usize,
}

impl Default for SnapRoundingOptions {
    fn default() -> Self {
        Self {
            precision: 1e-6,
            max_iterations: 8,
        }
    }
}

/// A single output segment after snap rounding.
///
/// Both endpoints are guaranteed to lie on the precision grid.
#[derive(Debug, Clone, PartialEq)]
pub struct SnappedSegment {
    /// Start vertex (on grid).
    pub start: Coordinate,
    /// End vertex (on grid).
    pub end: Coordinate,
    /// Index of the source polyline this segment was derived from.
    pub source_line: usize,
}

/// Result returned by [`snap_round`].
#[derive(Debug, Clone)]
pub struct SnapRoundingResult {
    /// All output segments — noded and snapped to the precision grid.
    pub segments: Vec<SnappedSegment>,
    /// Total number of unique intersection points inserted during propagation.
    pub intersections_added: usize,
    /// Number of propagation iterations actually performed.
    pub iterations: usize,
}

// ---------------------------------------------------------------------------
// Internal working segment type
// ---------------------------------------------------------------------------

/// An internal mutable segment used during the snap-rounding computation.
#[derive(Debug, Clone)]
struct WorkSegment {
    start: Coordinate,
    end: Coordinate,
    source_line: usize,
}

// ---------------------------------------------------------------------------
// Low-level grid helpers
// ---------------------------------------------------------------------------

/// Snap (x, y) to the nearest multiple of `prec` on each axis.
#[inline]
fn snap_to_grid(x: f64, y: f64, prec: f64) -> (f64, f64) {
    ((x / prec).round() * prec, (y / prec).round() * prec)
}

/// Return true when two coordinates coincide within half a grid cell.
#[inline]
fn coords_equal_grid(a: &Coordinate, b: &Coordinate, prec: f64) -> bool {
    let half = prec * 0.5;
    (a.x - b.x).abs() < half && (a.y - b.y).abs() < half
}

/// Test whether `p` lies on the open or closed segment `[a, b]` within the
/// tolerance implied by `prec`.
///
/// The check uses:
/// 1. The cross product (signed area) must be near zero — collinearity test.
/// 2. The dot product must place `p` between `a` and `b` along the segment
///    direction — endpoint inclusivity test.
#[inline]
fn point_on_segment(p: &Coordinate, a: &Coordinate, b: &Coordinate, prec: f64) -> bool {
    // Cross product of (b-a) × (p-a).  For p on the line this is 0.
    let cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
    // Tolerance for cross product: we allow up to `prec` absolute deviation.
    if cross.abs() > prec {
        return false;
    }
    // Dot product of (p-a) · (b-a) must be in [0, |b-a|²].
    let dot = (p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y);
    let len2 = (b.x - a.x).powi(2) + (b.y - a.y).powi(2);
    dot >= -prec && dot <= len2 + prec
}

/// Split a [`WorkSegment`] at point `p`, returning the two halves.
#[inline]
fn split_segment_at(seg: &WorkSegment, p: &Coordinate) -> (WorkSegment, WorkSegment) {
    (
        WorkSegment {
            start: seg.start,
            end: *p,
            source_line: seg.source_line,
        },
        WorkSegment {
            start: *p,
            end: seg.end,
            source_line: seg.source_line,
        },
    )
}

// ---------------------------------------------------------------------------
// Public API — coordinate-level helpers
// ---------------------------------------------------------------------------

/// Snap a single coordinate to the precision grid.
///
/// Each axis is independently rounded to the nearest multiple of `precision`.
///
/// # Panics
///
/// Does not panic. Returns `NaN` coordinates only if `precision` is zero or
/// `NaN` (callers are expected to validate `precision > 0`).
///
/// # Examples
///
/// ```
/// use oxigdal_algorithms::vector::{snap_coordinate, Coordinate};
///
/// let c = Coordinate::new_2d(1.23456789, -0.99999999);
/// let snapped = snap_coordinate(&c, 1e-6);
/// assert!((snapped.x - 1.234568).abs() < 1e-12);
/// ```
#[must_use]
pub fn snap_coordinate(c: &Coordinate, precision: f64) -> Coordinate {
    let (sx, sy) = snap_to_grid(c.x, c.y, precision);
    Coordinate::new_2d(sx, sy)
}

/// Snap all coordinates in a slice to the precision grid, removing consecutive
/// duplicates that arise after snapping.
///
/// Duplicate detection uses an exact equality check on the snapped (already
/// grid-aligned) values.
///
/// # Examples
///
/// ```
/// use oxigdal_algorithms::vector::{snap_linestring, Coordinate};
///
/// let coords = vec![
///     Coordinate::new_2d(0.0, 0.0),
///     Coordinate::new_2d(0.0000001, 0.0000001),  // collapses to same as first
///     Coordinate::new_2d(1.0, 0.0),
/// ];
/// let snapped = snap_linestring(&coords, 1e-6);
/// assert_eq!(snapped.len(), 2);
/// ```
#[must_use]
pub fn snap_linestring(coords: &[Coordinate], precision: f64) -> Vec<Coordinate> {
    let mut result: Vec<Coordinate> = Vec::with_capacity(coords.len());
    for c in coords {
        let s = snap_coordinate(c, precision);
        // Remove consecutive duplicates (exact equality after grid snapping).
        if result.last().is_none_or(|prev| {
            (prev.x - s.x).abs() > f64::EPSILON || (prev.y - s.y).abs() > f64::EPSILON
        }) {
            result.push(s);
        }
    }
    result
}

// ---------------------------------------------------------------------------
// Core snap-rounding algorithm
// ---------------------------------------------------------------------------

/// Perform iterative Hobby snap rounding on a collection of polylines.
///
/// Each element of `lines` is a polyline given as a `Vec<Coordinate>`.
/// The algorithm nodes the arrangement — every pair of segments from
/// *different* source lines is checked for intersection — and guarantees
/// that all vertices in the output lie on the precision grid.
///
/// # Errors
///
/// Returns [`AlgorithmError::InvalidParameter`] if `options.precision` is not
/// strictly positive.
///
/// # Complexity
///
/// The brute-force intersection search is O(n²) per iteration where *n* is
/// the total segment count.  This is appropriate for typical geospatial inputs
/// with up to tens of thousands of segments.
pub fn snap_round(
    lines: &[Vec<Coordinate>],
    options: &SnapRoundingOptions,
) -> Result<SnapRoundingResult> {
    if options.precision <= 0.0 || options.precision.is_nan() {
        return Err(AlgorithmError::InvalidParameter {
            parameter: "precision",
            message: format!(
                "precision must be strictly positive, got {}",
                options.precision
            ),
        });
    }
    let prec = options.precision;

    // ------------------------------------------------------------------
    // Step 1: Initial snapping — snap every vertex to the grid.
    // ------------------------------------------------------------------
    let mut segments: Vec<WorkSegment> = Vec::new();
    for (line_idx, line) in lines.iter().enumerate() {
        let snapped = snap_linestring(line, prec);
        // Collect pairwise segments from consecutive snapped vertices.
        for pair in snapped.windows(2) {
            let start = pair[0];
            let end = pair[1];
            // Skip zero-length segments that emerged from initial snapping.
            if coords_equal_grid(&start, &end, prec) {
                continue;
            }
            segments.push(WorkSegment {
                start,
                end,
                source_line: line_idx,
            });
        }
    }

    // ------------------------------------------------------------------
    // Steps 2-5: Iterative intersection propagation.
    // ------------------------------------------------------------------
    let mut total_intersections_added: usize = 0;
    let mut iterations_done: usize = 0;

    for _iter in 0..options.max_iterations {
        let new_pts = collect_cross_intersections(&segments, prec);
        if new_pts.is_empty() {
            break;
        }
        total_intersections_added += new_pts.len();
        segments = insert_intersection_points(segments, &new_pts, prec);
        iterations_done += 1;
    }

    // ------------------------------------------------------------------
    // Step 6: Remove zero-length segments from the final list.
    // ------------------------------------------------------------------
    let output: Vec<SnappedSegment> = segments
        .into_iter()
        .filter(|seg| !coords_equal_grid(&seg.start, &seg.end, prec))
        .map(|seg| SnappedSegment {
            start: seg.start,
            end: seg.end,
            source_line: seg.source_line,
        })
        .collect();

    Ok(SnapRoundingResult {
        segments: output,
        intersections_added: total_intersections_added,
        iterations: iterations_done,
    })
}

// ---------------------------------------------------------------------------
// Internal helpers for the iterative algorithm
// ---------------------------------------------------------------------------

/// Collect all unique pairwise intersection points between segments that belong
/// to *different* source lines.
///
/// Each intersection is snapped to the grid before deduplication.
fn collect_cross_intersections(segments: &[WorkSegment], prec: f64) -> Vec<Coordinate> {
    use crate::vector::intersection::{SegmentIntersection, intersect_segment_segment};

    let mut found: Vec<Coordinate> = Vec::new();

    let n = segments.len();
    for i in 0..n {
        for j in (i + 1)..n {
            let a = &segments[i];
            let b = &segments[j];
            // Only consider pairs from different source lines.
            if a.source_line == b.source_line {
                continue;
            }
            match intersect_segment_segment(&a.start, &a.end, &b.start, &b.end) {
                SegmentIntersection::Point(pt) => {
                    let (sx, sy) = snap_to_grid(pt.x, pt.y, prec);
                    let snapped = Coordinate::new_2d(sx, sy);
                    // Deduplicate: skip if already recorded.
                    if !found.iter().any(|p| coords_equal_grid(p, &snapped, prec)) {
                        found.push(snapped);
                    }
                }
                SegmentIntersection::Overlap(c1, c2) => {
                    // For overlaps, record both endpoint-snaps.
                    for &raw in &[c1, c2] {
                        let (sx, sy) = snap_to_grid(raw.x, raw.y, prec);
                        let snapped = Coordinate::new_2d(sx, sy);
                        if !found.iter().any(|p| coords_equal_grid(p, &snapped, prec)) {
                            found.push(snapped);
                        }
                    }
                }
                SegmentIntersection::None => {}
            }
        }
    }
    found
}

/// For each point in `new_pts`, split every segment in `segments` that the
/// point lies on.  Returns the updated segment list.
fn insert_intersection_points(
    segments: Vec<WorkSegment>,
    new_pts: &[Coordinate],
    prec: f64,
) -> Vec<WorkSegment> {
    // Process one intersection point at a time, rebuilding the segment list.
    // This ensures that a split segment can itself be split by a later point.
    let mut current = segments;

    for pt in new_pts {
        let mut next: Vec<WorkSegment> = Vec::with_capacity(current.len() + 4);
        for seg in &current {
            // If `pt` is already an endpoint, no split needed.
            let at_start = coords_equal_grid(&seg.start, pt, prec);
            let at_end = coords_equal_grid(&seg.end, pt, prec);
            if at_start || at_end {
                next.push(seg.clone());
                continue;
            }
            // Check whether pt lies strictly inside the segment.
            if point_on_segment(pt, &seg.start, &seg.end, prec) {
                let (left, right) = split_segment_at(seg, pt);
                // Discard degenerate halves.
                if !coords_equal_grid(&left.start, &left.end, prec) {
                    next.push(left);
                }
                if !coords_equal_grid(&right.start, &right.end, prec) {
                    next.push(right);
                }
            } else {
                next.push(seg.clone());
            }
        }
        current = next;
    }

    current
}

// ---------------------------------------------------------------------------
// Tests (unit)
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // Helper: build a Coordinate from (x, y)
    fn c(x: f64, y: f64) -> Coordinate {
        Coordinate::new_2d(x, y)
    }

    #[test]
    fn test_snap_coordinate_basic() {
        let coord = c(1.2345678, -9.8765432);
        let snapped = snap_coordinate(&coord, 1e-6);
        // Should be rounded to 6 decimal places
        assert!((snapped.x - 1.234568_f64).abs() < 1e-12);
        assert!((snapped.y - -9.876543_f64).abs() < 1e-12);
    }

    #[test]
    fn test_snap_linestring_no_duplicates() {
        // Two coords that collapse to the same grid point.
        let coords = vec![c(0.0, 0.0), c(1e-9, 1e-9), c(1.0, 0.0)];
        let result = snap_linestring(&coords, 1e-6);
        // The first two snap to (0, 0); only one survives.
        assert_eq!(result.len(), 2);
        assert!((result[0].x).abs() < 1e-12);
        assert!((result[1].x - 1.0).abs() < 1e-12);
    }

    #[test]
    fn test_snap_round_invalid_precision() {
        let lines: Vec<Vec<Coordinate>> = vec![vec![c(0.0, 0.0), c(1.0, 1.0)]];
        let opts = SnapRoundingOptions {
            precision: -1.0,
            max_iterations: 4,
        };
        assert!(snap_round(&lines, &opts).is_err());

        let opts_zero = SnapRoundingOptions {
            precision: 0.0,
            max_iterations: 4,
        };
        assert!(snap_round(&lines, &opts_zero).is_err());
    }

    #[test]
    fn test_point_on_segment_midpoint() {
        let a = c(0.0, 0.0);
        let b = c(10.0, 0.0);
        let p = c(5.0, 0.0);
        assert!(point_on_segment(&p, &a, &b, 1e-6));
    }

    #[test]
    fn test_point_on_segment_off_line() {
        let a = c(0.0, 0.0);
        let b = c(10.0, 0.0);
        let p = c(5.0, 1.0); // above the segment
        assert!(!point_on_segment(&p, &a, &b, 1e-6));
    }
}