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}