Skip to main content

rust_igraph/algorithms/spatial/
gabriel_graph.rs

1//! Gabriel graph of a spatial point set (ALGO-GEO-003).
2//!
3//! Counterpart of `igraph_gabriel_graph()` from
4//! `references/igraph/src/spatial/beta_skeleton.cpp:744`.
5//!
6//! In the Gabriel graph of a point set, two points `A` and `B` are
7//! connected if and only if no other point `C` lies inside the closed ball
8//! that has the segment `AB` as a diameter. Equivalently, the edge `A-B`
9//! is kept when every other point `C` satisfies
10//! `dist²(C, midpoint(A, B)) >= (dist(A, B) / 2)²`.
11//!
12//! The Gabriel graph is the lune-based β-skeleton with `β = 1`; it is
13//! connected and, in 2D, planar. Arbitrary dimensionality is supported.
14
15use crate::core::{Graph, IgraphError, IgraphResult};
16
17/// Closed-ball tolerance, mirroring the C reference's
18/// `#define TOLERANCE (128 * DBL_EPSILON)`. A point that sits exactly on
19/// the diametral sphere counts as *inside* (closed ball), so the boundary
20/// removes the edge — matching `igraph`'s `is_closed = true` filter and
21/// keeping degenerate lattices (e.g. a square grid, whose cell corners are
22/// co-circular) free of spurious diagonals.
23const TOLERANCE: f64 = 128.0 * f64::EPSILON;
24
25/// Build the Gabriel graph of a point set.
26///
27/// `points` holds one row per point with a shared, arbitrary
28/// dimensionality (inferred from the first row). The result is an
29/// undirected [`Graph`] on `points.len()` vertices whose edges are exactly
30/// the Gabriel-adjacent pairs.
31///
32/// This is an `O(n²·d)` candidate enumeration with an `O(n·d)` empty-ball
33/// test per pair (`O(n³·d)` overall), which reproduces the reference
34/// filter exactly: the Gabriel graph is a subgraph of the Delaunay
35/// triangulation, so testing every pair yields the same edge set as the
36/// reference's Delaunay-pruned candidate superset.
37///
38/// # Errors
39///
40/// - [`IgraphError::InvalidArgument`] if the points are zero-dimensional
41///   (and there is at least one point).
42/// - [`IgraphError::InvalidArgument`] if the rows have inconsistent
43///   dimensionality.
44///
45/// # Examples
46///
47/// ```
48/// use rust_igraph::gabriel_graph;
49///
50/// // The four corners of a unit square: the Gabriel graph keeps the four
51/// // sides and drops both diagonals (each diagonal's midpoint has the two
52/// // opposite corners sitting on the diametral circle).
53/// let pts = vec![
54///     vec![0.0, 0.0], // 0
55///     vec![1.0, 0.0], // 1
56///     vec![0.0, 1.0], // 2
57///     vec![1.0, 1.0], // 3
58/// ];
59/// let g = gabriel_graph(&pts).unwrap();
60/// assert_eq!(g.vcount(), 4);
61/// assert_eq!(g.ecount(), 4); // sides only, no diagonals
62/// ```
63// Single-character names (`a`, `b`, `c` for the three points, `d` for the
64// dimension axis) are the natural geometric notation here.
65#[allow(clippy::many_single_char_names)]
66pub fn gabriel_graph(points: &[Vec<f64>]) -> IgraphResult<Graph> {
67    let n = points.len();
68
69    let dim = if n == 0 { 0 } else { points[0].len() };
70    if n > 0 {
71        if dim == 0 {
72            return Err(IgraphError::InvalidArgument(
73                "gabriel_graph: points must not be zero-dimensional".into(),
74            ));
75        }
76        for (i, row) in points.iter().enumerate().skip(1) {
77            if row.len() != dim {
78                return Err(IgraphError::InvalidArgument(format!(
79                    "gabriel_graph: point row {i} has dimension {} but expected {dim}",
80                    row.len()
81                )));
82            }
83        }
84    }
85
86    let n_u32 = u32::try_from(n)
87        .map_err(|_| IgraphError::InvalidArgument("gabriel_graph: too many points".into()))?;
88    let mut graph = Graph::with_vertices(n_u32);
89
90    // (1 + TOLERANCE)² scaling applied to the squared radius, matching the
91    // reference `beta_radius = sqr_dist * r² * tol²` with `r = 0.5`.
92    let tol_sq = (1.0 + TOLERANCE) * (1.0 + TOLERANCE);
93
94    for i in 0..n {
95        let a = &points[i];
96        for j in (i + 1)..n {
97            let b = &points[j];
98
99            let mut dist_sq = 0.0_f64;
100            for d in 0..dim {
101                let t = a[d] - b[d];
102                dist_sq += t * t;
103            }
104            // Squared radius of the diametral ball, inflated by tol² so a
105            // boundary point falls strictly inside and removes the edge.
106            let radius_sq = dist_sq * 0.25 * tol_sq;
107
108            let mut empty = true;
109            for (k, c) in points.iter().enumerate() {
110                if k == i || k == j {
111                    continue;
112                }
113                let mut to_mid_sq = 0.0_f64;
114                for d in 0..dim {
115                    let mid = 0.5 * (a[d] + b[d]);
116                    let t = c[d] - mid;
117                    to_mid_sq += t * t;
118                }
119                if to_mid_sq < radius_sq {
120                    empty = false;
121                    break;
122                }
123            }
124
125            if empty {
126                let u = u32::try_from(i)
127                    .map_err(|_| IgraphError::Internal("gabriel_graph: vertex id overflow"))?;
128                let v = u32::try_from(j)
129                    .map_err(|_| IgraphError::Internal("gabriel_graph: vertex id overflow"))?;
130                graph.add_edge(u, v)?;
131            }
132        }
133    }
134
135    Ok(graph)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    /// Collect the undirected edge set as a sorted `(min, max)` vec for
143    /// order-independent comparison.
144    fn edge_set(g: &Graph) -> Vec<(u32, u32)> {
145        let mut edges: Vec<(u32, u32)> = (0..g.ecount())
146            .map(|e| {
147                let (u, v) = g
148                    .edge(u32::try_from(e).expect("edge id fits in u32"))
149                    .expect("edge");
150                (u.min(v), u.max(v))
151            })
152            .collect();
153        edges.sort_unstable();
154        edges
155    }
156
157    #[test]
158    fn empty_point_set() {
159        let g = gabriel_graph(&[]).expect("empty ok");
160        assert_eq!(g.vcount(), 0);
161        assert_eq!(g.ecount(), 0);
162    }
163
164    #[test]
165    fn single_point() {
166        let g = gabriel_graph(&[vec![0.5, 0.5]]).expect("singleton ok");
167        assert_eq!(g.vcount(), 1);
168        assert_eq!(g.ecount(), 0);
169    }
170
171    #[test]
172    fn two_points_always_connected() {
173        let g = gabriel_graph(&[vec![0.0, 0.0], vec![3.0, 4.0]]).expect("pair ok");
174        assert_eq!(g.vcount(), 2);
175        assert_eq!(edge_set(&g), vec![(0, 1)]);
176    }
177
178    #[test]
179    fn unit_square_keeps_sides_drops_diagonals() {
180        let pts = vec![
181            vec![0.0, 0.0], // 0
182            vec![1.0, 0.0], // 1
183            vec![0.0, 1.0], // 2
184            vec![1.0, 1.0], // 3
185        ];
186        let g = gabriel_graph(&pts).expect("square ok");
187        assert_eq!(edge_set(&g), vec![(0, 1), (0, 2), (1, 3), (2, 3)]);
188    }
189
190    #[test]
191    fn collinear_midpoint_breaks_edge() {
192        // Three collinear points: the outer pair's midpoint coincides with
193        // the middle point, so the long edge is removed; the two short
194        // edges remain (a path).
195        let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![2.0, 0.0]];
196        let g = gabriel_graph(&pts).expect("collinear ok");
197        assert_eq!(edge_set(&g), vec![(0, 1), (1, 2)]);
198    }
199
200    #[test]
201    fn equilateral_triangle_is_complete() {
202        // No point lies inside any side's diametral ball, so all three
203        // edges survive.
204        let h = 3.0_f64.sqrt() / 2.0;
205        let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![0.5, h]];
206        let g = gabriel_graph(&pts).expect("triangle ok");
207        assert_eq!(edge_set(&g), vec![(0, 1), (0, 2), (1, 2)]);
208    }
209
210    #[test]
211    fn three_dimensional_points() {
212        // A unit cube face vs. its space diagonal: the two endpoints of a
213        // 3D pair whose midpoint hosts no other point stay connected.
214        let pts = vec![vec![0.0, 0.0, 0.0], vec![1.0, 1.0, 1.0]];
215        let g = gabriel_graph(&pts).expect("3d ok");
216        assert_eq!(edge_set(&g), vec![(0, 1)]);
217    }
218
219    #[test]
220    fn zero_dimensional_error() {
221        let err = gabriel_graph(&[vec![], vec![]]).unwrap_err();
222        assert!(matches!(err, IgraphError::InvalidArgument(_)));
223    }
224
225    #[test]
226    fn inconsistent_dimensions_error() {
227        let err = gabriel_graph(&[vec![0.0, 0.0], vec![1.0]]).unwrap_err();
228        assert!(matches!(err, IgraphError::InvalidArgument(_)));
229    }
230
231    // Coordinates are transcribed verbatim from igraph's own
232    // `beta_skeletons.c`; underscores would obscure that they are an
233    // authentic anchor, so the readability lint is waived for this fixture.
234    #[allow(clippy::unreadable_literal)]
235    #[test]
236    fn rotated_square_lattice_c_anchor() {
237        // Authentic igraph C anchor (beta_skeletons.out, "Gabriel graph of
238        // rotated square lattice"): a rotated 4x4 grid whose Gabriel graph
239        // is exactly the grid adjacency — no diagonals, because each cell's
240        // opposite corners are co-circular (closed-ball boundary).
241        let pts = vec![
242            vec![-0.3594924531727418, 1.3677591805986329],
243            vec![-1.2231182700584293, 1.8718925443115786],
244            vec![-2.0867440869441167, 2.3760259080245243],
245            vec![-2.950369903829804, 2.8801592717374698],
246            vec![0.14464091054020378, 2.2313849974843203],
247            vec![-0.7189849063454836, 2.7355183611972658],
248            vec![-1.582610723231171, 3.2396517249102117],
249            vec![-2.4462365401168586, 3.743785088623157],
250            vec![0.6487742742531495, 3.0950108143700077],
251            vec![-0.21485154263253792, 3.5991441780829536],
252            vec![-1.0784773595182253, 4.103277541795899],
253            vec![-1.9421031764039127, 4.607410905508845],
254            vec![1.152907637966095, 3.958636631255695],
255            vec![0.28928182108040756, 4.462769994968641],
256            vec![-0.5743439958052798, 4.966903358681586],
257            vec![-1.4379698126909672, 5.4710367223945315],
258        ];
259        let g = gabriel_graph(&pts).expect("lattice ok");
260        let expected: Vec<(u32, u32)> = vec![
261            (0, 1),
262            (0, 4),
263            (1, 2),
264            (1, 5),
265            (2, 3),
266            (2, 6),
267            (3, 7),
268            (4, 5),
269            (4, 8),
270            (5, 6),
271            (5, 9),
272            (6, 7),
273            (6, 10),
274            (7, 11),
275            (8, 9),
276            (8, 12),
277            (9, 10),
278            (9, 13),
279            (10, 11),
280            (10, 14),
281            (11, 15),
282            (12, 13),
283            (13, 14),
284            (14, 15),
285        ];
286        assert_eq!(edge_set(&g), expected);
287    }
288}