Skip to main content

oxigdal_algorithms/vector/
snap_rounding.rs

1//! Hobby snap rounding for robust geometric operations.
2//!
3//! Snap rounding is a technique to achieve robustness in computational geometry
4//! by snapping all coordinates (and intersection points) to a regular grid.
5//! This eliminates floating-point inconsistencies and ensures topological
6//! validity of noded segment arrangements.
7//!
8//! # Algorithm
9//!
10//! The iterative Hobby snap rounding algorithm proceeds as:
11//! 1. Snap all input coordinates to the precision grid.
12//! 2. Collect all pairwise segment-segment intersections (cross-line).
13//! 3. Snap each intersection to the grid.
14//! 4. Split every segment that an intersection point lies on.
15//! 5. Repeat steps 2-4 until convergence or `max_iterations` is reached.
16//! 6. Remove zero-length segments.
17//!
18//! # References
19//!
20//! - J. Hobby, "Practical segment intersection with finite precision output",
21//!   *Computational Geometry*, 13(4):199-214, 1999.
22
23use crate::error::{AlgorithmError, Result};
24use oxigdal_core::vector::Coordinate;
25
26/// Options controlling snap rounding behaviour.
27///
28/// All output coordinates produced by [`snap_round`] are exact multiples of
29/// `precision` on both axes.
30#[derive(Debug, Clone)]
31pub struct SnapRoundingOptions {
32    /// Grid cell size (epsilon).
33    ///
34    /// All output coordinates are multiples of this value.
35    /// Must be strictly positive. Typical values:
36    /// - `1e-6` for geographic (degree) coordinates
37    /// - `0.01` for centimetre-precision projected coordinates
38    /// - `1.0` for integer grid coordinates
39    pub precision: f64,
40
41    /// Maximum propagation iterations (default `8`).
42    ///
43    /// Each pass may introduce new intersection points that themselves need
44    /// snapping and may lie on other segments. The algorithm terminates when
45    /// no new points are found or this limit is reached.
46    pub max_iterations: usize,
47}
48
49impl Default for SnapRoundingOptions {
50    fn default() -> Self {
51        Self {
52            precision: 1e-6,
53            max_iterations: 8,
54        }
55    }
56}
57
58/// A single output segment after snap rounding.
59///
60/// Both endpoints are guaranteed to lie on the precision grid.
61#[derive(Debug, Clone, PartialEq)]
62pub struct SnappedSegment {
63    /// Start vertex (on grid).
64    pub start: Coordinate,
65    /// End vertex (on grid).
66    pub end: Coordinate,
67    /// Index of the source polyline this segment was derived from.
68    pub source_line: usize,
69}
70
71/// Result returned by [`snap_round`].
72#[derive(Debug, Clone)]
73pub struct SnapRoundingResult {
74    /// All output segments — noded and snapped to the precision grid.
75    pub segments: Vec<SnappedSegment>,
76    /// Total number of unique intersection points inserted during propagation.
77    pub intersections_added: usize,
78    /// Number of propagation iterations actually performed.
79    pub iterations: usize,
80}
81
82// ---------------------------------------------------------------------------
83// Internal working segment type
84// ---------------------------------------------------------------------------
85
86/// An internal mutable segment used during the snap-rounding computation.
87#[derive(Debug, Clone)]
88struct WorkSegment {
89    start: Coordinate,
90    end: Coordinate,
91    source_line: usize,
92}
93
94// ---------------------------------------------------------------------------
95// Low-level grid helpers
96// ---------------------------------------------------------------------------
97
98/// Snap (x, y) to the nearest multiple of `prec` on each axis.
99#[inline]
100fn snap_to_grid(x: f64, y: f64, prec: f64) -> (f64, f64) {
101    ((x / prec).round() * prec, (y / prec).round() * prec)
102}
103
104/// Return true when two coordinates coincide within half a grid cell.
105#[inline]
106fn coords_equal_grid(a: &Coordinate, b: &Coordinate, prec: f64) -> bool {
107    let half = prec * 0.5;
108    (a.x - b.x).abs() < half && (a.y - b.y).abs() < half
109}
110
111/// Test whether `p` lies on the open or closed segment `[a, b]` within the
112/// tolerance implied by `prec`.
113///
114/// The check uses:
115/// 1. The cross product (signed area) must be near zero — collinearity test.
116/// 2. The dot product must place `p` between `a` and `b` along the segment
117///    direction — endpoint inclusivity test.
118#[inline]
119fn point_on_segment(p: &Coordinate, a: &Coordinate, b: &Coordinate, prec: f64) -> bool {
120    // Cross product of (b-a) × (p-a).  For p on the line this is 0.
121    let cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
122    // Tolerance for cross product: we allow up to `prec` absolute deviation.
123    if cross.abs() > prec {
124        return false;
125    }
126    // Dot product of (p-a) · (b-a) must be in [0, |b-a|²].
127    let dot = (p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y);
128    let len2 = (b.x - a.x).powi(2) + (b.y - a.y).powi(2);
129    dot >= -prec && dot <= len2 + prec
130}
131
132/// Split a [`WorkSegment`] at point `p`, returning the two halves.
133#[inline]
134fn split_segment_at(seg: &WorkSegment, p: &Coordinate) -> (WorkSegment, WorkSegment) {
135    (
136        WorkSegment {
137            start: seg.start,
138            end: *p,
139            source_line: seg.source_line,
140        },
141        WorkSegment {
142            start: *p,
143            end: seg.end,
144            source_line: seg.source_line,
145        },
146    )
147}
148
149// ---------------------------------------------------------------------------
150// Public API — coordinate-level helpers
151// ---------------------------------------------------------------------------
152
153/// Snap a single coordinate to the precision grid.
154///
155/// Each axis is independently rounded to the nearest multiple of `precision`.
156///
157/// # Panics
158///
159/// Does not panic. Returns `NaN` coordinates only if `precision` is zero or
160/// `NaN` (callers are expected to validate `precision > 0`).
161///
162/// # Examples
163///
164/// ```
165/// use oxigdal_algorithms::vector::{snap_coordinate, Coordinate};
166///
167/// let c = Coordinate::new_2d(1.23456789, -0.99999999);
168/// let snapped = snap_coordinate(&c, 1e-6);
169/// assert!((snapped.x - 1.234568).abs() < 1e-12);
170/// ```
171#[must_use]
172pub fn snap_coordinate(c: &Coordinate, precision: f64) -> Coordinate {
173    let (sx, sy) = snap_to_grid(c.x, c.y, precision);
174    Coordinate::new_2d(sx, sy)
175}
176
177/// Snap all coordinates in a slice to the precision grid, removing consecutive
178/// duplicates that arise after snapping.
179///
180/// Duplicate detection uses an exact equality check on the snapped (already
181/// grid-aligned) values.
182///
183/// # Examples
184///
185/// ```
186/// use oxigdal_algorithms::vector::{snap_linestring, Coordinate};
187///
188/// let coords = vec![
189///     Coordinate::new_2d(0.0, 0.0),
190///     Coordinate::new_2d(0.0000001, 0.0000001),  // collapses to same as first
191///     Coordinate::new_2d(1.0, 0.0),
192/// ];
193/// let snapped = snap_linestring(&coords, 1e-6);
194/// assert_eq!(snapped.len(), 2);
195/// ```
196#[must_use]
197pub fn snap_linestring(coords: &[Coordinate], precision: f64) -> Vec<Coordinate> {
198    let mut result: Vec<Coordinate> = Vec::with_capacity(coords.len());
199    for c in coords {
200        let s = snap_coordinate(c, precision);
201        // Remove consecutive duplicates (exact equality after grid snapping).
202        if result.last().is_none_or(|prev| {
203            (prev.x - s.x).abs() > f64::EPSILON || (prev.y - s.y).abs() > f64::EPSILON
204        }) {
205            result.push(s);
206        }
207    }
208    result
209}
210
211// ---------------------------------------------------------------------------
212// Core snap-rounding algorithm
213// ---------------------------------------------------------------------------
214
215/// Perform iterative Hobby snap rounding on a collection of polylines.
216///
217/// Each element of `lines` is a polyline given as a `Vec<Coordinate>`.
218/// The algorithm nodes the arrangement — every pair of segments from
219/// *different* source lines is checked for intersection — and guarantees
220/// that all vertices in the output lie on the precision grid.
221///
222/// # Errors
223///
224/// Returns [`AlgorithmError::InvalidParameter`] if `options.precision` is not
225/// strictly positive.
226///
227/// # Complexity
228///
229/// The brute-force intersection search is O(n²) per iteration where *n* is
230/// the total segment count.  This is appropriate for typical geospatial inputs
231/// with up to tens of thousands of segments.
232pub fn snap_round(
233    lines: &[Vec<Coordinate>],
234    options: &SnapRoundingOptions,
235) -> Result<SnapRoundingResult> {
236    if options.precision <= 0.0 || options.precision.is_nan() {
237        return Err(AlgorithmError::InvalidParameter {
238            parameter: "precision",
239            message: format!(
240                "precision must be strictly positive, got {}",
241                options.precision
242            ),
243        });
244    }
245    let prec = options.precision;
246
247    // ------------------------------------------------------------------
248    // Step 1: Initial snapping — snap every vertex to the grid.
249    // ------------------------------------------------------------------
250    let mut segments: Vec<WorkSegment> = Vec::new();
251    for (line_idx, line) in lines.iter().enumerate() {
252        let snapped = snap_linestring(line, prec);
253        // Collect pairwise segments from consecutive snapped vertices.
254        for pair in snapped.windows(2) {
255            let start = pair[0];
256            let end = pair[1];
257            // Skip zero-length segments that emerged from initial snapping.
258            if coords_equal_grid(&start, &end, prec) {
259                continue;
260            }
261            segments.push(WorkSegment {
262                start,
263                end,
264                source_line: line_idx,
265            });
266        }
267    }
268
269    // ------------------------------------------------------------------
270    // Steps 2-5: Iterative intersection propagation.
271    // ------------------------------------------------------------------
272    let mut total_intersections_added: usize = 0;
273    let mut iterations_done: usize = 0;
274
275    for _iter in 0..options.max_iterations {
276        let new_pts = collect_cross_intersections(&segments, prec);
277        if new_pts.is_empty() {
278            break;
279        }
280        total_intersections_added += new_pts.len();
281        segments = insert_intersection_points(segments, &new_pts, prec);
282        iterations_done += 1;
283    }
284
285    // ------------------------------------------------------------------
286    // Step 6: Remove zero-length segments from the final list.
287    // ------------------------------------------------------------------
288    let output: Vec<SnappedSegment> = segments
289        .into_iter()
290        .filter(|seg| !coords_equal_grid(&seg.start, &seg.end, prec))
291        .map(|seg| SnappedSegment {
292            start: seg.start,
293            end: seg.end,
294            source_line: seg.source_line,
295        })
296        .collect();
297
298    Ok(SnapRoundingResult {
299        segments: output,
300        intersections_added: total_intersections_added,
301        iterations: iterations_done,
302    })
303}
304
305// ---------------------------------------------------------------------------
306// Internal helpers for the iterative algorithm
307// ---------------------------------------------------------------------------
308
309/// Collect all unique pairwise intersection points between segments that belong
310/// to *different* source lines.
311///
312/// Each intersection is snapped to the grid before deduplication.
313fn collect_cross_intersections(segments: &[WorkSegment], prec: f64) -> Vec<Coordinate> {
314    use crate::vector::intersection::{SegmentIntersection, intersect_segment_segment};
315
316    let mut found: Vec<Coordinate> = Vec::new();
317
318    let n = segments.len();
319    for i in 0..n {
320        for j in (i + 1)..n {
321            let a = &segments[i];
322            let b = &segments[j];
323            // Only consider pairs from different source lines.
324            if a.source_line == b.source_line {
325                continue;
326            }
327            match intersect_segment_segment(&a.start, &a.end, &b.start, &b.end) {
328                SegmentIntersection::Point(pt) => {
329                    let (sx, sy) = snap_to_grid(pt.x, pt.y, prec);
330                    let snapped = Coordinate::new_2d(sx, sy);
331                    // Deduplicate: skip if already recorded.
332                    if !found.iter().any(|p| coords_equal_grid(p, &snapped, prec)) {
333                        found.push(snapped);
334                    }
335                }
336                SegmentIntersection::Overlap(c1, c2) => {
337                    // For overlaps, record both endpoint-snaps.
338                    for &raw in &[c1, c2] {
339                        let (sx, sy) = snap_to_grid(raw.x, raw.y, prec);
340                        let snapped = Coordinate::new_2d(sx, sy);
341                        if !found.iter().any(|p| coords_equal_grid(p, &snapped, prec)) {
342                            found.push(snapped);
343                        }
344                    }
345                }
346                SegmentIntersection::None => {}
347            }
348        }
349    }
350    found
351}
352
353/// For each point in `new_pts`, split every segment in `segments` that the
354/// point lies on.  Returns the updated segment list.
355fn insert_intersection_points(
356    segments: Vec<WorkSegment>,
357    new_pts: &[Coordinate],
358    prec: f64,
359) -> Vec<WorkSegment> {
360    // Process one intersection point at a time, rebuilding the segment list.
361    // This ensures that a split segment can itself be split by a later point.
362    let mut current = segments;
363
364    for pt in new_pts {
365        let mut next: Vec<WorkSegment> = Vec::with_capacity(current.len() + 4);
366        for seg in &current {
367            // If `pt` is already an endpoint, no split needed.
368            let at_start = coords_equal_grid(&seg.start, pt, prec);
369            let at_end = coords_equal_grid(&seg.end, pt, prec);
370            if at_start || at_end {
371                next.push(seg.clone());
372                continue;
373            }
374            // Check whether pt lies strictly inside the segment.
375            if point_on_segment(pt, &seg.start, &seg.end, prec) {
376                let (left, right) = split_segment_at(seg, pt);
377                // Discard degenerate halves.
378                if !coords_equal_grid(&left.start, &left.end, prec) {
379                    next.push(left);
380                }
381                if !coords_equal_grid(&right.start, &right.end, prec) {
382                    next.push(right);
383                }
384            } else {
385                next.push(seg.clone());
386            }
387        }
388        current = next;
389    }
390
391    current
392}
393
394// ---------------------------------------------------------------------------
395// Tests (unit)
396// ---------------------------------------------------------------------------
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    // Helper: build a Coordinate from (x, y)
403    fn c(x: f64, y: f64) -> Coordinate {
404        Coordinate::new_2d(x, y)
405    }
406
407    #[test]
408    fn test_snap_coordinate_basic() {
409        let coord = c(1.2345678, -9.8765432);
410        let snapped = snap_coordinate(&coord, 1e-6);
411        // Should be rounded to 6 decimal places
412        assert!((snapped.x - 1.234568_f64).abs() < 1e-12);
413        assert!((snapped.y - -9.876543_f64).abs() < 1e-12);
414    }
415
416    #[test]
417    fn test_snap_linestring_no_duplicates() {
418        // Two coords that collapse to the same grid point.
419        let coords = vec![c(0.0, 0.0), c(1e-9, 1e-9), c(1.0, 0.0)];
420        let result = snap_linestring(&coords, 1e-6);
421        // The first two snap to (0, 0); only one survives.
422        assert_eq!(result.len(), 2);
423        assert!((result[0].x).abs() < 1e-12);
424        assert!((result[1].x - 1.0).abs() < 1e-12);
425    }
426
427    #[test]
428    fn test_snap_round_invalid_precision() {
429        let lines: Vec<Vec<Coordinate>> = vec![vec![c(0.0, 0.0), c(1.0, 1.0)]];
430        let opts = SnapRoundingOptions {
431            precision: -1.0,
432            max_iterations: 4,
433        };
434        assert!(snap_round(&lines, &opts).is_err());
435
436        let opts_zero = SnapRoundingOptions {
437            precision: 0.0,
438            max_iterations: 4,
439        };
440        assert!(snap_round(&lines, &opts_zero).is_err());
441    }
442
443    #[test]
444    fn test_point_on_segment_midpoint() {
445        let a = c(0.0, 0.0);
446        let b = c(10.0, 0.0);
447        let p = c(5.0, 0.0);
448        assert!(point_on_segment(&p, &a, &b, 1e-6));
449    }
450
451    #[test]
452    fn test_point_on_segment_off_line() {
453        let a = c(0.0, 0.0);
454        let b = c(10.0, 0.0);
455        let p = c(5.0, 1.0); // above the segment
456        assert!(!point_on_segment(&p, &a, &b, 1e-6));
457    }
458}