1pub type P2 = [f32; 2];
29
30#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct BundleParams {
33 pub subdivisions: usize,
35 pub iterations: usize,
37 pub step: f32,
39 pub stiffness: f32,
41 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#[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
67fn 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 let ca = ((pv[0] * qv[0] + pv[1] * qv[1]) / (lp * lq)).abs();
77 let lavg = (lp + lq) * 0.5;
79 let cs = 2.0 / (lavg * (lp.min(lq)).recip() + (lp.max(lq)) / lavg);
80 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#[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 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 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 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 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 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; 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#[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#[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#[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 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]); }
213 for k in 0..6 {
214 nodes.push([10.0, k as f32]); }
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 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 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 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 assert!(b[0].path[mid][1].abs() < 0.6, "perpendicular edge didn't bundle (y={})", b[0].path[mid][1]);
283 }
284}