Skip to main content

facett_graphview/
fdeb.rs

1//! **Force-Directed Edge Bundling (FDEB)** — the answer to the *hairball* curse (#2).
2//!
3//! Straight edges in a dense directed graph smear into an unreadable hairball. FDEB
4//! (Holten & van Wijk, 2009) turns each edge into a flexible wire subdivided into
5//! control points, then lets **compatible** edges — ones travelling the same way,
6//! at a similar scale and close together — **attract each other's control points**.
7//! Edges going the same direction collapse into shared "information highways" that
8//! split again near their endpoints, so the eye follows trunks instead of spaghetti.
9//!
10//! # Reused force pipeline
11//! The bundling force is the **same boids/n-body shape** as
12//! [`facett_core::render::gpu::particles`] and [`crate::gpu_layout`]: a per-point
13//! **electrostatic attraction** toward the matching control points of compatible
14//! edges (cohesion) balanced by a **spring** holding each wire's own points evenly
15//! spaced (the rubberband). It is a pure, deterministic function of the input
16//! positions + params (FC-7): fixed iteration order, no RNG.
17//!
18//! # What lands here vs the roadmap
19//! This is the **CPU reference** FDEB: compatibility (angle · scale · position) is
20//! precomputed once, then a fixed-subdivision force loop bundles the wires. The
21//! cycle-refinement schedule (double the subdivisions each cycle) and the **GPU**
22//! port (the control points are particles; compatibility is the neighbour kernel —
23//! it drops straight onto the `particles.wgsl` storage-ping-pong) are documented in
24//! `.nornir/elite-graph-engine.md`. The output is bundled polylines a host paints as
25//! glowing splines.
26
27/// A 2D point.
28pub type P2 = [f32; 2];
29
30/// FDEB tuning. [`Default`] bundles moderately on a unit-ish coordinate scale.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct BundleParams {
33    /// Interior control points per edge (the wire's flexibility). Endpoints are extra.
34    pub subdivisions: usize,
35    /// Force-application iterations.
36    pub iterations: usize,
37    /// Per-iteration step size (how far a control point moves per step).
38    pub step: f32,
39    /// Global spring stiffness holding a wire's own points evenly spaced.
40    pub stiffness: f32,
41    /// Compatibility floor — edge pairs below this don't attract (the precompute prune).
42    pub compat_threshold: f32,
43}
44
45impl Default for BundleParams {
46    fn default() -> Self {
47        Self { subdivisions: 6, iterations: 60, step: 0.04, stiffness: 0.1, compat_threshold: 0.05 }
48    }
49}
50
51/// One bundled edge: its endpoints + the subdivided control polyline (endpoints
52/// included, so `path.len() == subdivisions + 2`). Feed `path` to a spline painter.
53#[derive(Clone, Debug, PartialEq)]
54pub struct BundledEdge {
55    pub from: usize,
56    pub to: usize,
57    pub path: Vec<P2>,
58}
59
60fn sub(a: P2, b: P2) -> P2 {
61    [a[0] - b[0], a[1] - b[1]]
62}
63fn len(a: P2) -> f32 {
64    (a[0] * a[0] + a[1] * a[1]).sqrt()
65}
66
67/// Edge-pair **compatibility** `Ca · Cs · Cp` (angle, scale, position) — the classic
68/// FDEB measure in `[0,1]`. High when two edges run parallel, at a similar length,
69/// close together (i.e. should bundle).
70fn compatibility(p0: P2, p1: P2, q0: P2, q1: P2) -> f32 {
71    let pv = sub(p1, p0);
72    let qv = sub(q1, q0);
73    let lp = len(pv).max(1e-6);
74    let lq = len(qv).max(1e-6);
75    // angle: |cos θ| between the two edge vectors.
76    let ca = ((pv[0] * qv[0] + pv[1] * qv[1]) / (lp * lq)).abs();
77    // scale: penalise very different lengths.
78    let lavg = (lp + lq) * 0.5;
79    let cs = 2.0 / (lavg * (lp.min(lq)).recip() + (lp.max(lq)) / lavg);
80    // position: penalise distant midpoints (relative to lavg).
81    let pm = [(p0[0] + p1[0]) * 0.5, (p0[1] + p1[1]) * 0.5];
82    let qm = [(q0[0] + q1[0]) * 0.5, (q0[1] + q1[1]) * 0.5];
83    let cp = lavg / (lavg + len(sub(pm, qm)));
84    (ca * cs * cp).clamp(0.0, 1.0)
85}
86
87/// **Bundle** the `edges` (index pairs into `nodes`) with FDEB. Returns one
88/// [`BundledEdge`] per input edge whose `path` is the bundled control polyline.
89/// Deterministic in `(nodes, edges, p)`. Self-loops / out-of-range edges become a
90/// straight degenerate path (left unbundled).
91#[must_use]
92pub fn bundle(nodes: &[P2], edges: &[(usize, usize)], p: &BundleParams) -> Vec<BundledEdge> {
93    let m = p.subdivisions.max(1);
94    let n = edges.len();
95    // ── initialise each wire as evenly-spaced points on its straight chord ──
96    let mut paths: Vec<Vec<P2>> = Vec::with_capacity(n);
97    let mut endpoints: Vec<(P2, P2)> = Vec::with_capacity(n);
98    for &(a, b) in edges {
99        let (pa, pb) = match (nodes.get(a), nodes.get(b)) {
100            (Some(&pa), Some(&pb)) => (pa, pb),
101            _ => ([0.0, 0.0], [0.0, 0.0]),
102        };
103        endpoints.push((pa, pb));
104        let path = (0..=m + 1)
105            .map(|i| {
106                let t = i as f32 / (m + 1) as f32;
107                [pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t]
108            })
109            .collect();
110        paths.push(path);
111    }
112
113    // ── precompute compatibility (symmetric, pruned at the threshold) ──
114    let mut compat: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n];
115    for i in 0..n {
116        let (pi0, pi1) = endpoints[i];
117        for j in (i + 1)..n {
118            let (qj0, qj1) = endpoints[j];
119            let c = compatibility(pi0, pi1, qj0, qj1);
120            if c >= p.compat_threshold {
121                compat[i].push((j, c));
122                compat[j].push((i, c));
123            }
124        }
125    }
126
127    // ── force iterations: spring (own wire) + electrostatic (compatible wires) ──
128    for _ in 0..p.iterations {
129        let snapshot = paths.clone();
130        for e in 0..n {
131            let rest = len(sub(endpoints[e].1, endpoints[e].0)).max(1e-4);
132            let kp = p.stiffness / (rest * (m + 1) as f32);
133            for i in 1..=m {
134                let cur = snapshot[e][i];
135                // spring toward the midpoint of this wire's own neighbours.
136                let nb = snapshot[e][i - 1];
137                let nf = snapshot[e][i + 1];
138                let mut force = [
139                    kp * ((nb[0] - cur[0]) + (nf[0] - cur[0])),
140                    kp * ((nb[1] - cur[1]) + (nf[1] - cur[1])),
141                ];
142                // electrostatic attraction to compatible wires' matching points.
143                for &(q, c) in &compat[e] {
144                    let d = sub(snapshot[q][i], cur);
145                    let dist = len(d).max(1e-3);
146                    let w = c / dist; // inverse-distance, compatibility-weighted
147                    force[0] += d[0] * w;
148                    force[1] += d[1] * w;
149                }
150                paths[e][i] = [cur[0] + p.step * force[0], cur[1] + p.step * force[1]];
151            }
152        }
153    }
154
155    edges
156        .iter()
157        .zip(paths)
158        .map(|(&(from, to), path)| BundledEdge { from, to, path })
159        .collect()
160}
161
162/// The straight-edge baseline polylines (same subdivision count, no bundling) — the
163/// control comparison for the occlusion metric.
164#[must_use]
165pub fn straight(nodes: &[P2], edges: &[(usize, usize)], subdivisions: usize) -> Vec<BundledEdge> {
166    let p = BundleParams { subdivisions, iterations: 0, ..BundleParams::default() };
167    bundle(nodes, edges, &p)
168}
169
170/// **Occlusion / ink-coverage metric**: how many distinct cells of a `cell`-sized grid
171/// the edge polylines touch (sampled densely along each path). Bundling concentrates
172/// edges onto shared trunks → fewer distinct cells → less occlusion. Read as DATA: a
173/// drop vs the straight baseline proves the bundle de-hairballs the view.
174#[must_use]
175pub fn occupancy(edges: &[BundledEdge], cell: f32) -> usize {
176    use std::collections::BTreeSet;
177    let cell = cell.max(1e-4);
178    let mut cells: BTreeSet<(i32, i32)> = BTreeSet::new();
179    for e in edges {
180        for w in e.path.windows(2) {
181            let segs = 8;
182            for s in 0..=segs {
183                let t = s as f32 / segs as f32;
184                let x = w[0][0] + (w[1][0] - w[0][0]) * t;
185                let y = w[0][1] + (w[1][1] - w[0][1]) * t;
186                cells.insert(((x / cell).floor() as i32, (y / cell).floor() as i32));
187            }
188        }
189    }
190    cells.len()
191}
192
193/// Total polyline length over all edges (bundling bows wires, so this is a secondary
194/// diagnostic — the primary win is the [`occupancy`] drop).
195#[must_use]
196pub fn total_length(edges: &[BundledEdge]) -> f32 {
197    edges.iter().map(|e| e.path.windows(2).map(|w| len(sub(w[1], w[0]))).sum::<f32>()).sum()
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    /// A bipartite hairball: 6 nodes on the left (x=0) fully connected to 6 nodes on
205    /// the right (x=10). Straight, the 36 near-parallel edges smear across the whole
206    /// middle band; they're highly compatible (parallel, same scale, overlapping) so
207    /// FDEB should pull them into a trunk.
208    fn hairball() -> (Vec<P2>, Vec<(usize, usize)>) {
209        let mut nodes = Vec::new();
210        for k in 0..6 {
211            nodes.push([0.0, k as f32]); // left column 0..5
212        }
213        for k in 0..6 {
214            nodes.push([10.0, k as f32]); // right column 6..11
215        }
216        let mut edges = Vec::new();
217        for l in 0..6 {
218            for r in 6..12 {
219                edges.push((l, r));
220            }
221        }
222        (nodes, edges)
223    }
224
225    #[test]
226    fn endpoints_stay_pinned() {
227        let (nodes, edges) = hairball();
228        let bundled = bundle(&nodes, &edges, &BundleParams::default());
229        for (b, &(a, c)) in bundled.iter().zip(&edges) {
230            assert!(len(sub(*b.path.first().unwrap(), nodes[a])) < 1e-4, "start pinned to node");
231            assert!(len(sub(*b.path.last().unwrap(), nodes[c])) < 1e-4, "end pinned to node");
232            assert_eq!(b.path.len(), BundleParams::default().subdivisions + 2);
233        }
234    }
235
236    #[test]
237    fn bundling_reduces_occlusion_vs_straight() {
238        let (nodes, edges) = hairball();
239        let p = BundleParams::default();
240        let straight_edges = straight(&nodes, &edges, p.subdivisions);
241        let bundled = bundle(&nodes, &edges, &p);
242
243        let cell = 0.5;
244        let occ_straight = occupancy(&straight_edges, cell);
245        let occ_bundled = occupancy(&bundled, cell);
246        // The hairball collapses onto shared trunks → markedly fewer occupied cells.
247        assert!(
248            (occ_bundled as f32) < (occ_straight as f32) * 0.8,
249            "bundling cut occlusion by >20%: {occ_straight} → {occ_bundled} cells"
250        );
251    }
252
253    #[test]
254    fn bundling_is_deterministic() {
255        let (nodes, edges) = hairball();
256        let p = BundleParams::default();
257        assert_eq!(bundle(&nodes, &edges, &p), bundle(&nodes, &edges, &p), "FDEB is reproducible (FC-7)");
258    }
259
260    #[test]
261    fn compatible_parallel_edges_converge_in_the_middle() {
262        // Two parallel horizontal edges one unit apart. Their mid control points
263        // should be pulled closer together than the straight 1.0 separation.
264        let nodes = vec![[0.0, 0.0], [10.0, 0.0], [0.0, 1.0], [10.0, 1.0]];
265        let edges = vec![(0, 1), (2, 3)];
266        let p = BundleParams { iterations: 120, ..BundleParams::default() };
267        let b = bundle(&nodes, &edges, &p);
268        let mid = b[0].path.len() / 2;
269        let gap = len(sub(b[1].path[mid], b[0].path[mid]));
270        assert!(gap < 0.9, "parallel wires bundled closer than their 1.0 endpoint gap (got {gap})");
271    }
272
273    #[test]
274    fn perpendicular_edges_are_incompatible() {
275        // A horizontal and a vertical edge crossing: low angle-compatibility ⇒ they
276        // barely attract, so the horizontal wire stays ~straight.
277        let nodes = vec![[0.0, 0.0], [10.0, 0.0], [5.0, -5.0], [5.0, 5.0]];
278        let edges = vec![(0, 1), (2, 3)];
279        let b = bundle(&nodes, &edges, &BundleParams::default());
280        let mid = b[0].path.len() / 2;
281        // The horizontal wire's midpoint barely leaves the y=0 chord.
282        assert!(b[0].path[mid][1].abs() < 0.6, "perpendicular edge didn't bundle (y={})", b[0].path[mid][1]);
283    }
284}