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 ¤t {
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}