pub type P2 = [f32; 2];
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BundleParams {
pub subdivisions: usize,
pub iterations: usize,
pub step: f32,
pub stiffness: f32,
pub compat_threshold: f32,
}
impl Default for BundleParams {
fn default() -> Self {
Self { subdivisions: 6, iterations: 60, step: 0.04, stiffness: 0.1, compat_threshold: 0.05 }
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct BundledEdge {
pub from: usize,
pub to: usize,
pub path: Vec<P2>,
}
fn sub(a: P2, b: P2) -> P2 {
[a[0] - b[0], a[1] - b[1]]
}
fn len(a: P2) -> f32 {
(a[0] * a[0] + a[1] * a[1]).sqrt()
}
fn compatibility(p0: P2, p1: P2, q0: P2, q1: P2) -> f32 {
let pv = sub(p1, p0);
let qv = sub(q1, q0);
let lp = len(pv).max(1e-6);
let lq = len(qv).max(1e-6);
let ca = ((pv[0] * qv[0] + pv[1] * qv[1]) / (lp * lq)).abs();
let lavg = (lp + lq) * 0.5;
let cs = 2.0 / (lavg * (lp.min(lq)).recip() + (lp.max(lq)) / lavg);
let pm = [(p0[0] + p1[0]) * 0.5, (p0[1] + p1[1]) * 0.5];
let qm = [(q0[0] + q1[0]) * 0.5, (q0[1] + q1[1]) * 0.5];
let cp = lavg / (lavg + len(sub(pm, qm)));
(ca * cs * cp).clamp(0.0, 1.0)
}
#[must_use]
pub fn bundle(nodes: &[P2], edges: &[(usize, usize)], p: &BundleParams) -> Vec<BundledEdge> {
let m = p.subdivisions.max(1);
let n = edges.len();
let mut paths: Vec<Vec<P2>> = Vec::with_capacity(n);
let mut endpoints: Vec<(P2, P2)> = Vec::with_capacity(n);
for &(a, b) in edges {
let (pa, pb) = match (nodes.get(a), nodes.get(b)) {
(Some(&pa), Some(&pb)) => (pa, pb),
_ => ([0.0, 0.0], [0.0, 0.0]),
};
endpoints.push((pa, pb));
let path = (0..=m + 1)
.map(|i| {
let t = i as f32 / (m + 1) as f32;
[pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t]
})
.collect();
paths.push(path);
}
let mut compat: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n];
for i in 0..n {
let (pi0, pi1) = endpoints[i];
for j in (i + 1)..n {
let (qj0, qj1) = endpoints[j];
let c = compatibility(pi0, pi1, qj0, qj1);
if c >= p.compat_threshold {
compat[i].push((j, c));
compat[j].push((i, c));
}
}
}
for _ in 0..p.iterations {
let snapshot = paths.clone();
for e in 0..n {
let rest = len(sub(endpoints[e].1, endpoints[e].0)).max(1e-4);
let kp = p.stiffness / (rest * (m + 1) as f32);
for i in 1..=m {
let cur = snapshot[e][i];
let nb = snapshot[e][i - 1];
let nf = snapshot[e][i + 1];
let mut force = [
kp * ((nb[0] - cur[0]) + (nf[0] - cur[0])),
kp * ((nb[1] - cur[1]) + (nf[1] - cur[1])),
];
for &(q, c) in &compat[e] {
let d = sub(snapshot[q][i], cur);
let dist = len(d).max(1e-3);
let w = c / dist; force[0] += d[0] * w;
force[1] += d[1] * w;
}
paths[e][i] = [cur[0] + p.step * force[0], cur[1] + p.step * force[1]];
}
}
}
edges
.iter()
.zip(paths)
.map(|(&(from, to), path)| BundledEdge { from, to, path })
.collect()
}
#[must_use]
pub fn straight(nodes: &[P2], edges: &[(usize, usize)], subdivisions: usize) -> Vec<BundledEdge> {
let p = BundleParams { subdivisions, iterations: 0, ..BundleParams::default() };
bundle(nodes, edges, &p)
}
#[must_use]
pub fn occupancy(edges: &[BundledEdge], cell: f32) -> usize {
use std::collections::BTreeSet;
let cell = cell.max(1e-4);
let mut cells: BTreeSet<(i32, i32)> = BTreeSet::new();
for e in edges {
for w in e.path.windows(2) {
let segs = 8;
for s in 0..=segs {
let t = s as f32 / segs as f32;
let x = w[0][0] + (w[1][0] - w[0][0]) * t;
let y = w[0][1] + (w[1][1] - w[0][1]) * t;
cells.insert(((x / cell).floor() as i32, (y / cell).floor() as i32));
}
}
}
cells.len()
}
#[must_use]
pub fn total_length(edges: &[BundledEdge]) -> f32 {
edges.iter().map(|e| e.path.windows(2).map(|w| len(sub(w[1], w[0]))).sum::<f32>()).sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn hairball() -> (Vec<P2>, Vec<(usize, usize)>) {
let mut nodes = Vec::new();
for k in 0..6 {
nodes.push([0.0, k as f32]); }
for k in 0..6 {
nodes.push([10.0, k as f32]); }
let mut edges = Vec::new();
for l in 0..6 {
for r in 6..12 {
edges.push((l, r));
}
}
(nodes, edges)
}
#[test]
fn endpoints_stay_pinned() {
let (nodes, edges) = hairball();
let bundled = bundle(&nodes, &edges, &BundleParams::default());
for (b, &(a, c)) in bundled.iter().zip(&edges) {
assert!(len(sub(*b.path.first().unwrap(), nodes[a])) < 1e-4, "start pinned to node");
assert!(len(sub(*b.path.last().unwrap(), nodes[c])) < 1e-4, "end pinned to node");
assert_eq!(b.path.len(), BundleParams::default().subdivisions + 2);
}
}
#[test]
fn bundling_reduces_occlusion_vs_straight() {
let (nodes, edges) = hairball();
let p = BundleParams::default();
let straight_edges = straight(&nodes, &edges, p.subdivisions);
let bundled = bundle(&nodes, &edges, &p);
let cell = 0.5;
let occ_straight = occupancy(&straight_edges, cell);
let occ_bundled = occupancy(&bundled, cell);
assert!(
(occ_bundled as f32) < (occ_straight as f32) * 0.8,
"bundling cut occlusion by >20%: {occ_straight} → {occ_bundled} cells"
);
}
#[test]
fn bundling_is_deterministic() {
let (nodes, edges) = hairball();
let p = BundleParams::default();
assert_eq!(bundle(&nodes, &edges, &p), bundle(&nodes, &edges, &p), "FDEB is reproducible (FC-7)");
}
#[test]
fn compatible_parallel_edges_converge_in_the_middle() {
let nodes = vec![[0.0, 0.0], [10.0, 0.0], [0.0, 1.0], [10.0, 1.0]];
let edges = vec![(0, 1), (2, 3)];
let p = BundleParams { iterations: 120, ..BundleParams::default() };
let b = bundle(&nodes, &edges, &p);
let mid = b[0].path.len() / 2;
let gap = len(sub(b[1].path[mid], b[0].path[mid]));
assert!(gap < 0.9, "parallel wires bundled closer than their 1.0 endpoint gap (got {gap})");
}
#[test]
fn perpendicular_edges_are_incompatible() {
let nodes = vec![[0.0, 0.0], [10.0, 0.0], [5.0, -5.0], [5.0, 5.0]];
let edges = vec![(0, 1), (2, 3)];
let b = bundle(&nodes, &edges, &BundleParams::default());
let mid = b[0].path.len() / 2;
assert!(b[0].path[mid][1].abs() < 0.6, "perpendicular edge didn't bundle (y={})", b[0].path[mid][1]);
}
}