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}