Skip to main content

rust_igraph/algorithms/spatial/
circle_beta_skeleton.rs

1//! Circle-based β-skeleton of a 2-D spatial point set (ALGO-GEO-007).
2//!
3//! Counterpart of `igraph_circle_beta_skeleton()` from
4//! `references/igraph/src/spatial/beta_skeleton.cpp:494`.
5//!
6//! Like the [lune-based β-skeleton](crate::lune_beta_skeleton), two points
7//! `A` and `B` are connected when a β-governed empty region contains no other
8//! point. The circle-based variant differs from the lune one **only for
9//! `β ≥ 1`**, where it uses two circles whose centres sit perpendicular to the
10//! line `AB` (rather than sliding along it) and asks whether their *union* is
11//! empty:
12//!
13//! - For `β ≥ 1`: radius `r = β/2`, centres offset perpendicular to `AB` by
14//!   `(A−B)·sqrt(r²−¼)` rotated 90°. The edge survives iff **neither** circle
15//!   (radius `r·dist(A,B)`) contains another point — the *union-empty* test.
16//!   `β = 1` collapses both centres onto the midpoint, recovering the [Gabriel
17//!   graph](crate::gabriel_graph).
18//! - For `β < 1`: identical to the lune skeleton — `r = 1/(2β)`, the same
19//!   perpendicular centres, and the *intersection-empty* test (a point removes
20//!   the edge only when it lies in **both** circles).
21//!
22//! A larger `β` enlarges the empty region and so yields a sparser graph. The
23//! construction is **2-D only** in every regime (the perpendicular-centre
24//! rotation is defined only in the plane), matching the reference's
25//! unconditional `IGRAPH_UNIMPLEMENTED` for non-2-D input. The boundary is
26//! **closed** (matching the reference's `is_closed = true`): a point on the
27//! exact circle boundary counts as inside and removes the edge.
28
29use crate::core::{Graph, IgraphError, IgraphResult};
30
31/// Closed-region tolerance, mirroring the C reference's
32/// `#define TOLERANCE (128 * DBL_EPSILON)`.
33const TOLERANCE: f64 = 128.0 * f64::EPSILON;
34
35/// Build the circle-based β-skeleton of a 2-D point set.
36///
37/// `points` holds one row per point and must be 2-dimensional (inferred from
38/// the first row). `beta` is the skeleton parameter. The result is an
39/// undirected [`Graph`] on `points.len()` vertices.
40///
41/// This is an `O(n²)` candidate enumeration with an `O(n)` empty-region test
42/// per pair. For `β ≥ 1` the β-skeleton is a subgraph of the Delaunay
43/// triangulation, so testing every pair yields the same edge set as the
44/// reference's Delaunay-pruned candidate superset; for `β < 1` the reference
45/// itself starts from the complete graph, which this matches directly.
46///
47/// # Errors
48///
49/// - [`IgraphError::InvalidArgument`] if `beta` is not a finite positive
50///   number, or if the rows have inconsistent dimensionality.
51/// - [`IgraphError::Unsupported`] if the points are not 2-dimensional (with at
52///   least one point), matching the reference's `IGRAPH_UNIMPLEMENTED`.
53///
54/// # Examples
55///
56/// ```
57/// use rust_igraph::circle_beta_skeleton;
58///
59/// // Four corners of a unit square. At β = 1 (the Gabriel graph) the four
60/// // sides survive and both diagonals drop: each diagonal's midpoint has the
61/// // two opposite corners sitting on the diametral circle.
62/// let pts = vec![
63///     vec![0.0, 0.0],
64///     vec![1.0, 0.0],
65///     vec![0.0, 1.0],
66///     vec![1.0, 1.0],
67/// ];
68/// let g = circle_beta_skeleton(&pts, 1.0).unwrap();
69/// assert_eq!(g.vcount(), 4);
70/// assert_eq!(g.ecount(), 4); // sides only
71/// ```
72// Single-character names (`a`, `b`, `c` for the three points) are the natural
73// geometric notation here.
74#[allow(clippy::many_single_char_names)]
75#[allow(clippy::similar_names)]
76pub fn circle_beta_skeleton(points: &[Vec<f64>], beta: f64) -> IgraphResult<Graph> {
77    if !beta.is_finite() || beta <= 0.0 {
78        return Err(IgraphError::InvalidArgument(format!(
79            "circle_beta_skeleton: beta must be a finite positive number, got {beta}"
80        )));
81    }
82
83    let n = points.len();
84    let dim = if n == 0 { 0 } else { points[0].len() };
85    if n > 0 {
86        for (i, row) in points.iter().enumerate().skip(1) {
87            if row.len() != dim {
88                return Err(IgraphError::InvalidArgument(format!(
89                    "circle_beta_skeleton: point row {i} has dimension {} but expected {dim}",
90                    row.len()
91                )));
92            }
93        }
94        if dim != 2 {
95            return Err(IgraphError::Unsupported(
96                "circle_beta_skeleton: circle-based beta skeletons are only supported in 2 dimensions",
97            ));
98        }
99    }
100
101    let n_u32 = u32::try_from(n).map_err(|_| {
102        IgraphError::InvalidArgument("circle_beta_skeleton: too many points".into())
103    })?;
104    let mut graph = Graph::with_vertices(n_u32);
105
106    let r = if beta < 1.0 { 0.5 / beta } else { 0.5 * beta };
107    // Perpendicular offset scales by sqrt(r² − 1/4); r ≥ 1/2 in both regimes,
108    // so the argument is non-negative.
109    let perp_scale = (r * r - 0.25).max(0.0).sqrt();
110
111    // For β ≥ 1 the union-empty test inflates the squared radius by a single
112    // (1 + TOLERANCE) factor; for β < 1 the intersection-empty test (matching
113    // the lune skeleton) inflates by (1 + TOLERANCE)².
114    let union_mode = beta >= 1.0;
115    let tol = 1.0 + TOLERANCE;
116    let radius_factor = if union_mode { tol } else { tol * tol };
117
118    let mut a_center = [0.0_f64; 2];
119    let mut b_center = [0.0_f64; 2];
120
121    for i in 0..n {
122        let a = &points[i];
123        for j in (i + 1)..n {
124            let b = &points[j];
125
126            let dx = a[0] - b[0];
127            let dy = a[1] - b[1];
128            let dist_sq = dx * dx + dy * dy;
129
130            // Perpendicular centres (used in both regimes for this variant).
131            let mid0 = 0.5 * (a[0] + b[0]);
132            let mid1 = 0.5 * (a[1] + b[1]);
133            // perp = (a − b) · sqrt(r² − 1/4), rotated 90° CCW: (x,y) → (−y,x).
134            let perp0 = -dy * perp_scale;
135            let perp1 = dx * perp_scale;
136            a_center[0] = mid0 + perp0;
137            a_center[1] = mid1 + perp1;
138            b_center[0] = mid0 - perp0;
139            b_center[1] = mid1 - perp1;
140
141            let circle_radius = dist_sq * r * r * radius_factor;
142
143            let mut empty = true;
144            for (k, c) in points.iter().enumerate() {
145                if k == i || k == j {
146                    continue;
147                }
148                let ca0 = c[0] - a_center[0];
149                let ca1 = c[1] - a_center[1];
150                let da = ca0 * ca0 + ca1 * ca1;
151                let cb0 = c[0] - b_center[0];
152                let cb1 = c[1] - b_center[1];
153                let db = cb0 * cb0 + cb1 * cb1;
154
155                let blocks = if union_mode {
156                    // Union empty: a point in EITHER circle removes the edge.
157                    da < circle_radius || db < circle_radius
158                } else {
159                    // Intersection empty: a point in BOTH circles removes it.
160                    da < circle_radius && db < circle_radius
161                };
162                if blocks {
163                    empty = false;
164                    break;
165                }
166            }
167
168            if empty {
169                let u = u32::try_from(i).map_err(|_| {
170                    IgraphError::Internal("circle_beta_skeleton: vertex id overflow")
171                })?;
172                let v = u32::try_from(j).map_err(|_| {
173                    IgraphError::Internal("circle_beta_skeleton: vertex id overflow")
174                })?;
175                graph.add_edge(u, v)?;
176            }
177        }
178    }
179
180    Ok(graph)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    /// Collect the undirected edge set as a sorted `(min, max)` vec.
188    fn edge_set(g: &Graph) -> Vec<(u32, u32)> {
189        let mut edges: Vec<(u32, u32)> = (0..g.ecount())
190            .map(|e| {
191                let (u, v) = g
192                    .edge(u32::try_from(e).expect("edge id fits in u32"))
193                    .expect("edge");
194                (u.min(v), u.max(v))
195            })
196            .collect();
197        edges.sort_unstable();
198        edges
199    }
200
201    /// The 25-point 2-D set transcribed verbatim from igraph's
202    /// `beta_skeletons.c` (row-major, 25×2).
203    #[allow(clippy::unreadable_literal)]
204    fn points_25() -> Vec<Vec<f64>> {
205        let flat = [
206            0.474217, 0.0314797, 0.208089, 0.439308, 0.967367, 0.530466, 0.177005, 0.426713,
207            0.568462, 0.57507, 0.441834, 0.284514, 0.479224, 0.817988, 0.720209, 0.225744,
208            0.204941, 0.44297, 0.285318, 0.912984, 0.831097, 0.0176603, 0.827154, 0.472702,
209            0.173059, 0.561858, 0.156276, 0.88019, 0.65935, 0.538207, 0.570379, 0.518081, 0.900553,
210            0.656416, 0.726631, 0.863709, 0.380264, 0.287159, 0.31098, 0.230773, 0.243089,
211            0.164584, 0.967974, 0.524992, 0.726605, 0.0724703, 0.739752, 0.447069, 0.0443581,
212            0.444839,
213        ];
214        flat.chunks_exact(2).map(<[f64]>::to_vec).collect()
215    }
216
217    #[test]
218    fn empty_point_set() {
219        let g = circle_beta_skeleton(&[], 1.1).expect("empty ok");
220        assert_eq!(g.vcount(), 0);
221        assert_eq!(g.ecount(), 0);
222    }
223
224    #[test]
225    fn single_point() {
226        let g = circle_beta_skeleton(&[vec![0.5, 0.5]], 2.0).expect("singleton ok");
227        assert_eq!(g.vcount(), 1);
228        assert_eq!(g.ecount(), 0);
229    }
230
231    #[test]
232    fn two_points_always_connected() {
233        let g = circle_beta_skeleton(&[vec![0.0, 0.0], vec![3.0, 4.0]], 1.1).expect("pair ok");
234        assert_eq!(edge_set(&g), vec![(0, 1)]);
235    }
236
237    #[test]
238    fn non_finite_beta_is_error() {
239        assert!(matches!(
240            circle_beta_skeleton(&[vec![0.0, 0.0]], f64::NAN).unwrap_err(),
241            IgraphError::InvalidArgument(_)
242        ));
243        assert!(matches!(
244            circle_beta_skeleton(&[vec![0.0, 0.0]], f64::INFINITY).unwrap_err(),
245            IgraphError::InvalidArgument(_)
246        ));
247    }
248
249    #[test]
250    fn non_positive_beta_is_error() {
251        assert!(matches!(
252            circle_beta_skeleton(&[vec![0.0, 0.0]], 0.0).unwrap_err(),
253            IgraphError::InvalidArgument(_)
254        ));
255        assert!(matches!(
256            circle_beta_skeleton(&[vec![0.0, 0.0]], -1.0).unwrap_err(),
257            IgraphError::InvalidArgument(_)
258        ));
259    }
260
261    #[test]
262    fn non_2d_is_unsupported() {
263        // 2-D only in every regime, unlike the lune skeleton (which allows any
264        // dimension for β ≥ 1).
265        let pts = vec![vec![0.0, 0.0, 0.0], vec![1.0, 1.0, 1.0]];
266        assert!(matches!(
267            circle_beta_skeleton(&pts, 2.0).unwrap_err(),
268            IgraphError::Unsupported(_)
269        ));
270        assert!(matches!(
271            circle_beta_skeleton(&pts, 0.5).unwrap_err(),
272            IgraphError::Unsupported(_)
273        ));
274    }
275
276    #[test]
277    fn inconsistent_dimensions_error() {
278        let err = circle_beta_skeleton(&[vec![0.0, 0.0], vec![1.0]], 1.1).unwrap_err();
279        assert!(matches!(err, IgraphError::InvalidArgument(_)));
280    }
281
282    #[test]
283    fn beta_one_equals_gabriel() {
284        // β = 1: both perpendicular centres collapse onto the midpoint and the
285        // union of the two (identical) circles is the Gabriel diametral ball.
286        let pts = points_25();
287        let circle = circle_beta_skeleton(&pts, 1.0).expect("circle ok");
288        let gabriel = crate::gabriel_graph(&pts).expect("gabriel ok");
289        assert_eq!(edge_set(&circle), edge_set(&gabriel));
290    }
291
292    // --- Authentic igraph C anchor (beta_skeletons.out) ---
293
294    #[allow(clippy::unreadable_literal)]
295    #[test]
296    fn c_anchor_beta11_circle_25_points() {
297        let g = circle_beta_skeleton(&points_25(), 1.1).expect("ok");
298        let expected = vec![
299            (0, 22),
300            (1, 8),
301            (2, 16),
302            (2, 21),
303            (3, 24),
304            (4, 6),
305            (4, 15),
306            (5, 7),
307            (5, 15),
308            (5, 18),
309            (6, 9),
310            (6, 17),
311            (7, 22),
312            (7, 23),
313            (8, 12),
314            (9, 13),
315            (10, 22),
316            (11, 23),
317            (12, 13),
318            (14, 23),
319            (16, 17),
320            (18, 19),
321            (19, 20),
322        ];
323        assert_eq!(edge_set(&g), expected);
324    }
325}