Skip to main content

rust_igraph/algorithms/layout/
fruchterman_reingold.rs

1//! Fruchterman-Reingold force-directed layout (ALGO-LO-002).
2
3use crate::algorithms::connectivity::connected_components;
4use crate::core::{Graph, IgraphError, IgraphResult, rng::SplitMix64};
5
6/// Grid acceleration mode for Fruchterman-Reingold layout.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum FrGrid {
9    /// Use grid-based acceleration (faster but less accurate for large graphs).
10    Grid,
11    /// No grid — compute all-pairs repulsion (O(V²) per iteration).
12    NoGrid,
13    /// Automatically choose: grid for V > 1000, otherwise no grid.
14    Auto,
15}
16
17/// Optional per-vertex bounding box constraints.
18#[derive(Debug, Clone, Default)]
19pub struct FrBounds {
20    pub minx: Option<Vec<f64>>,
21    pub maxx: Option<Vec<f64>>,
22    pub miny: Option<Vec<f64>>,
23    pub maxy: Option<Vec<f64>>,
24}
25
26/// Parameters for the 2D Fruchterman-Reingold layout.
27#[derive(Debug, Clone)]
28pub struct FrParams {
29    /// Number of iterations (default: 500).
30    pub niter: u32,
31    /// Starting temperature — maximum displacement per step (default: sqrt(V)).
32    pub start_temp: Option<f64>,
33    /// Grid acceleration mode (default: Auto).
34    pub grid: FrGrid,
35    /// Edge weights (must be positive). `None` means all weights = 1.
36    pub weights: Option<Vec<f64>>,
37    /// Per-vertex coordinate bounds.
38    pub bounds: FrBounds,
39    /// If `Some(coords)`, use as initial layout seed. Otherwise random init.
40    pub seed_coords: Option<Vec<(f64, f64)>>,
41    /// RNG seed for random initialization (default: 42).
42    pub rng_seed: u64,
43}
44
45impl Default for FrParams {
46    fn default() -> Self {
47        Self {
48            niter: 500,
49            start_temp: None,
50            grid: FrGrid::Auto,
51            weights: None,
52            bounds: FrBounds::default(),
53            seed_coords: None,
54            rng_seed: 42,
55        }
56    }
57}
58
59/// 3D bounding box constraints.
60#[derive(Debug, Clone, Default)]
61pub struct FrBounds3d {
62    pub minx: Option<Vec<f64>>,
63    pub maxx: Option<Vec<f64>>,
64    pub miny: Option<Vec<f64>>,
65    pub maxy: Option<Vec<f64>>,
66    pub minz: Option<Vec<f64>>,
67    pub maxz: Option<Vec<f64>>,
68}
69
70/// Parameters for the 3D Fruchterman-Reingold layout.
71#[derive(Debug, Clone)]
72pub struct FrParams3d {
73    pub niter: u32,
74    pub start_temp: Option<f64>,
75    pub weights: Option<Vec<f64>>,
76    pub bounds: FrBounds3d,
77    pub seed_coords: Option<Vec<(f64, f64, f64)>>,
78    pub rng_seed: u64,
79}
80
81impl Default for FrParams3d {
82    fn default() -> Self {
83        Self {
84            niter: 500,
85            start_temp: None,
86            weights: None,
87            bounds: FrBounds3d::default(),
88            seed_coords: None,
89            rng_seed: 42,
90        }
91    }
92}
93
94/// 2D Fruchterman-Reingold force-directed layout.
95///
96/// Simulates attractive force `f_a(d) = -w * d²` between connected vertices
97/// and repulsive force `f_r(d) = 1/d` between all vertex pairs. Temperature
98/// cools linearly from `start_temp` to 0 over `niter` iterations.
99///
100/// For disconnected graphs, a weak global attraction `n^(-3/2)` keeps
101/// components from drifting apart.
102///
103/// Reference: Fruchterman & Reingold, "Graph Drawing by Force-directed
104/// Placement", Software—Practice and Experience 21(11), 1991.
105///
106/// # Examples
107///
108/// ```
109/// use rust_igraph::{Graph, layout_fruchterman_reingold, FrParams};
110///
111/// let mut g = Graph::with_vertices(5);
112/// g.add_edge(0, 1).unwrap();
113/// g.add_edge(1, 2).unwrap();
114/// g.add_edge(2, 3).unwrap();
115/// g.add_edge(3, 4).unwrap();
116/// g.add_edge(4, 0).unwrap();
117///
118/// let coords = layout_fruchterman_reingold(&g, &FrParams::default()).unwrap();
119/// assert_eq!(coords.len(), 5);
120/// ```
121pub fn layout_fruchterman_reingold(
122    graph: &Graph,
123    params: &FrParams,
124) -> IgraphResult<Vec<(f64, f64)>> {
125    let vcount = graph.vcount() as usize;
126    let ecount = graph.ecount() as usize;
127
128    if let Some(ref w) = params.weights {
129        if w.len() != ecount {
130            return Err(IgraphError::InvalidArgument(
131                "weight vector length must equal edge count".into(),
132            ));
133        }
134        if ecount > 0 && w.iter().any(|&x| x <= 0.0) {
135            return Err(IgraphError::InvalidArgument(
136                "weights must be positive for Fruchterman-Reingold layout".into(),
137            ));
138        }
139    }
140
141    validate_bounds_2d(vcount, &params.bounds)?;
142
143    if let Some(ref sc) = params.seed_coords {
144        if sc.len() != vcount {
145            return Err(IgraphError::InvalidArgument(
146                "seed_coords length must equal vertex count".into(),
147            ));
148        }
149    }
150
151    if vcount == 0 {
152        return Ok(Vec::new());
153    }
154
155    let start_temp = params.start_temp.unwrap_or_else(|| (vcount as f64).sqrt());
156
157    let use_grid = match params.grid {
158        FrGrid::Grid => true,
159        FrGrid::NoGrid => false,
160        FrGrid::Auto => vcount > 1000,
161    };
162
163    if use_grid {
164        fr_grid_2d(graph, params, start_temp)
165    } else {
166        fr_naive_2d(graph, params, start_temp)
167    }
168}
169
170/// 3D Fruchterman-Reingold force-directed layout.
171///
172/// Same algorithm as the 2D version but in three dimensions.
173/// No grid acceleration is available for 3D.
174///
175/// # Examples
176///
177/// ```
178/// use rust_igraph::{Graph, layout_fruchterman_reingold_3d, FrParams3d};
179///
180/// let mut g = Graph::with_vertices(4);
181/// g.add_edge(0, 1).unwrap();
182/// g.add_edge(1, 2).unwrap();
183/// g.add_edge(2, 3).unwrap();
184///
185/// let coords = layout_fruchterman_reingold_3d(&g, &FrParams3d::default()).unwrap();
186/// assert_eq!(coords.len(), 4);
187/// ```
188pub fn layout_fruchterman_reingold_3d(
189    graph: &Graph,
190    params: &FrParams3d,
191) -> IgraphResult<Vec<(f64, f64, f64)>> {
192    let vcount = graph.vcount() as usize;
193    let ecount = graph.ecount() as usize;
194
195    if let Some(ref w) = params.weights {
196        if w.len() != ecount {
197            return Err(IgraphError::InvalidArgument(
198                "weight vector length must equal edge count".into(),
199            ));
200        }
201        if ecount > 0 && w.iter().any(|&x| x <= 0.0) {
202            return Err(IgraphError::InvalidArgument(
203                "weights must be positive for Fruchterman-Reingold layout".into(),
204            ));
205        }
206    }
207
208    validate_bounds_3d(vcount, &params.bounds)?;
209
210    if let Some(ref sc) = params.seed_coords {
211        if sc.len() != vcount {
212            return Err(IgraphError::InvalidArgument(
213                "seed_coords length must equal vertex count".into(),
214            ));
215        }
216    }
217
218    if vcount == 0 {
219        return Ok(Vec::new());
220    }
221
222    let start_temp = params.start_temp.unwrap_or_else(|| (vcount as f64).sqrt());
223    fr_naive_3d(graph, params, start_temp)
224}
225
226// -- Internal implementations --
227
228fn fr_naive_2d(graph: &Graph, params: &FrParams, start_temp: f64) -> IgraphResult<Vec<(f64, f64)>> {
229    let vcount = graph.vcount() as usize;
230    let ecount = graph.ecount() as usize;
231
232    let cc = connected_components(graph)?;
233    let connected = cc.count == 1;
234    let big_c = if connected {
235        0.0
236    } else {
237        vcount as f64 * (vcount as f64).sqrt()
238    };
239
240    let mut pos: Vec<(f64, f64)> = if let Some(ref sc) = params.seed_coords {
241        sc.clone()
242    } else {
243        init_random_bounded_2d(vcount, &params.bounds, params.rng_seed)
244    };
245
246    let mut rng = SplitMix64::new(params.rng_seed.wrapping_add(0xDEAD_BEEF));
247    let mut dispx = vec![0.0f64; vcount];
248    let mut dispy = vec![0.0f64; vcount];
249    let niter = params.niter;
250    let diff_temp = if niter > 0 {
251        start_temp / niter as f64
252    } else {
253        0.0
254    };
255    let mut temp = start_temp;
256
257    for _ in 0..niter {
258        dispx.iter_mut().for_each(|d| *d = 0.0);
259        dispy.iter_mut().for_each(|d| *d = 0.0);
260
261        // Repulsive forces
262        if connected {
263            for v in 0..vcount {
264                for u in (v + 1)..vcount {
265                    let mut dx = pos[v].0 - pos[u].0;
266                    let mut dy = pos[v].1 - pos[u].1;
267                    let mut dlen = dx * dx + dy * dy;
268                    while dlen == 0.0 {
269                        dx = rng.gen_unit() * 2e-9 - 1e-9;
270                        dy = rng.gen_unit() * 2e-9 - 1e-9;
271                        dlen = dx * dx + dy * dy;
272                    }
273                    dispx[v] += dx / dlen;
274                    dispy[v] += dy / dlen;
275                    dispx[u] -= dx / dlen;
276                    dispy[u] -= dy / dlen;
277                }
278            }
279        } else {
280            for v in 0..vcount {
281                for u in (v + 1)..vcount {
282                    let mut dx = pos[v].0 - pos[u].0;
283                    let mut dy = pos[v].1 - pos[u].1;
284                    let mut dlen = dx * dx + dy * dy;
285                    while dlen == 0.0 {
286                        dx = rng.gen_unit() * 2e-9 - 1e-9;
287                        dy = rng.gen_unit() * 2e-9 - 1e-9;
288                        dlen = dx * dx + dy * dy;
289                    }
290                    let rdlen = dlen.sqrt();
291                    let factor = (big_c - dlen * rdlen) / (dlen * big_c);
292                    dispx[v] += dx * factor;
293                    dispy[v] += dy * factor;
294                    dispx[u] -= dx * factor;
295                    dispy[u] -= dy * factor;
296                }
297            }
298        }
299
300        // Attractive forces
301        for e in 0..ecount {
302            let (from, to) = graph.edge(e as u32)?;
303            let v = from as usize;
304            let u = to as usize;
305            let dx = pos[v].0 - pos[u].0;
306            let dy = pos[v].1 - pos[u].1;
307            let w = params.weights.as_ref().map_or(1.0, |ws| ws[e]);
308            let dlen = (dx * dx + dy * dy).sqrt() * w;
309            dispx[v] -= dx * dlen;
310            dispy[v] -= dy * dlen;
311            dispx[u] += dx * dlen;
312            dispy[u] += dy * dlen;
313        }
314
315        // Apply displacement capped by temperature
316        for v in 0..vcount {
317            let dx = dispx[v] + rng.gen_unit() * 2e-9 - 1e-9;
318            let dy = dispy[v] + rng.gen_unit() * 2e-9 - 1e-9;
319            let displen = (dx * dx + dy * dy).sqrt();
320            let (cdx, cdy) = if displen > temp {
321                (dx * temp / displen, dy * temp / displen)
322            } else {
323                (dx, dy)
324            };
325            if displen > 0.0 {
326                pos[v].0 += cdx;
327                pos[v].1 += cdy;
328            }
329            apply_bounds_2d(&mut pos[v], v, &params.bounds);
330        }
331
332        temp -= diff_temp;
333    }
334
335    Ok(pos)
336}
337
338fn fr_grid_2d(graph: &Graph, params: &FrParams, start_temp: f64) -> IgraphResult<Vec<(f64, f64)>> {
339    let vcount = graph.vcount() as usize;
340    let ecount = graph.ecount() as usize;
341    let width = (vcount as f64).sqrt();
342    let cellsize = 2.0f64;
343
344    let mut pos: Vec<(f64, f64)> = if let Some(ref sc) = params.seed_coords {
345        sc.clone()
346    } else {
347        init_random_bounded_2d(vcount, &params.bounds, params.rng_seed)
348    };
349
350    let mut rng = SplitMix64::new(params.rng_seed.wrapping_add(0xDEAD_BEEF));
351    let mut dispx = vec![0.0f64; vcount];
352    let mut dispy = vec![0.0f64; vcount];
353    let niter = params.niter;
354    let diff_temp = if niter > 0 {
355        start_temp / niter as f64
356    } else {
357        0.0
358    };
359    let mut temp = start_temp;
360
361    let half_w = width / 2.0;
362    let cell_threshold_sq = cellsize * cellsize;
363
364    for _ in 0..niter {
365        dispx.iter_mut().for_each(|d| *d = 0.0);
366        dispy.iter_mut().for_each(|d| *d = 0.0);
367
368        // Grid-based repulsion: only consider pairs within cell distance
369        let grid = Grid2d::build(&pos, -half_w, half_w, -half_w, half_w, cellsize);
370        for v in 0..vcount {
371            let neighbors = grid.nearby_vertices(v, &pos);
372            for &u in &neighbors {
373                if u <= v {
374                    continue;
375                }
376                let mut dx = pos[v].0 - pos[u].0;
377                let mut dy = pos[v].1 - pos[u].1;
378                let mut dlen = dx * dx + dy * dy;
379                while dlen == 0.0 {
380                    dx = rng.gen_unit() * 2e-9 - 1e-9;
381                    dy = rng.gen_unit() * 2e-9 - 1e-9;
382                    dlen = dx * dx + dy * dy;
383                }
384                if dlen < cell_threshold_sq {
385                    dispx[v] += dx / dlen;
386                    dispy[v] += dy / dlen;
387                    dispx[u] -= dx / dlen;
388                    dispy[u] -= dy / dlen;
389                }
390            }
391        }
392
393        // Attractive forces
394        for e in 0..ecount {
395            let (from, to) = graph.edge(e as u32)?;
396            let v = from as usize;
397            let u = to as usize;
398            let dx = pos[v].0 - pos[u].0;
399            let dy = pos[v].1 - pos[u].1;
400            let w = params.weights.as_ref().map_or(1.0, |ws| ws[e]);
401            let dlen = (dx * dx + dy * dy).sqrt() * w;
402            dispx[v] -= dx * dlen;
403            dispy[v] -= dy * dlen;
404            dispx[u] += dx * dlen;
405            dispy[u] += dy * dlen;
406        }
407
408        // Apply displacement
409        for v in 0..vcount {
410            let dx = dispx[v] + rng.gen_unit() * 2e-9 - 1e-9;
411            let dy = dispy[v] + rng.gen_unit() * 2e-9 - 1e-9;
412            let displen = (dx * dx + dy * dy).sqrt();
413            let (cdx, cdy) = if displen > temp {
414                (dx * temp / displen, dy * temp / displen)
415            } else {
416                (dx, dy)
417            };
418            if displen > 0.0 {
419                pos[v].0 += cdx;
420                pos[v].1 += cdy;
421            }
422            apply_bounds_2d(&mut pos[v], v, &params.bounds);
423        }
424
425        temp -= diff_temp;
426    }
427
428    Ok(pos)
429}
430
431fn fr_naive_3d(
432    graph: &Graph,
433    params: &FrParams3d,
434    start_temp: f64,
435) -> IgraphResult<Vec<(f64, f64, f64)>> {
436    let vcount = graph.vcount() as usize;
437    let ecount = graph.ecount() as usize;
438
439    let cc = connected_components(graph)?;
440    let connected = cc.count == 1;
441    let big_c = if connected {
442        0.0
443    } else {
444        vcount as f64 * (vcount as f64).sqrt()
445    };
446
447    let mut pos: Vec<(f64, f64, f64)> = if let Some(ref sc) = params.seed_coords {
448        sc.clone()
449    } else {
450        init_random_bounded_3d(vcount, &params.bounds, params.rng_seed)
451    };
452
453    let mut rng = SplitMix64::new(params.rng_seed.wrapping_add(0xDEAD_BEEF));
454    let mut dispx = vec![0.0f64; vcount];
455    let mut dispy = vec![0.0f64; vcount];
456    let mut dispz = vec![0.0f64; vcount];
457    let niter = params.niter;
458    let diff_temp = if niter > 0 {
459        start_temp / niter as f64
460    } else {
461        0.0
462    };
463    let mut temp = start_temp;
464
465    for _ in 0..niter {
466        dispx.iter_mut().for_each(|d| *d = 0.0);
467        dispy.iter_mut().for_each(|d| *d = 0.0);
468        dispz.iter_mut().for_each(|d| *d = 0.0);
469
470        // Repulsive forces
471        if connected {
472            for v in 0..vcount {
473                for u in (v + 1)..vcount {
474                    let mut dx = pos[v].0 - pos[u].0;
475                    let mut dy = pos[v].1 - pos[u].1;
476                    let mut dz = pos[v].2 - pos[u].2;
477                    let mut dlen = dx * dx + dy * dy + dz * dz;
478                    while dlen == 0.0 {
479                        dx = rng.gen_unit() * 2e-9 - 1e-9;
480                        dy = rng.gen_unit() * 2e-9 - 1e-9;
481                        dz = rng.gen_unit() * 2e-9 - 1e-9;
482                        dlen = dx * dx + dy * dy + dz * dz;
483                    }
484                    dispx[v] += dx / dlen;
485                    dispy[v] += dy / dlen;
486                    dispz[v] += dz / dlen;
487                    dispx[u] -= dx / dlen;
488                    dispy[u] -= dy / dlen;
489                    dispz[u] -= dz / dlen;
490                }
491            }
492        } else {
493            for v in 0..vcount {
494                for u in (v + 1)..vcount {
495                    let mut dx = pos[v].0 - pos[u].0;
496                    let mut dy = pos[v].1 - pos[u].1;
497                    let mut dz = pos[v].2 - pos[u].2;
498                    let mut dlen = dx * dx + dy * dy + dz * dz;
499                    while dlen == 0.0 {
500                        dx = rng.gen_unit() * 2e-9 - 1e-9;
501                        dy = rng.gen_unit() * 2e-9 - 1e-9;
502                        dz = rng.gen_unit() * 2e-9 - 1e-9;
503                        dlen = dx * dx + dy * dy + dz * dz;
504                    }
505                    let rdlen = dlen.sqrt();
506                    let factor = (big_c - dlen * rdlen) / (dlen * big_c);
507                    dispx[v] += dx * factor;
508                    dispy[v] += dy * factor;
509                    dispz[v] += dz * factor;
510                    dispx[u] -= dx * factor;
511                    dispy[u] -= dy * factor;
512                    dispz[u] -= dz * factor;
513                }
514            }
515        }
516
517        // Attractive forces
518        for e in 0..ecount {
519            let (from, to) = graph.edge(e as u32)?;
520            let v = from as usize;
521            let u = to as usize;
522            let dx = pos[v].0 - pos[u].0;
523            let dy = pos[v].1 - pos[u].1;
524            let dz = pos[v].2 - pos[u].2;
525            let w = params.weights.as_ref().map_or(1.0, |ws| ws[e]);
526            let dlen = (dx * dx + dy * dy + dz * dz).sqrt() * w;
527            dispx[v] -= dx * dlen;
528            dispy[v] -= dy * dlen;
529            dispz[v] -= dz * dlen;
530            dispx[u] += dx * dlen;
531            dispy[u] += dy * dlen;
532            dispz[u] += dz * dlen;
533        }
534
535        // Apply displacement
536        for v in 0..vcount {
537            let dx = dispx[v] + rng.gen_unit() * 2e-9 - 1e-9;
538            let dy = dispy[v] + rng.gen_unit() * 2e-9 - 1e-9;
539            let dz = dispz[v] + rng.gen_unit() * 2e-9 - 1e-9;
540            let displen = (dx * dx + dy * dy + dz * dz).sqrt();
541            let (cdx, cdy, cdz) = if displen > temp {
542                let scale = temp / displen;
543                (dx * scale, dy * scale, dz * scale)
544            } else {
545                (dx, dy, dz)
546            };
547            if displen > 0.0 {
548                pos[v].0 += cdx;
549                pos[v].1 += cdy;
550                pos[v].2 += cdz;
551            }
552            apply_bounds_3d(&mut pos[v], v, &params.bounds);
553        }
554
555        temp -= diff_temp;
556    }
557
558    Ok(pos)
559}
560
561// -- Helpers --
562
563fn validate_bounds_2d(vcount: usize, bounds: &FrBounds) -> IgraphResult<()> {
564    if let Some(ref v) = bounds.minx {
565        if v.len() != vcount {
566            return Err(IgraphError::InvalidArgument("minx length mismatch".into()));
567        }
568    }
569    if let Some(ref v) = bounds.maxx {
570        if v.len() != vcount {
571            return Err(IgraphError::InvalidArgument("maxx length mismatch".into()));
572        }
573    }
574    if let Some(ref v) = bounds.miny {
575        if v.len() != vcount {
576            return Err(IgraphError::InvalidArgument("miny length mismatch".into()));
577        }
578    }
579    if let Some(ref v) = bounds.maxy {
580        if v.len() != vcount {
581            return Err(IgraphError::InvalidArgument("maxy length mismatch".into()));
582        }
583    }
584    if let (Some(lo), Some(hi)) = (&bounds.minx, &bounds.maxx) {
585        if lo.iter().zip(hi.iter()).any(|(a, b)| a > b) {
586            return Err(IgraphError::InvalidArgument(
587                "minx must not exceed maxx".into(),
588            ));
589        }
590    }
591    if let (Some(lo), Some(hi)) = (&bounds.miny, &bounds.maxy) {
592        if lo.iter().zip(hi.iter()).any(|(a, b)| a > b) {
593            return Err(IgraphError::InvalidArgument(
594                "miny must not exceed maxy".into(),
595            ));
596        }
597    }
598    Ok(())
599}
600
601fn validate_bounds_3d(vcount: usize, bounds: &FrBounds3d) -> IgraphResult<()> {
602    for (name, vec) in [
603        ("minx", &bounds.minx),
604        ("maxx", &bounds.maxx),
605        ("miny", &bounds.miny),
606        ("maxy", &bounds.maxy),
607        ("minz", &bounds.minz),
608        ("maxz", &bounds.maxz),
609    ] {
610        if let Some(v) = vec {
611            if v.len() != vcount {
612                let msg = match name {
613                    "minx" => "minx length mismatch",
614                    "maxx" => "maxx length mismatch",
615                    "miny" => "miny length mismatch",
616                    "maxy" => "maxy length mismatch",
617                    "minz" => "minz length mismatch",
618                    _ => "maxz length mismatch",
619                };
620                return Err(IgraphError::InvalidArgument(msg.into()));
621            }
622        }
623    }
624    if let (Some(lo), Some(hi)) = (&bounds.minx, &bounds.maxx) {
625        if lo.iter().zip(hi.iter()).any(|(a, b)| a > b) {
626            return Err(IgraphError::InvalidArgument(
627                "minx must not exceed maxx".into(),
628            ));
629        }
630    }
631    if let (Some(lo), Some(hi)) = (&bounds.miny, &bounds.maxy) {
632        if lo.iter().zip(hi.iter()).any(|(a, b)| a > b) {
633            return Err(IgraphError::InvalidArgument(
634                "miny must not exceed maxy".into(),
635            ));
636        }
637    }
638    if let (Some(lo), Some(hi)) = (&bounds.minz, &bounds.maxz) {
639        if lo.iter().zip(hi.iter()).any(|(a, b)| a > b) {
640            return Err(IgraphError::InvalidArgument(
641                "minz must not exceed maxz".into(),
642            ));
643        }
644    }
645    Ok(())
646}
647
648fn init_random_bounded_2d(vcount: usize, bounds: &FrBounds, seed: u64) -> Vec<(f64, f64)> {
649    let mut rng = SplitMix64::new(seed);
650    (0..vcount)
651        .map(|i| {
652            let lo_x = bounds.minx.as_ref().map_or(-1.0, |v| v[i]);
653            let hi_x = bounds.maxx.as_ref().map_or(1.0, |v| v[i]);
654            let lo_y = bounds.miny.as_ref().map_or(-1.0, |v| v[i]);
655            let hi_y = bounds.maxy.as_ref().map_or(1.0, |v| v[i]);
656            let x = lo_x + rng.gen_unit() * (hi_x - lo_x);
657            let y = lo_y + rng.gen_unit() * (hi_y - lo_y);
658            (x, y)
659        })
660        .collect()
661}
662
663fn init_random_bounded_3d(vcount: usize, bounds: &FrBounds3d, seed: u64) -> Vec<(f64, f64, f64)> {
664    let mut rng = SplitMix64::new(seed);
665    (0..vcount)
666        .map(|i| {
667            let lo_x = bounds.minx.as_ref().map_or(-1.0, |v| v[i]);
668            let hi_x = bounds.maxx.as_ref().map_or(1.0, |v| v[i]);
669            let lo_y = bounds.miny.as_ref().map_or(-1.0, |v| v[i]);
670            let hi_y = bounds.maxy.as_ref().map_or(1.0, |v| v[i]);
671            let lo_z = bounds.minz.as_ref().map_or(-1.0, |v| v[i]);
672            let hi_z = bounds.maxz.as_ref().map_or(1.0, |v| v[i]);
673            let x = lo_x + rng.gen_unit() * (hi_x - lo_x);
674            let y = lo_y + rng.gen_unit() * (hi_y - lo_y);
675            let z = lo_z + rng.gen_unit() * (hi_z - lo_z);
676            (x, y, z)
677        })
678        .collect()
679}
680
681fn apply_bounds_2d(pos: &mut (f64, f64), i: usize, bounds: &FrBounds) {
682    if let Some(ref v) = bounds.minx {
683        if pos.0 < v[i] {
684            pos.0 = v[i];
685        }
686    }
687    if let Some(ref v) = bounds.maxx {
688        if pos.0 > v[i] {
689            pos.0 = v[i];
690        }
691    }
692    if let Some(ref v) = bounds.miny {
693        if pos.1 < v[i] {
694            pos.1 = v[i];
695        }
696    }
697    if let Some(ref v) = bounds.maxy {
698        if pos.1 > v[i] {
699            pos.1 = v[i];
700        }
701    }
702}
703
704fn apply_bounds_3d(pos: &mut (f64, f64, f64), i: usize, bounds: &FrBounds3d) {
705    if let Some(ref v) = bounds.minx {
706        if pos.0 < v[i] {
707            pos.0 = v[i];
708        }
709    }
710    if let Some(ref v) = bounds.maxx {
711        if pos.0 > v[i] {
712            pos.0 = v[i];
713        }
714    }
715    if let Some(ref v) = bounds.miny {
716        if pos.1 < v[i] {
717            pos.1 = v[i];
718        }
719    }
720    if let Some(ref v) = bounds.maxy {
721        if pos.1 > v[i] {
722            pos.1 = v[i];
723        }
724    }
725    if let Some(ref v) = bounds.minz {
726        if pos.2 < v[i] {
727            pos.2 = v[i];
728        }
729    }
730    if let Some(ref v) = bounds.maxz {
731        if pos.2 > v[i] {
732            pos.2 = v[i];
733        }
734    }
735}
736
737// Simple 2D spatial grid for acceleration
738struct Grid2d {
739    cells: Vec<Vec<usize>>,
740    nx: usize,
741    ny: usize,
742    x_min: f64,
743    y_min: f64,
744    cellsize: f64,
745}
746
747impl Grid2d {
748    fn build(
749        pos: &[(f64, f64)],
750        x_min: f64,
751        x_max: f64,
752        y_min: f64,
753        y_max: f64,
754        cellsize: f64,
755    ) -> Self {
756        let nx = ((x_max - x_min) / cellsize).ceil().max(1.0) as usize;
757        let ny = ((y_max - y_min) / cellsize).ceil().max(1.0) as usize;
758        let mut cells = vec![Vec::new(); nx * ny];
759        for (i, &(x, y)) in pos.iter().enumerate() {
760            let cx = ((x - x_min) / cellsize).floor() as isize;
761            let cy = ((y - y_min) / cellsize).floor() as isize;
762            let cx = cx.clamp(0, nx as isize - 1) as usize;
763            let cy = cy.clamp(0, ny as isize - 1) as usize;
764            cells[cy * nx + cx].push(i);
765        }
766        Grid2d {
767            cells,
768            nx,
769            ny,
770            x_min,
771            y_min,
772            cellsize,
773        }
774    }
775
776    fn nearby_vertices(&self, v: usize, pos: &[(f64, f64)]) -> Vec<usize> {
777        let (x, y) = pos[v];
778        let cx = ((x - self.x_min) / self.cellsize).floor() as isize;
779        let cy = ((y - self.y_min) / self.cellsize).floor() as isize;
780        let mut result = Vec::new();
781        for dy in -1..=1i64 {
782            for dx in -1..=1i64 {
783                let nx = cx + dx as isize;
784                let ny = cy + dy as isize;
785                if nx >= 0 && nx < self.nx as isize && ny >= 0 && ny < self.ny as isize {
786                    let idx = ny as usize * self.nx + nx as usize;
787                    for &u in &self.cells[idx] {
788                        if u != v {
789                            result.push(u);
790                        }
791                    }
792                }
793            }
794        }
795        result
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    fn make_cycle(n: u32) -> Graph {
804        let mut g = Graph::with_vertices(n);
805        for i in 0..n {
806            g.add_edge(i, (i + 1) % n).unwrap();
807        }
808        g
809    }
810
811    fn make_path(n: u32) -> Graph {
812        let mut g = Graph::with_vertices(n);
813        for i in 0..n - 1 {
814            g.add_edge(i, i + 1).unwrap();
815        }
816        g
817    }
818
819    #[test]
820    fn empty_graph() {
821        let g = Graph::with_vertices(0);
822        let coords = layout_fruchterman_reingold(&g, &FrParams::default()).unwrap();
823        assert!(coords.is_empty());
824    }
825
826    #[test]
827    fn single_vertex() {
828        let g = Graph::with_vertices(1);
829        let coords = layout_fruchterman_reingold(&g, &FrParams::default()).unwrap();
830        assert_eq!(coords.len(), 1);
831    }
832
833    #[test]
834    fn cycle5_produces_finite_coords() {
835        let g = make_cycle(5);
836        let coords = layout_fruchterman_reingold(&g, &FrParams::default()).unwrap();
837        assert_eq!(coords.len(), 5);
838        for &(x, y) in &coords {
839            assert!(x.is_finite());
840            assert!(y.is_finite());
841        }
842    }
843
844    #[test]
845    fn disconnected_graph_doesnt_panic() {
846        let mut g = Graph::with_vertices(6);
847        g.add_edge(0, 1).unwrap();
848        g.add_edge(2, 3).unwrap();
849        g.add_edge(4, 5).unwrap();
850        let coords = layout_fruchterman_reingold(&g, &FrParams::default()).unwrap();
851        assert_eq!(coords.len(), 6);
852        for &(x, y) in &coords {
853            assert!(x.is_finite());
854            assert!(y.is_finite());
855        }
856    }
857
858    #[test]
859    fn grid_mode_works() {
860        let g = make_path(10);
861        let params = FrParams {
862            grid: FrGrid::Grid,
863            niter: 50,
864            ..Default::default()
865        };
866        let coords = layout_fruchterman_reingold(&g, &params).unwrap();
867        assert_eq!(coords.len(), 10);
868        for &(x, y) in &coords {
869            assert!(x.is_finite());
870            assert!(y.is_finite());
871        }
872    }
873
874    #[test]
875    fn weighted_edges() {
876        let g = make_cycle(4);
877        let params = FrParams {
878            weights: Some(vec![1.0, 2.0, 1.0, 2.0]),
879            niter: 100,
880            ..Default::default()
881        };
882        let coords = layout_fruchterman_reingold(&g, &params).unwrap();
883        assert_eq!(coords.len(), 4);
884    }
885
886    #[test]
887    fn invalid_weights_rejected() {
888        let g = make_cycle(4);
889        let params = FrParams {
890            weights: Some(vec![1.0, -1.0, 1.0, 1.0]),
891            ..Default::default()
892        };
893        assert!(layout_fruchterman_reingold(&g, &params).is_err());
894    }
895
896    #[test]
897    fn bounds_constrain_output() {
898        let g = make_path(5);
899        let params = FrParams {
900            niter: 200,
901            bounds: FrBounds {
902                minx: Some(vec![0.0; 5]),
903                maxx: Some(vec![1.0; 5]),
904                miny: Some(vec![0.0; 5]),
905                maxy: Some(vec![1.0; 5]),
906            },
907            ..Default::default()
908        };
909        let coords = layout_fruchterman_reingold(&g, &params).unwrap();
910        for &(x, y) in &coords {
911            assert!((0.0..=1.0).contains(&x), "x={x} out of bounds");
912            assert!((0.0..=1.0).contains(&y), "y={y} out of bounds");
913        }
914    }
915
916    #[test]
917    fn layout_3d_basic() {
918        let g = make_cycle(5);
919        let coords = layout_fruchterman_reingold_3d(&g, &FrParams3d::default()).unwrap();
920        assert_eq!(coords.len(), 5);
921        for &(x, y, z) in &coords {
922            assert!(x.is_finite());
923            assert!(y.is_finite());
924            assert!(z.is_finite());
925        }
926    }
927
928    #[test]
929    fn seed_coords_used() {
930        let g = make_path(3);
931        let seed = vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)];
932        let params = FrParams {
933            niter: 0,
934            seed_coords: Some(seed.clone()),
935            ..Default::default()
936        };
937        let coords = layout_fruchterman_reingold(&g, &params).unwrap();
938        assert_eq!(coords, seed);
939    }
940}