Skip to main content

rust_igraph/algorithms/spatial/
delaunay.rs

1//! Delaunay triangulation graph of a spatial point set (ALGO-GEO-009).
2//!
3//! Pure-Rust counterpart of `igraph_delaunay_graph()` from
4//! `references/igraph/src/spatial/delaunay.c`. The C version delegates to
5//! Qhull for ≥2D; this port hand-rolls the 1D case (sorted adjacency) and
6//! the 2D case (Bowyer-Watson incremental insertion) to stay dependency-free.
7//! Dimensionality ≥3 is not currently supported and returns an error.
8//!
9//! The Delaunay graph of a point set connects two points with an edge
10//! whenever they share a face in the Delaunay triangulation: no other point
11//! lies inside the circumscribed circle of any triangle.
12//!
13//! Reference:
14//! Bowyer, A. "Computing Dirichlet Tessellations." *Computer Journal*
15//! 24.2 (1981): 162–166.
16
17use crate::core::{Graph, IgraphError, IgraphResult};
18
19/// Build the Delaunay triangulation graph of a 1D or 2D point set.
20///
21/// `points` holds one row per point: each inner `Vec<f64>` is a coordinate
22/// vector of shared dimensionality (inferred from the first row). The
23/// result is an undirected [`Graph`] on `points.len()` vertices whose
24/// edges are exactly the Delaunay-adjacent pairs.
25///
26/// Duplicate points (identical coordinates) are an error — the Delaunay
27/// triangulation is undefined for duplicate sites.
28///
29/// # Supported dimensions
30///
31/// - **1D**: Points are sorted by coordinate; consecutive points in the
32///   sorted order are connected. `O(n log n)`.
33/// - **2D**: Bowyer-Watson incremental insertion. `O(n²)` average,
34///   `O(n²)` worst case for pathological inputs.
35/// - **≥3D**: Currently unsupported; returns
36///   [`IgraphError::InvalidArgument`].
37///
38/// # Errors
39///
40/// - [`IgraphError::InvalidArgument`] if the points are zero-dimensional
41///   (and there is at least one point), if the rows have inconsistent
42///   dimensionality, if any coordinate is NaN or infinite, or if there
43///   are duplicate points.
44/// - [`IgraphError::InvalidArgument`] for dimensionality ≥ 3 (not yet
45///   supported).
46///
47/// # Examples
48///
49/// ```
50/// use rust_igraph::delaunay_graph;
51///
52/// // Three points forming a triangle: all three pairs are connected.
53/// let pts = vec![
54///     vec![0.0, 0.0],
55///     vec![1.0, 0.0],
56///     vec![0.5, 1.0],
57/// ];
58/// let g = delaunay_graph(&pts).unwrap();
59/// assert_eq!(g.vcount(), 3);
60/// assert_eq!(g.ecount(), 3);
61///
62/// // 1D: four points on a line.
63/// let pts1d = vec![vec![3.0], vec![1.0], vec![4.0], vec![2.0]];
64/// let g1d = delaunay_graph(&pts1d).unwrap();
65/// assert_eq!(g1d.vcount(), 4);
66/// assert_eq!(g1d.ecount(), 3); // 1-2, 2-3, 3-4 in sorted order
67/// ```
68pub fn delaunay_graph(points: &[Vec<f64>]) -> IgraphResult<Graph> {
69    let n = points.len();
70    if n == 0 {
71        return Ok(Graph::with_vertices(0));
72    }
73
74    let dim = points[0].len();
75    if dim == 0 {
76        return Err(IgraphError::InvalidArgument(
77            "delaunay_graph: points must not be zero-dimensional".into(),
78        ));
79    }
80    for (i, row) in points.iter().enumerate() {
81        if row.len() != dim {
82            return Err(IgraphError::InvalidArgument(format!(
83                "delaunay_graph: point row {i} has dimension {} but expected {dim}",
84                row.len()
85            )));
86        }
87        for (j, &c) in row.iter().enumerate() {
88            if c.is_nan() || c.is_infinite() {
89                return Err(IgraphError::InvalidArgument(format!(
90                    "delaunay_graph: coordinate [{i}][{j}] is not finite"
91                )));
92            }
93        }
94    }
95
96    if n == 1 {
97        return Ok(Graph::with_vertices(1));
98    }
99
100    match dim {
101        1 => delaunay_1d(points),
102        2 => {
103            if n == 2 {
104                let n_u32 = u32::try_from(n).map_err(|_| {
105                    IgraphError::InvalidArgument("delaunay_graph: too many points".into())
106                })?;
107                let mut g = Graph::with_vertices(n_u32);
108                g.add_edge(0, 1)?;
109                return Ok(g);
110            }
111            delaunay_2d(points)
112        }
113        _ => Err(IgraphError::InvalidArgument(format!(
114            "delaunay_graph: {dim}D not supported (only 1D and 2D)"
115        ))),
116    }
117}
118
119fn delaunay_1d(points: &[Vec<f64>]) -> IgraphResult<Graph> {
120    let n = points.len();
121    let mut order: Vec<usize> = (0..n).collect();
122    order.sort_by(|&a, &b| {
123        points[a][0]
124            .partial_cmp(&points[b][0])
125            .unwrap_or(std::cmp::Ordering::Equal)
126    });
127
128    let n_u32 = u32::try_from(n)
129        .map_err(|_| IgraphError::InvalidArgument("delaunay_graph: too many points".into()))?;
130    let mut g = Graph::with_vertices(n_u32);
131
132    for w in order.windows(2) {
133        let (a, b) = (w[0], w[1]);
134        if (points[a][0] - points[b][0]).abs() < f64::EPSILON * 128.0 {
135            return Err(IgraphError::InvalidArgument(
136                "delaunay_graph: duplicate points".into(),
137            ));
138        }
139        #[allow(clippy::cast_possible_truncation)]
140        g.add_edge(a as u32, b as u32)?;
141    }
142    Ok(g)
143}
144
145fn is_collinear(points: &[Vec<f64>]) -> bool {
146    if points.len() < 3 {
147        return true;
148    }
149    let (x0, y0) = (points[0][0], points[0][1]);
150    let (x1, y1) = (points[1][0], points[1][1]);
151    let (dx, dy) = (x1 - x0, y1 - y0);
152    for p in points.iter().skip(2) {
153        let cross = dx * (p[1] - y0) - dy * (p[0] - x0);
154        if cross.abs() > 1e-10 {
155            return false;
156        }
157    }
158    true
159}
160
161fn delaunay_collinear(points: &[Vec<f64>]) -> IgraphResult<Graph> {
162    let n = points.len();
163    let n_u32 = u32::try_from(n)
164        .map_err(|_| IgraphError::InvalidArgument("delaunay_graph: too many points".into()))?;
165
166    // Project onto the dominant axis and sort
167    let (x0, y0) = (points[0][0], points[0][1]);
168    let (x1, y1) = (points[1][0], points[1][1]);
169    let (dx, dy) = (x1 - x0, y1 - y0);
170    let len = (dx * dx + dy * dy).sqrt();
171    let (ux, uy) = if len > 1e-15 {
172        (dx / len, dy / len)
173    } else {
174        (1.0, 0.0)
175    };
176
177    let mut order: Vec<usize> = (0..n).collect();
178    order.sort_by(|&a, &b| {
179        let pa = ux * points[a][0] + uy * points[a][1];
180        let pb = ux * points[b][0] + uy * points[b][1];
181        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
182    });
183
184    let mut g = Graph::with_vertices(n_u32);
185    for w in order.windows(2) {
186        #[allow(clippy::cast_possible_truncation)]
187        g.add_edge(w[0] as u32, w[1] as u32)?;
188    }
189    Ok(g)
190}
191
192// --- Bowyer-Watson 2D Delaunay ---
193
194#[derive(Clone, Copy)]
195struct Pt {
196    x: f64,
197    y: f64,
198}
199
200#[derive(Clone, Copy)]
201struct Triangle {
202    v: [usize; 3],
203    alive: bool,
204}
205
206struct Circumcircle {
207    cx: f64,
208    cy: f64,
209    r2: f64,
210}
211
212fn circumcircle(a: Pt, b: Pt, c: Pt) -> Circumcircle {
213    let ax = a.x - c.x;
214    let ay = a.y - c.y;
215    let bx = b.x - c.x;
216    let by = b.y - c.y;
217    let d = 2.0 * (ax * by - ay * bx);
218    if d.abs() < 1e-30 {
219        return Circumcircle {
220            cx: f64::INFINITY,
221            cy: f64::INFINITY,
222            r2: f64::INFINITY,
223        };
224    }
225    let a2 = ax * ax + ay * ay;
226    let b2 = bx * bx + by * by;
227    let ux = (a2 * by - b2 * ay) / d;
228    let uy = (ax * b2 - bx * a2) / d;
229    Circumcircle {
230        cx: ux + c.x,
231        cy: uy + c.y,
232        r2: ux * ux + uy * uy,
233    }
234}
235
236fn in_circumcircle(cc: &Circumcircle, p: Pt) -> bool {
237    if cc.r2.is_infinite() {
238        return false;
239    }
240    let dx = p.x - cc.cx;
241    let dy = p.y - cc.cy;
242    dx * dx + dy * dy < cc.r2
243}
244
245fn check_duplicate_2d(points: &[Vec<f64>]) -> IgraphResult<()> {
246    let n = points.len();
247    let mut sorted_indices: Vec<usize> = (0..n).collect();
248    sorted_indices.sort_by(|&a, &b| {
249        points[a][0]
250            .partial_cmp(&points[b][0])
251            .unwrap_or(std::cmp::Ordering::Equal)
252            .then_with(|| {
253                points[a][1]
254                    .partial_cmp(&points[b][1])
255                    .unwrap_or(std::cmp::Ordering::Equal)
256            })
257    });
258    for w in sorted_indices.windows(2) {
259        let (a, b) = (w[0], w[1]);
260        if (points[a][0] - points[b][0]).abs() < 1e-12
261            && (points[a][1] - points[b][1]).abs() < 1e-12
262        {
263            return Err(IgraphError::InvalidArgument(
264                "delaunay_graph: duplicate points".into(),
265            ));
266        }
267    }
268    Ok(())
269}
270
271#[allow(unknown_lints, clippy::manual_midpoint)]
272fn super_triangle_setup(pts: &[Pt], n: usize) -> (Vec<Pt>, Vec<Triangle>, Vec<Circumcircle>) {
273    let mut lo_x = f64::INFINITY;
274    let mut hi_x = f64::NEG_INFINITY;
275    let mut lo_y = f64::INFINITY;
276    let mut hi_y = f64::NEG_INFINITY;
277    for p in pts {
278        lo_x = lo_x.min(p.x);
279        hi_x = hi_x.max(p.x);
280        lo_y = lo_y.min(p.y);
281        hi_y = hi_y.max(p.y);
282    }
283    let cx = (lo_x + hi_x) / 2.0;
284    let cy = (lo_y + hi_y) / 2.0;
285
286    let big_r = if n <= 20 {
287        let mut max_r = 1.0_f64;
288        for i in 0..n {
289            for j in (i + 1)..n {
290                for k in (j + 1)..n {
291                    let cc = circumcircle(pts[i], pts[j], pts[k]);
292                    if !cc.r2.is_infinite() {
293                        max_r = max_r.max(cc.r2.sqrt());
294                    }
295                }
296            }
297        }
298        3.0 * max_r
299    } else {
300        let mut max_d2 = 1.0_f64;
301        for i in 0..n {
302            for j in (i + 1)..n {
303                let dx = pts[i].x - pts[j].x;
304                let dy = pts[i].y - pts[j].y;
305                max_d2 = max_d2.max(dx * dx + dy * dy);
306            }
307        }
308        100.0 * max_d2.sqrt()
309    };
310
311    let sqrt3 = 3.0_f64.sqrt();
312    let sp = [
313        Pt {
314            x: cx - big_r * sqrt3,
315            y: cy - big_r,
316        },
317        Pt {
318            x: cx + big_r * sqrt3,
319            y: cy - big_r,
320        },
321        Pt {
322            x: cx,
323            y: cy + 2.0 * big_r,
324        },
325    ];
326    let mut all_pts: Vec<Pt> = pts.to_vec();
327    all_pts.extend_from_slice(&sp);
328    let tri = Triangle {
329        v: [n, n + 1, n + 2],
330        alive: true,
331    };
332    let cc = circumcircle(sp[0], sp[1], sp[2]);
333    (all_pts, vec![tri], vec![cc])
334}
335
336fn bowyer_watson_insert(
337    i: usize,
338    all_pts: &[Pt],
339    triangles: &mut Vec<Triangle>,
340    circles: &mut Vec<Circumcircle>,
341) {
342    let p = all_pts[i];
343    let mut bad = Vec::new();
344    for (ti, tri) in triangles.iter().enumerate() {
345        if tri.alive && in_circumcircle(&circles[ti], p) {
346            bad.push(ti);
347        }
348    }
349
350    let mut boundary: Vec<[usize; 2]> = Vec::new();
351    for &ti in &bad {
352        let tri = &triangles[ti];
353        for edge_idx in 0..3 {
354            let e = [tri.v[edge_idx], tri.v[(edge_idx + 1) % 3]];
355            let shared = bad
356                .iter()
357                .any(|&oti| oti != ti && edge_in_triangle(e, &triangles[oti]));
358            if !shared {
359                boundary.push(e);
360            }
361        }
362    }
363
364    for &ti in &bad {
365        triangles[ti].alive = false;
366    }
367
368    for e in &boundary {
369        let new_tri = Triangle {
370            v: [i, e[0], e[1]],
371            alive: true,
372        };
373        let cc = circumcircle(all_pts[i], all_pts[e[0]], all_pts[e[1]]);
374        triangles.push(new_tri);
375        circles.push(cc);
376    }
377}
378
379fn collect_delaunay_edges(triangles: &[Triangle], n: usize) -> IgraphResult<Graph> {
380    let mut edge_set = std::collections::BTreeSet::new();
381    for tri in triangles {
382        if !tri.alive || tri.v[0] >= n || tri.v[1] >= n || tri.v[2] >= n {
383            continue;
384        }
385        for i in 0..3 {
386            let (a, b) = (tri.v[i], tri.v[(i + 1) % 3]);
387            let edge = if a < b { (a, b) } else { (b, a) };
388            edge_set.insert(edge);
389        }
390    }
391
392    let n_u32 = u32::try_from(n)
393        .map_err(|_| IgraphError::InvalidArgument("delaunay_graph: too many points".into()))?;
394    let mut g = Graph::with_vertices(n_u32);
395    for (a, b) in &edge_set {
396        #[allow(clippy::cast_possible_truncation)]
397        g.add_edge(*a as u32, *b as u32)?;
398    }
399    Ok(g)
400}
401
402fn delaunay_2d(points: &[Vec<f64>]) -> IgraphResult<Graph> {
403    let n = points.len();
404    check_duplicate_2d(points)?;
405
406    if is_collinear(points) {
407        return delaunay_collinear(points);
408    }
409
410    let pts: Vec<Pt> = points.iter().map(|p| Pt { x: p[0], y: p[1] }).collect();
411    let (all_pts, mut triangles, mut circles) = super_triangle_setup(&pts, n);
412
413    for i in 0..n {
414        bowyer_watson_insert(i, &all_pts, &mut triangles, &mut circles);
415    }
416
417    collect_delaunay_edges(&triangles, n)
418}
419
420fn edge_in_triangle(e: [usize; 2], tri: &Triangle) -> bool {
421    for i in 0..3 {
422        let te = [tri.v[i], tri.v[(i + 1) % 3]];
423        if (te[0] == e[0] && te[1] == e[1]) || (te[0] == e[1] && te[1] == e[0]) {
424            return true;
425        }
426    }
427    false
428}
429
430#[cfg(test)]
431#[allow(clippy::float_cmp)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn empty_points() {
437        let g = delaunay_graph(&[]).unwrap();
438        assert_eq!(g.vcount(), 0);
439        assert_eq!(g.ecount(), 0);
440    }
441
442    #[test]
443    fn single_point() {
444        let g = delaunay_graph(&[vec![1.0, 2.0]]).unwrap();
445        assert_eq!(g.vcount(), 1);
446        assert_eq!(g.ecount(), 0);
447    }
448
449    #[test]
450    fn two_points_2d() {
451        let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0]];
452        let g = delaunay_graph(&pts).unwrap();
453        assert_eq!(g.vcount(), 2);
454        assert_eq!(g.ecount(), 1);
455    }
456
457    #[test]
458    fn triangle_2d() {
459        let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![0.5, 1.0]];
460        let g = delaunay_graph(&pts).unwrap();
461        assert_eq!(g.vcount(), 3);
462        assert_eq!(g.ecount(), 3);
463    }
464
465    #[test]
466    fn square_2d_has_five_edges() {
467        // A unit square: the Delaunay triangulation has 4 outer edges + 1
468        // diagonal = 5 edges (two triangles). Which diagonal is chosen is
469        // implementation-defined but there must be exactly one.
470        let pts = vec![
471            vec![0.0, 0.0],
472            vec![1.0, 0.0],
473            vec![1.0, 1.0],
474            vec![0.0, 1.0],
475        ];
476        let g = delaunay_graph(&pts).unwrap();
477        assert_eq!(g.vcount(), 4);
478        assert_eq!(g.ecount(), 5);
479    }
480
481    #[test]
482    fn regular_pentagon_2d() {
483        let pts: Vec<Vec<f64>> = (0..5)
484            .map(|i| {
485                let angle = 2.0 * std::f64::consts::PI * f64::from(i) / 5.0;
486                vec![angle.cos(), angle.sin()]
487            })
488            .collect();
489        let g = delaunay_graph(&pts).unwrap();
490        assert_eq!(g.vcount(), 5);
491        // Convex pentagon: 5 outer edges + 2 interior diagonals = 7
492        // (3 triangles, each with 3 edges, 2 shared internally: 3*3-2*2=5? No:
493        // actually 3 triangles = 9 half-edges, 4 shared = 5+2=7 unique edges)
494        assert_eq!(g.ecount(), 7);
495    }
496
497    #[test]
498    fn line_1d() {
499        let pts = vec![vec![3.0], vec![1.0], vec![4.0], vec![2.0]];
500        let g = delaunay_graph(&pts).unwrap();
501        assert_eq!(g.vcount(), 4);
502        assert_eq!(g.ecount(), 3);
503        // Sorted order: 1(=1.0), 3(=2.0), 0(=3.0), 2(=4.0)
504        // Edges: (1,3), (0,3), (0,2)
505        assert!(g.get_eid(1, 3).is_ok());
506        assert!(g.get_eid(0, 3).is_ok() || g.get_eid(3, 0).is_ok());
507        assert!(g.get_eid(0, 2).is_ok() || g.get_eid(2, 0).is_ok());
508    }
509
510    #[test]
511    fn single_point_1d() {
512        let g = delaunay_graph(&[vec![5.0]]).unwrap();
513        assert_eq!(g.vcount(), 1);
514        assert_eq!(g.ecount(), 0);
515    }
516
517    #[test]
518    fn two_points_1d() {
519        let g = delaunay_graph(&[vec![2.0], vec![7.0]]).unwrap();
520        assert_eq!(g.vcount(), 2);
521        assert_eq!(g.ecount(), 1);
522    }
523
524    #[test]
525    fn duplicate_points_1d_error() {
526        let pts = vec![vec![1.0], vec![2.0], vec![1.0]];
527        assert!(delaunay_graph(&pts).is_err());
528    }
529
530    #[test]
531    fn duplicate_points_2d_error() {
532        let pts = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![1.0, 2.0]];
533        assert!(delaunay_graph(&pts).is_err());
534    }
535
536    #[test]
537    fn zero_dimensional_error() {
538        let pts: Vec<Vec<f64>> = vec![vec![]];
539        assert!(delaunay_graph(&pts).is_err());
540    }
541
542    #[test]
543    fn nan_coordinate_error() {
544        let pts = vec![vec![0.0, f64::NAN]];
545        assert!(delaunay_graph(&pts).is_err());
546    }
547
548    #[test]
549    fn infinite_coordinate_error() {
550        let pts = vec![vec![f64::INFINITY, 0.0]];
551        assert!(delaunay_graph(&pts).is_err());
552    }
553
554    #[test]
555    fn three_d_unsupported() {
556        let pts = vec![vec![0.0, 0.0, 0.0], vec![1.0, 0.0, 0.0]];
557        assert!(delaunay_graph(&pts).is_err());
558    }
559
560    #[test]
561    fn collinear_2d() {
562        // Three collinear points in 2D: the Delaunay triangulation
563        // reduces to a path (2 edges), same as the 1D case projected.
564        let pts = vec![vec![0.0, 0.0], vec![1.0, 1.0], vec![2.0, 2.0]];
565        let g = delaunay_graph(&pts).unwrap();
566        assert_eq!(g.vcount(), 3);
567        assert_eq!(g.ecount(), 2);
568    }
569
570    #[test]
571    fn grid_3x3() {
572        // 3x3 grid of points (9 points), Delaunay should produce
573        // connected triangulation with euler: V - E + F = 2 for planar
574        // 9 vertices, convex hull has 8 edges on boundary
575        // F_inner = E - V + 1 for connected planar = E - 9 + 2 = E - 7
576        // Each inner face is a triangle (3 edges, each shared by 2 faces)
577        // plus outer face. For 9 points in grid: expect 16 edges.
578        let mut pts = Vec::new();
579        for i in 0..3 {
580            for j in 0..3 {
581                pts.push(vec![f64::from(i), f64::from(j)]);
582            }
583        }
584        let g = delaunay_graph(&pts).unwrap();
585        assert_eq!(g.vcount(), 9);
586        // A regular 3x3 grid has 12 grid edges + 4 diagonals = 16 in
587        // Delaunay. (Each of 4 interior squares gets one diagonal.)
588        assert_eq!(g.ecount(), 16);
589    }
590
591    #[test]
592    fn cocircular_points() {
593        // Four co-circular points: one diagonal must be chosen, giving
594        // exactly 5 edges. This tests the epsilon handling.
595        let pts = vec![
596            vec![0.0, 0.0],
597            vec![1.0, 0.0],
598            vec![1.0, 1.0],
599            vec![0.0, 1.0],
600        ];
601        let g = delaunay_graph(&pts).unwrap();
602        assert_eq!(g.vcount(), 4);
603        assert_eq!(g.ecount(), 5);
604    }
605}
606
607#[cfg(all(test, feature = "proptest-harness"))]
608mod proptests {
609    use super::*;
610    use proptest::prelude::*;
611
612    fn arb_points_2d(max_n: usize) -> impl Strategy<Value = Vec<Vec<f64>>> {
613        (3..=max_n).prop_flat_map(|n| {
614            proptest::collection::vec(proptest::collection::vec(-100.0_f64..100.0, 2..=2), n..=n)
615        })
616    }
617
618    proptest! {
619        /// Euler's formula for connected planar graphs: V - E + F = 2.
620        /// For a Delaunay triangulation (convex hull = outer face), the
621        /// number of triangles T satisfies E = V + T - 1 (when
622        /// connected). We just check that the result is a valid graph
623        /// with reasonable edge count.
624        #[test]
625        fn edge_count_in_planar_range(pts in arb_points_2d(8)) {
626            // Skip if there are near-duplicates
627            let mut sorted: Vec<(usize, &Vec<f64>)> = pts.iter().enumerate().collect();
628            sorted.sort_by(|(_, a), (_, b)| {
629                a[0].partial_cmp(&b[0]).unwrap()
630                    .then_with(|| a[1].partial_cmp(&b[1]).unwrap())
631            });
632            for w in sorted.windows(2) {
633                let (_, a) = w[0];
634                let (_, b) = w[1];
635                if (a[0] - b[0]).abs() < 1e-10 && (a[1] - b[1]).abs() < 1e-10 {
636                    return Ok(());
637                }
638            }
639
640            let g = delaunay_graph(&pts).unwrap();
641            let n = pts.len();
642            prop_assert_eq!(g.vcount() as usize, n);
643            // Planar graph: E <= 3V - 6 for V >= 3
644            if n >= 3 {
645                prop_assert!(g.ecount() as usize <= 3 * n - 6,
646                    "too many edges: {} > {}", g.ecount(), 3 * n - 6);
647            }
648            // At least V - 1 edges (connected spanning tree)
649            prop_assert!(g.ecount() as usize >= n - 1,
650                "too few edges: {} < {}", g.ecount(), n - 1);
651        }
652    }
653}