Skip to main content

graph_core/
force.rs

1//! Barnes-Hut 2D force simulation.
2//!
3//! Port of the d3-force semantics onto a quadtree-accelerated
4//! n-body repulsion kernel, plus link springs and a center-of-mass
5//! gravity. The result is deterministic given a seed so the WASM and
6//! native call sites (Wave 2+) produce identical layouts for the
7//! same graph.
8//!
9//! ## Single-threaded for Wave 1
10//!
11//! The naive per-tick traversal on 50k nodes is well under 5 ms on
12//! a modern laptop core so we leave it single-threaded here. A
13//! rayon-parallel variant would live behind `#[cfg(feature = "native")]`
14//! once we have profile data to justify it. Wave 1's priority is
15//! correctness + WASM-compatibility of the algorithm, not raw peak
16//! throughput on a dense 100k-node graph.
17//!
18//! ## SIMD choice
19//!
20//! Positions are 2-float vectors, so per-element SIMD (`f32x2`)
21//! buys nothing — the dot product over two floats is one FMA already.
22//! We use `glam::Vec2` scalar math everywhere in this file and keep
23//! `graph_core::util::dot_simd` for the wide-float kernels (cosine,
24//! BM25, bloom) that need it. A future revision that batches the
25//! center-gravity force over all positions at once and runs it as a
26//! fused reduction could reach for `dot_simd`; the per-node force
27//! loop wouldn't benefit.
28
29use glam::{Vec2, Vec3};
30use rand::{Rng, SeedableRng};
31use rand_chacha::ChaCha8Rng;
32
33use crate::force_3d::{OctNode, accumulate_repulsion_3d, build_octree};
34
35/// Minimum quadtree cell size, in world units. Below this the tree
36/// stops splitting and treats coincident bodies as a single
37/// accumulated point mass. 1.0 is plenty given initial positions
38/// are scattered on a radius-10+ disc.
39const MIN_CELL_SIZE: f32 = 1.0;
40
41/// Force simulation configuration. The defaults match d3-force's
42/// defaults so graphs that used to render there look the same here.
43#[derive(Debug, Clone, Copy)]
44pub struct SimulationConfig {
45    /// Negative values repel, positive values attract. d3 default -30.
46    pub repulsion_strength: f32,
47    /// Spring stiffness scaling on the link force (0..1 range is typical).
48    pub link_strength: f32,
49    /// Natural length of each link edge in world units.
50    pub link_distance: f32,
51    /// Center-of-mass gravity pulling nodes toward the origin.
52    pub center_strength: f32,
53    /// Per-tick velocity damping. 0.4 matches d3's default velocity decay.
54    pub velocity_decay: f32,
55    /// Barnes-Hut approximation threshold. Larger = faster but less
56    /// accurate. d3 uses 0.9 under the name `theta`.
57    pub theta: f32,
58    /// RNG seed used to scatter initial positions deterministically.
59    pub seed: u64,
60    /// Dimensionality of the simulation: 2 (default, quadtree) or 3
61    /// (octree). Phase 224 Wave 2 toggle — anything else is clamped
62    /// to 2. Positions keep the 2D layout when `dimensions == 2` and
63    /// switch to 3-vector positions (accessible via `positions_3d()`)
64    /// when `dimensions == 3`.
65    pub dimensions: u8,
66}
67
68impl Default for SimulationConfig {
69    fn default() -> Self {
70        Self {
71            repulsion_strength: -30.0,
72            link_strength: 0.1,
73            link_distance: 30.0,
74            center_strength: 0.05,
75            velocity_decay: 0.4,
76            theta: 0.9,
77            seed: 0,
78            dimensions: 2,
79        }
80    }
81}
82
83/// Single edge in the force graph. `weight` scales the link spring
84/// force so heavier edges pull endpoints together faster.
85#[derive(Debug, Clone, Copy)]
86struct Edge {
87    src: u32,
88    dst: u32,
89    weight: f32,
90}
91
92/// Public force simulation handle. Owns positions, velocities, edges,
93/// and a scratch quadtree that gets rebuilt every tick.
94///
95/// In 2D mode (`config.dimensions == 2`, the default) only the
96/// `positions`/`velocities`/`forces` vectors and the `tree` quadtree
97/// are used. In 3D mode the parallel `positions_3d`/`velocities_3d`/
98/// `forces_3d` vectors are populated alongside an `oct_tree` octree,
99/// and `positions` mirrors the x/y pair of each 3D position so the
100/// `positions()` accessor stays zero-copy for the common 2D readers.
101pub struct Simulation {
102    config: SimulationConfig,
103    positions: Vec<Vec2>,
104    velocities: Vec<Vec2>,
105    // Per-tick scratch force accumulator, kept to avoid reallocating.
106    forces: Vec<Vec2>,
107    edges: Vec<Edge>,
108    // Scratch arena for the Barnes-Hut quadtree. Cleared and refilled
109    // every tick; retained here to amortise allocation.
110    tree: Vec<QuadNode>,
111    // 3D-only state. Empty vectors when `config.dimensions == 2`.
112    positions_3d: Vec<Vec3>,
113    velocities_3d: Vec<Vec3>,
114    forces_3d: Vec<Vec3>,
115    oct_tree: Vec<OctNode>,
116}
117
118impl Simulation {
119    /// Build a simulation with `n_nodes` nodes. Initial positions are
120    /// scattered uniformly inside a circle of radius `sqrt(n) * 10`
121    /// using a deterministic ChaCha8 RNG seeded from `config.seed`.
122    ///
123    /// When `config.dimensions == 3`, the 3D `positions_3d` field is
124    /// also populated with points scattered inside a ball of the same
125    /// radius; the 2D `positions` field mirrors the x/y slice of each
126    /// 3D point so consumers of the 2D accessor continue to see a
127    /// sensible layout.
128    pub fn new(n_nodes: u32, config: SimulationConfig) -> Self {
129        let n = n_nodes as usize;
130        let mut rng = ChaCha8Rng::seed_from_u64(config.seed);
131        let radius = (n_nodes as f32).max(1.0).sqrt() * 10.0;
132
133        // Clamp dimensions; anything outside {2, 3} degrades gracefully to 2D.
134        let dims = if config.dimensions == 3 { 3u8 } else { 2u8 };
135        let mut cfg = config;
136        cfg.dimensions = dims;
137
138        let mut positions = Vec::with_capacity(n);
139        let mut positions_3d: Vec<Vec3> = if dims == 3 {
140            Vec::with_capacity(n)
141        } else {
142            Vec::new()
143        };
144
145        for _ in 0..n {
146            if dims == 3 {
147                // Rejection-sample a point in the unit ball, then scale.
148                // Same pattern as the 2D case; keeps the disc/ball
149                // analogue visually consistent.
150                loop {
151                    let x: f32 = rng.gen_range(-1.0..=1.0);
152                    let y: f32 = rng.gen_range(-1.0..=1.0);
153                    let z: f32 = rng.gen_range(-1.0..=1.0);
154                    if x * x + y * y + z * z <= 1.0 {
155                        let p = Vec3::new(x * radius, y * radius, z * radius);
156                        positions_3d.push(p);
157                        positions.push(Vec2::new(p.x, p.y));
158                        break;
159                    }
160                }
161            } else {
162                // Rejection-sample a point in the unit disc, then scale.
163                // Gives a visibly uniform cloud rather than the polar
164                // artefacts you get from sampling (r, θ) independently.
165                loop {
166                    let x: f32 = rng.gen_range(-1.0..=1.0);
167                    let y: f32 = rng.gen_range(-1.0..=1.0);
168                    if x * x + y * y <= 1.0 {
169                        positions.push(Vec2::new(x * radius, y * radius));
170                        break;
171                    }
172                }
173            }
174        }
175
176        let (velocities_3d, forces_3d) = if dims == 3 {
177            (vec![Vec3::ZERO; n], vec![Vec3::ZERO; n])
178        } else {
179            (Vec::new(), Vec::new())
180        };
181
182        Self {
183            config: cfg,
184            positions,
185            velocities: vec![Vec2::ZERO; n],
186            forces: vec![Vec2::ZERO; n],
187            edges: Vec::new(),
188            tree: Vec::new(),
189            positions_3d,
190            velocities_3d,
191            forces_3d,
192            oct_tree: Vec::new(),
193        }
194    }
195
196    /// Replace the edge set. Each edge is `(src, dst, weight)`.
197    /// Out-of-range or self-loop indices are silently dropped — callers
198    /// who care should validate upstream.
199    pub fn set_edges(&mut self, edges: &[(u32, u32, f32)]) {
200        let n = self.positions.len() as u32;
201        self.edges.clear();
202        self.edges.reserve(edges.len());
203        for &(src, dst, weight) in edges {
204            if src < n && dst < n && src != dst {
205                self.edges.push(Edge { src, dst, weight });
206            }
207        }
208    }
209
210    /// Number of nodes in the simulation.
211    #[inline]
212    pub fn node_count(&self) -> usize {
213        self.positions.len()
214    }
215
216    /// Borrow positions as a flat `&[Vec2]` for zero-copy consumption
217    /// by downstream bindings.
218    #[inline]
219    pub fn positions(&self) -> &[Vec2] {
220        &self.positions
221    }
222
223    /// Borrow 3D positions as a flat `&[Vec3]`. Returns an empty slice
224    /// when the simulation was constructed in 2D mode. Phase 224 Wave 2.
225    #[inline]
226    pub fn positions_3d(&self) -> &[Vec3] {
227        &self.positions_3d
228    }
229
230    /// Advance the simulation by one tick of size `dt`. Routes to the
231    /// 3D octree pass when `config.dimensions == 3`, otherwise runs
232    /// the 2D quadtree path.
233    pub fn tick(&mut self, dt: f32) {
234        if self.config.dimensions == 3 {
235            self.tick_3d(dt);
236            return;
237        }
238        self.tick_2d(dt);
239    }
240
241    fn tick_2d(&mut self, dt: f32) {
242        let n = self.positions.len();
243        if n == 0 {
244            return;
245        }
246
247        // Reset force accumulator.
248        for f in self.forces.iter_mut() {
249            *f = Vec2::ZERO;
250        }
251
252        // --- Repulsion via Barnes-Hut quadtree ----------------------
253        self.build_tree();
254        let theta2 = self.config.theta * self.config.theta;
255        let repulsion = self.config.repulsion_strength;
256        if !self.tree.is_empty() {
257            for i in 0..n {
258                let pos = self.positions[i];
259                let f = accumulate_repulsion(&self.tree, 0, pos, i as u32, theta2, repulsion);
260                self.forces[i] += f;
261            }
262        }
263
264        // --- Link springs ------------------------------------------
265        let link_k = self.config.link_strength;
266        let link_d = self.config.link_distance;
267        for e in &self.edges {
268            let a = self.positions[e.src as usize];
269            let b = self.positions[e.dst as usize];
270            let delta = b - a;
271            let dist = delta.length().max(1e-6);
272            // Hooke's law: force magnitude is (dist - natural) * k * w,
273            // along the edge direction. Positive (dist - link_d) pulls
274            // endpoints together.
275            let mag = (dist - link_d) * link_k * e.weight;
276            let dir = delta / dist;
277            self.forces[e.src as usize] += dir * mag;
278            self.forces[e.dst as usize] -= dir * mag;
279        }
280
281        // --- Center gravity ----------------------------------------
282        let c = self.config.center_strength;
283        if c != 0.0 {
284            for i in 0..n {
285                self.forces[i] -= self.positions[i] * c;
286            }
287        }
288
289        // --- Semi-implicit Euler integration ------------------------
290        let decay = self.config.velocity_decay;
291        for i in 0..n {
292            self.velocities[i] *= decay;
293            self.velocities[i] += self.forces[i] * dt;
294            self.positions[i] += self.velocities[i] * dt;
295        }
296    }
297
298    /// 3D counterpart of [`Self::tick_2d`]. Uses a Barnes-Hut octree
299    /// (`force_3d.rs`) for repulsion and otherwise mirrors the 2D
300    /// semi-implicit Euler integration, link springs, and center
301    /// gravity. Also mirrors the x/y slice of each 3D position back
302    /// into the 2D `positions` array so zero-copy consumers that
303    /// only care about the projection still get updated values.
304    fn tick_3d(&mut self, dt: f32) {
305        let n = self.positions_3d.len();
306        if n == 0 {
307            return;
308        }
309
310        for f in self.forces_3d.iter_mut() {
311            *f = Vec3::ZERO;
312        }
313
314        // --- Repulsion via Barnes-Hut octree -----------------------
315        build_octree(&mut self.oct_tree, &self.positions_3d, MIN_CELL_SIZE);
316        let theta2 = self.config.theta * self.config.theta;
317        let repulsion = self.config.repulsion_strength;
318        if !self.oct_tree.is_empty() {
319            for i in 0..n {
320                let pos = self.positions_3d[i];
321                let f = accumulate_repulsion_3d(
322                    &self.oct_tree,
323                    0,
324                    pos,
325                    i as u32,
326                    theta2,
327                    repulsion,
328                );
329                self.forces_3d[i] += f;
330            }
331        }
332
333        // --- Link springs ------------------------------------------
334        let link_k = self.config.link_strength;
335        let link_d = self.config.link_distance;
336        for e in &self.edges {
337            let a = self.positions_3d[e.src as usize];
338            let b = self.positions_3d[e.dst as usize];
339            let delta = b - a;
340            let dist = delta.length().max(1e-6);
341            let mag = (dist - link_d) * link_k * e.weight;
342            let dir = delta / dist;
343            self.forces_3d[e.src as usize] += dir * mag;
344            self.forces_3d[e.dst as usize] -= dir * mag;
345        }
346
347        // --- Center gravity ----------------------------------------
348        let c = self.config.center_strength;
349        if c != 0.0 {
350            for i in 0..n {
351                self.forces_3d[i] -= self.positions_3d[i] * c;
352            }
353        }
354
355        // --- Semi-implicit Euler integration ------------------------
356        let decay = self.config.velocity_decay;
357        for i in 0..n {
358            self.velocities_3d[i] *= decay;
359            self.velocities_3d[i] += self.forces_3d[i] * dt;
360            self.positions_3d[i] += self.velocities_3d[i] * dt;
361            // Mirror x/y into the 2D projection so callers going
362            // through positions() still see live values.
363            self.positions[i] = Vec2::new(self.positions_3d[i].x, self.positions_3d[i].y);
364        }
365    }
366
367    /// Build a flat-arena Barnes-Hut quadtree covering all current
368    /// positions. The tree is rebuilt every tick (positions drift by
369    /// more than a cell between ticks so incremental maintenance
370    /// wouldn't help).
371    fn build_tree(&mut self) {
372        self.tree.clear();
373        let n = self.positions.len();
374        if n == 0 {
375            return;
376        }
377
378        let mut min = self.positions[0];
379        let mut max = self.positions[0];
380        for p in &self.positions[1..] {
381            min = min.min(*p);
382            max = max.max(*p);
383        }
384        // Square up the bounding box so quadrants stay square. Also
385        // guard against degenerate (all-coincident) inputs.
386        let extent = (max - min).max_element().max(MIN_CELL_SIZE);
387        let center = (min + max) * 0.5;
388        let half = Vec2::splat(extent * 0.5);
389        let min = center - half;
390        let max = center + half;
391
392        self.tree.push(QuadNode::empty(min, max));
393        for i in 0..n {
394            let p = self.positions[i];
395            insert(&mut self.tree, 0, i as u32, p);
396        }
397
398        // Finalise aggregate mass / centre-of-mass on internal nodes.
399        finalise(&mut self.tree, 0);
400    }
401}
402
403// ------------------------------------------------------------------
404// Quadtree internals
405// ------------------------------------------------------------------
406
407/// A single quadtree node. Empty leaves have `body == None` and
408/// `has_children == false`; one-body leaves have `body == Some(_)`
409/// and `has_children == false`; internal nodes have `has_children
410/// == true` and any mix of child slots populated. `mass` is the
411/// aggregate body count of the subtree (unit mass per body) and
412/// `com` the unweighted centroid, both filled in by `finalise`.
413#[derive(Debug, Clone, Copy)]
414struct QuadNode {
415    min: Vec2,
416    max: Vec2,
417    mass: f32,
418    com: Vec2,
419    body: Option<u32>,
420    children: [u32; 4], // u32::MAX sentinel for "no child"
421    has_children: bool,
422}
423
424impl QuadNode {
425    fn empty(min: Vec2, max: Vec2) -> Self {
426        Self {
427            min,
428            max,
429            mass: 0.0,
430            com: Vec2::ZERO,
431            body: None,
432            children: [u32::MAX; 4],
433            has_children: false,
434        }
435    }
436
437    #[inline]
438    fn size(&self) -> f32 {
439        (self.max - self.min).max_element()
440    }
441
442    #[inline]
443    fn center(&self) -> Vec2 {
444        (self.min + self.max) * 0.5
445    }
446
447    /// Return 0..4 based on which quadrant `p` falls into relative to
448    /// this node's centre. Bit 0 = east (x >= cx), bit 1 = north (y >= cy).
449    #[inline]
450    fn quadrant_of(&self, p: Vec2) -> usize {
451        let c = self.center();
452        let east = (p.x >= c.x) as usize;
453        let north = (p.y >= c.y) as usize;
454        (north << 1) | east
455    }
456
457    #[inline]
458    fn child_bounds(&self, q: usize) -> (Vec2, Vec2) {
459        let c = self.center();
460        let (x_min, x_max) = if q & 1 == 1 {
461            (c.x, self.max.x)
462        } else {
463            (self.min.x, c.x)
464        };
465        let (y_min, y_max) = if q & 2 == 2 {
466            (c.y, self.max.y)
467        } else {
468            (self.min.y, c.y)
469        };
470        (Vec2::new(x_min, y_min), Vec2::new(x_max, y_max))
471    }
472}
473
474/// Insert a body into the subtree rooted at `tree[idx]`.
475///
476/// Three cases:
477///
478/// 1. Empty leaf → drop the body in, record mass/com directly.
479/// 2. One-body leaf with room to split → split into children and
480///    reinsert both bodies.
481/// 3. Internal node → recurse into the matching quadrant.
482///
483/// The "room to split" test is `size() > MIN_CELL_SIZE`. Below that
484/// threshold we accumulate the new body into the leaf's mass/com
485/// without splitting further — this avoids infinite recursion on
486/// coincident positions. The accumulated leaf is still treated as
487/// a single point in the force loop.
488fn insert(tree: &mut Vec<QuadNode>, idx: usize, body: u32, pos: Vec2) {
489    // Case 1: empty leaf.
490    if !tree[idx].has_children && tree[idx].body.is_none() && tree[idx].mass == 0.0 {
491        tree[idx].body = Some(body);
492        tree[idx].com = pos;
493        tree[idx].mass = 1.0;
494        return;
495    }
496
497    // Case 2: one-body (or multi-body-accumulated) leaf. Split if we
498    // still have room.
499    if !tree[idx].has_children {
500        if tree[idx].size() <= MIN_CELL_SIZE {
501            // Too small to split further — accumulate. Weighted
502            // average of existing COM and the new body.
503            let new_mass = tree[idx].mass + 1.0;
504            tree[idx].com = (tree[idx].com * tree[idx].mass + pos) / new_mass;
505            tree[idx].mass = new_mass;
506            // `body` stays as the first one inserted; the self-skip
507            // in `accumulate_repulsion` will still skip that body
508            // and the rest of the pile contributes force as part of
509            // the aggregated leaf mass (close enough for physics).
510            return;
511        }
512        let existing = tree[idx].body.take().expect("leaf without a body");
513        let existing_pos = tree[idx].com;
514        tree[idx].mass = 0.0;
515        tree[idx].com = Vec2::ZERO;
516        tree[idx].has_children = true;
517
518        // Reinsert the two bodies into their quadrants. `quadrant_of`
519        // on the same two distinct positions must eventually route
520        // them into distinct cells, because we bail at MIN_CELL_SIZE
521        // (case 2 above on the next level down).
522        let q_existing = tree[idx].quadrant_of(existing_pos);
523        let c_existing = create_or_get_child(tree, idx, q_existing);
524        insert(tree, c_existing, existing, existing_pos);
525
526        let q_new = tree[idx].quadrant_of(pos);
527        let c_new = create_or_get_child(tree, idx, q_new);
528        insert(tree, c_new, body, pos);
529        return;
530    }
531
532    // Case 3: internal node — route into the matching quadrant.
533    let q = tree[idx].quadrant_of(pos);
534    let c = create_or_get_child(tree, idx, q);
535    insert(tree, c, body, pos);
536}
537
538/// Return the existing child index at `quadrant` or allocate a new
539/// child cell with the right sub-bounds.
540fn create_or_get_child(tree: &mut Vec<QuadNode>, parent: usize, quadrant: usize) -> usize {
541    let existing = tree[parent].children[quadrant];
542    if existing != u32::MAX {
543        return existing as usize;
544    }
545    let (cmin, cmax) = tree[parent].child_bounds(quadrant);
546    let idx = tree.len();
547    tree.push(QuadNode::empty(cmin, cmax));
548    tree[parent].children[quadrant] = idx as u32;
549    idx
550}
551
552/// Post-order pass: sum child masses and centres up into each
553/// internal node. Leaves already have mass/com set during insertion.
554fn finalise(tree: &mut [QuadNode], idx: usize) {
555    if !tree[idx].has_children {
556        return;
557    }
558    let children = tree[idx].children;
559    let mut mass = 0.0;
560    let mut com = Vec2::ZERO;
561    for &c in &children {
562        if c == u32::MAX {
563            continue;
564        }
565        finalise(tree, c as usize);
566        let child = tree[c as usize];
567        mass += child.mass;
568        com += child.com * child.mass;
569    }
570    if mass > 0.0 {
571        com /= mass;
572    }
573    tree[idx].mass = mass;
574    tree[idx].com = com;
575}
576
577/// Walk the Barnes-Hut tree and accumulate the repulsion force on
578/// the node at `target_pos` (index `target_idx`, so we can skip
579/// self-interactions at the leaf). `theta2` is the squared ratio
580/// threshold for the approximation: when
581/// `cell_size^2 < theta^2 * distance^2` the whole subtree is
582/// collapsed to its centre-of-mass point charge.
583fn accumulate_repulsion(
584    tree: &[QuadNode],
585    idx: usize,
586    target_pos: Vec2,
587    target_idx: u32,
588    theta2: f32,
589    repulsion: f32,
590) -> Vec2 {
591    let node = &tree[idx];
592    if node.mass <= 0.0 {
593        return Vec2::ZERO;
594    }
595
596    // Leaf handling: direct pairwise force, skip self. Multi-body
597    // accumulated leaves still skip the stored `body` identity; the
598    // remaining mass at that spot contributes a small residual
599    // force at ~zero distance, which is capped by the softening
600    // term (`+ 0.01`) in `pair_force`.
601    if !node.has_children {
602        if let Some(body) = node.body {
603            if body == target_idx {
604                // Subtract self: mass==1.0 for a single-body leaf,
605                // > 1 for a pile-up. Everything beyond self still
606                // contributes.
607                let residual_mass = node.mass - 1.0;
608                if residual_mass <= 0.0 {
609                    return Vec2::ZERO;
610                }
611                return pair_force(target_pos, node.com, residual_mass, repulsion);
612            }
613        }
614        return pair_force(target_pos, node.com, node.mass, repulsion);
615    }
616
617    let delta = node.com - target_pos;
618    let dist2 = delta.length_squared();
619    let size = node.size();
620    // Barnes-Hut approximation check: treat subtree as a point when
621    // it's far enough away relative to its size.
622    if size * size < theta2 * dist2 {
623        return pair_force(target_pos, node.com, node.mass, repulsion);
624    }
625
626    // Otherwise recurse into whichever children exist.
627    let mut total = Vec2::ZERO;
628    for &c in &node.children {
629        if c == u32::MAX {
630            continue;
631        }
632        total += accumulate_repulsion(tree, c as usize, target_pos, target_idx, theta2, repulsion);
633    }
634    total
635}
636
637/// Coulomb-like pairwise force on `from` due to a point mass `mass`
638/// at `to`. With `repulsion < 0` (d3 default -30) the returned vector
639/// points away from `to`; with `repulsion > 0` it points toward `to`.
640///
641/// We stay scalar here: for 2-float deltas there's nothing to
642/// vectorise. The `graph_core::util::dot_simd` primitive is reserved
643/// for the wide-float kernels and for a future batched center-gravity
644/// reduction over all positions at once.
645#[inline]
646fn pair_force(from: Vec2, to: Vec2, mass: f32, repulsion: f32) -> Vec2 {
647    let delta = to - from;
648    let dist2 = delta.length_squared() + 0.01;
649    let dist = dist2.sqrt();
650    let dir = delta / dist;
651    // Negative `repulsion` means the force on `from` is in the
652    // `-dir` direction — away from `to`, which is the repulsion case.
653    let magnitude = repulsion * mass / dist2;
654    dir * magnitude
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[test]
662    fn two_linked_nodes_converge() {
663        // `velocity_decay=0.4` (d3 default) is aggressive damping — it
664        // multiplies velocity by 0.4 every tick, so with a small dt the
665        // system is strongly overdamped and reaches equilibrium slowly.
666        // Use a milder decay and bump the tick count so we end well
667        // inside the 20% tolerance band around `link_distance`.
668        let mut sim = Simulation::new(
669            2,
670            SimulationConfig {
671                seed: 42,
672                link_strength: 0.8,
673                link_distance: 30.0,
674                repulsion_strength: -30.0,
675                center_strength: 0.0,
676                velocity_decay: 0.9,
677                ..Default::default()
678            },
679        );
680        sim.set_edges(&[(0, 1, 1.0)]);
681        for _ in 0..2000 {
682            sim.tick(0.02);
683        }
684        let d = (sim.positions()[0] - sim.positions()[1]).length();
685        let target = 30.0_f32;
686        assert!(
687            d > target * 0.8 && d < target * 1.2,
688            "expected ~30 got {d}"
689        );
690    }
691
692    #[test]
693    fn star_topology_stabilizes() {
694        let n = 9u32;
695        let mut sim = Simulation::new(
696            n,
697            SimulationConfig {
698                seed: 1,
699                link_strength: 0.2,
700                link_distance: 30.0,
701                repulsion_strength: -30.0,
702                center_strength: 0.1,
703                velocity_decay: 0.5,
704                ..Default::default()
705            },
706        );
707        let edges: Vec<_> = (1..n).map(|i| (0u32, i, 1.0)).collect();
708        sim.set_edges(&edges);
709        for _ in 0..1000 {
710            sim.tick(0.02);
711        }
712        let max_v = sim
713            .velocities
714            .iter()
715            .map(|v| v.length())
716            .fold(0.0_f32, f32::max);
717        assert!(max_v < 0.5, "max_v={max_v}");
718    }
719
720    #[test]
721    fn deterministic_tick_sequence() {
722        let config = SimulationConfig {
723            seed: 12345,
724            ..Default::default()
725        };
726        let mut a = Simulation::new(50, config);
727        let mut b = Simulation::new(50, config);
728        let edges: Vec<_> = (0..49u32).map(|i| (i, i + 1, 1.0)).collect();
729        a.set_edges(&edges);
730        b.set_edges(&edges);
731        for _ in 0..100 {
732            a.tick(0.02);
733            b.tick(0.02);
734        }
735        for (pa, pb) in a.positions().iter().zip(b.positions().iter()) {
736            assert_eq!(pa, pb, "deterministic run diverged");
737        }
738    }
739
740    #[test]
741    fn isolated_nodes_repel() {
742        let n = 10u32;
743        let mut sim = Simulation::new(
744            n,
745            SimulationConfig {
746                seed: 7,
747                repulsion_strength: -80.0,
748                center_strength: 0.0,
749                velocity_decay: 0.6,
750                ..Default::default()
751            },
752        );
753        // Start clustered on a small 2D disc so repulsion has both
754        // axes to work along. A perfectly collinear starting layout
755        // stays collinear under symmetric repulsion, which makes the
756        // per-neighbour spacing fall off too slowly for a clean
757        // threshold test.
758        let cluster_rng = &mut rand_chacha::ChaCha8Rng::seed_from_u64(99);
759        for p in sim.positions.iter_mut() {
760            loop {
761                let x: f32 = cluster_rng.gen_range(-1.0..=1.0);
762                let y: f32 = cluster_rng.gen_range(-1.0..=1.0);
763                if x * x + y * y <= 1.0 {
764                    *p = Vec2::new(x, y);
765                    break;
766                }
767            }
768        }
769        for v in sim.velocities.iter_mut() {
770            *v = Vec2::ZERO;
771        }
772
773        for _ in 0..500 {
774            sim.tick(0.02);
775        }
776
777        let mut min_d = f32::INFINITY;
778        for i in 0..sim.positions.len() {
779            for j in (i + 1)..sim.positions.len() {
780                let d = (sim.positions[i] - sim.positions[j]).length();
781                if d < min_d {
782                    min_d = d;
783                }
784            }
785        }
786        assert!(min_d > 5.0, "min pairwise distance {min_d} ≤ 5");
787    }
788}