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