use crate::graph::SimTopology;
use crate::particle::Particle;
use super::barnes_hut_3d::{self, Octree};
use super::{Layout, LayoutTickResult};
#[derive(Debug, Clone, Copy)]
pub struct ForceParams3D {
pub charge_strength: f32,
pub link_strength: f32,
pub link_distance: f32,
pub center_strength: f32,
pub center: (f32, f32, f32),
pub velocity_decay: f32,
pub alpha_decay: f32,
pub alpha_min: f32,
pub theta: f32,
pub brute_force_threshold: usize,
pub collision: bool,
pub collision_strength: f32,
pub normalize_fps: f32,
pub max_step: f32,
pub settle_displacement_eps: f32,
pub seed_degenerate_positions: bool,
pub min_dist2: f32,
pub min_split_dist2: f32,
pub min_cell_size: f32,
pub max_displacement_per_tick: f32,
pub weighted_links: bool,
pub mass_from_degree: bool,
}
const DEFAULT_MAX_DISPLACEMENT_PER_TICK_3D: f32 = 20_000.0;
impl Default for ForceParams3D {
fn default() -> Self {
Self {
charge_strength: 900.0,
link_strength: 0.08,
link_distance: 60.0,
center_strength: 0.02,
center: (0.0, 0.0, 0.0),
velocity_decay: 0.4,
alpha_decay: 0.0228,
alpha_min: 0.001,
theta: barnes_hut_3d::DEFAULT_THETA,
brute_force_threshold: barnes_hut_3d::BRUTE_FORCE_THRESHOLD,
collision: true,
collision_strength: 0.7,
normalize_fps: 60.0,
max_step: 4.0,
settle_displacement_eps: 0.05,
seed_degenerate_positions: true,
min_dist2: barnes_hut_3d::MIN_DIST2,
min_split_dist2: barnes_hut_3d::MIN_SPLIT_DIST2,
min_cell_size: barnes_hut_3d::MIN_CELL_SIZE,
max_displacement_per_tick: DEFAULT_MAX_DISPLACEMENT_PER_TICK_3D,
weighted_links: true,
mass_from_degree: false,
}
}
}
const DEGENERACY_EPS: f32 = 1e-6;
const SEED_RADIUS_SCALE: f32 = 10.0;
const SEED_GOLDEN_ANGLE: f32 = 2.399_963_2;
fn positions_are_degenerate_3d(particles: &[Particle]) -> bool {
if particles.len() < 2 {
return false;
}
let (x0, y0, z0) = (particles[0].x, particles[0].y, particles[0].z);
particles
.iter()
.all(|p| (p.x - x0).abs() <= DEGENERACY_EPS && (p.y - y0).abs() <= DEGENERACY_EPS && (p.z - z0).abs() <= DEGENERACY_EPS)
}
fn degree_masses_3d(degree: &[u32], n: usize) -> Vec<f32> {
(0..n).map(|i| 1.0 + degree.get(i).copied().unwrap_or(0) as f32).collect()
}
fn seed_phyllotaxis_sphere_positions(particles: &mut [Particle], center: (f32, f32, f32)) {
let n = particles.len().max(1) as f32;
for (i, p) in particles.iter_mut().enumerate() {
let t = (i as f32 + 0.5) / n;
let z_unit = (1.0 - 2.0 * t).clamp(-1.0, 1.0);
let ring_unit = (1.0 - z_unit * z_unit).max(0.0).sqrt();
let theta = i as f32 * SEED_GOLDEN_ANGLE;
let radius = SEED_RADIUS_SCALE * (0.5 + i as f32).sqrt();
p.x = center.0 + ring_unit * theta.cos() * radius;
p.y = center.1 + ring_unit * theta.sin() * radius;
p.z = center.2 + z_unit * radius;
}
}
pub struct ForceDirectedLayout3D {
params: ForceParams3D,
alpha: f32,
alpha_target: f32,
}
impl Default for ForceDirectedLayout3D {
fn default() -> Self {
Self::new(ForceParams3D::default())
}
}
impl ForceDirectedLayout3D {
pub fn new(params: ForceParams3D) -> Self {
Self { params, alpha: 1.0, alpha_target: 0.0 }
}
pub fn params(&self) -> &ForceParams3D {
&self.params
}
pub fn set_params(&mut self, params: ForceParams3D) {
self.params = params;
}
pub fn alpha(&self) -> f32 {
self.alpha
}
}
fn apply_collision_3d(particles: &[Particle], radii: &[f32], strength: f32, force: &mut [(f32, f32, f32)]) {
let n = particles.len();
for i in 0..n {
for j in (i + 1)..n {
barnes_hut_3d::collision_pair_force_3d(i, j, particles, radii, strength, force);
}
}
}
impl Layout for ForceDirectedLayout3D {
fn tick(&mut self, topo: &SimTopology<'_>, particles: &mut [Particle], dt: f32) -> LayoutTickResult {
let n = particles.len();
if n == 0 {
return LayoutTickResult { alpha: self.alpha, max_displacement: 0.0, settled: true };
}
if self.params.seed_degenerate_positions && positions_are_degenerate_3d(particles) {
seed_phyllotaxis_sphere_positions(particles, self.params.center);
}
let mut force = vec![(0f32, 0f32, 0f32); n];
let masses = self.params.mass_from_degree.then(|| degree_masses_3d(topo.degree, n));
let ot = if n > self.params.brute_force_threshold {
let ot = Octree::build_weighted(particles, masses.as_deref(), self.params.min_split_dist2, self.params.min_cell_size);
ot.accumulate_forces(particles, self.params.theta, self.params.charge_strength, self.params.min_dist2, &mut force);
Some(ot)
} else {
barnes_hut_3d::apply_repulsion_brute_force_3d(particles, self.params.charge_strength, self.params.min_dist2, masses.as_deref(), &mut force);
None
};
for e in topo.edges {
let a = e.from.index();
let b = e.to.index();
if a >= n || b >= n || a == b {
continue;
}
let dx = particles[b].x - particles[a].x;
let dy = particles[b].y - particles[a].y;
let dz = particles[b].z - particles[a].z;
let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(0.01);
let ideal = self.params.link_distance;
let link_strength = if self.params.weighted_links {
self.params.link_strength * e.weight.max(0.0)
} else {
self.params.link_strength
};
let diff = (dist - ideal) / dist * link_strength;
let fx = dx * diff;
let fy = dy * diff;
let fz = dz * diff;
force[a].0 += fx;
force[a].1 += fy;
force[a].2 += fz;
force[b].0 -= fx;
force[b].1 -= fy;
force[b].2 -= fz;
}
for i in 0..n {
let dx = self.params.center.0 - particles[i].x;
let dy = self.params.center.1 - particles[i].y;
let dz = self.params.center.2 - particles[i].z;
force[i].0 += dx * self.params.center_strength;
force[i].1 += dy * self.params.center_strength;
force[i].2 += dz * self.params.center_strength;
}
if self.params.collision {
match &ot {
Some(ot) => ot.apply_collision_3d(particles, &topo.radii, self.params.collision_strength, &mut force),
None => apply_collision_3d(particles, &topo.radii, self.params.collision_strength, &mut force),
}
}
let step = (dt * self.params.normalize_fps).clamp(0.0, self.params.max_step);
let alpha = self.alpha;
let decay_factor = (1.0 - self.params.velocity_decay).powf(step);
let mut max_disp = 0f32;
for i in 0..n {
if particles[i].is_pinned_3d() {
if let Some(fx) = particles[i].fx {
particles[i].x = fx;
}
if let Some(fy) = particles[i].fy {
particles[i].y = fy;
}
if let Some(fz) = particles[i].fz {
particles[i].z = fz;
}
particles[i].vx = 0.0;
particles[i].vy = 0.0;
particles[i].vz = 0.0;
continue;
}
let (fx, fy, fz) = force[i];
particles[i].vx = (particles[i].vx + fx * alpha) * decay_factor;
particles[i].vy = (particles[i].vy + fy * alpha) * decay_factor;
particles[i].vz = (particles[i].vz + fz * alpha) * decay_factor;
if step > 1e-9 {
let max_speed = self.params.max_displacement_per_tick / step;
let speed2 = particles[i].vx * particles[i].vx + particles[i].vy * particles[i].vy + particles[i].vz * particles[i].vz;
if speed2 > max_speed * max_speed {
let scale = max_speed / speed2.sqrt();
particles[i].vx *= scale;
particles[i].vy *= scale;
particles[i].vz *= scale;
}
}
let dx = particles[i].vx * step;
let dy = particles[i].vy * step;
let dz = particles[i].vz * step;
particles[i].x += dx;
particles[i].y += dy;
particles[i].z += dz;
let disp = (dx * dx + dy * dy + dz * dz).sqrt();
if disp > max_disp {
max_disp = disp;
}
}
let alpha_decay_factor = (1.0 - self.params.alpha_decay).powf(step);
self.alpha = self.alpha_target + (self.alpha - self.alpha_target) * alpha_decay_factor;
if self.alpha < 0.0 {
self.alpha = 0.0;
}
let settled = self.alpha < self.params.alpha_min || max_disp < self.params.settle_displacement_eps;
LayoutTickResult { alpha: self.alpha, max_displacement: max_disp, settled }
}
fn reheat(&mut self, alpha: f32) {
self.alpha = self.alpha.max(alpha.clamp(0.0, 1.0));
}
fn is_settled(&self) -> bool {
self.alpha < self.params.alpha_min
}
fn set_alpha_target(&mut self, target: f32) {
self.alpha_target = target.clamp(0.0, 1.0);
}
fn alpha_target(&self) -> f32 {
self.alpha_target
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{NodeIndex, SimEdge};
fn topo<'a>(node_count: usize, edges: &'a [SimEdge], degree: &'a [u32], radii: Vec<f32>) -> SimTopology<'a> {
SimTopology { node_count, edges, degree, radii }
}
fn deterministic_particles_3d(n: usize) -> Vec<Particle> {
let mut particles = Vec::with_capacity(n);
let mut state: u64 = 0xD1B54A32D192ED03;
for _ in 0..n {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let rx = ((state >> 33) as u32 % 400) as f32 - 200.0;
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let ry = ((state >> 33) as u32 % 400) as f32 - 200.0;
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let rz = ((state >> 33) as u32 % 400) as f32 - 200.0;
particles.push(Particle::at3(rx, ry, rz));
}
particles
}
#[test]
fn alpha_cools_monotonically_and_eventually_settles() {
let mut layout = ForceDirectedLayout3D::default();
let mut particles = vec![Particle::at3(-10.0, 0.0, 0.0), Particle::at3(10.0, 0.0, 0.0), Particle::at3(0.0, 15.0, 5.0)];
let degree = vec![0u32; 3];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(3, &edges, °ree, vec![4.0; 3]);
let mut prev_alpha = f32::MAX;
let mut settled_at = None;
for i in 0..600 {
let r = layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(r.alpha <= prev_alpha + 1e-6, "alpha increased at tick {i}: {} > {}", r.alpha, prev_alpha);
prev_alpha = r.alpha;
if r.settled {
settled_at = Some(i);
break;
}
}
assert!(settled_at.is_some(), "3D simulation never settled within 600 ticks");
}
#[test]
fn pinned_particle_position_is_held_across_ticks() {
let mut layout = ForceDirectedLayout3D::default();
let mut particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(5.0, 0.0, 0.0)];
particles[0].pin3(0.0, 0.0, 0.0);
let degree = vec![0u32; 2];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(2, &edges, °ree, vec![4.0; 2]);
for _ in 0..30 {
layout.tick(&t, &mut particles, 1.0 / 60.0);
}
assert_eq!(particles[0].x, 0.0);
assert_eq!(particles[0].y, 0.0);
assert_eq!(particles[0].z, 0.0);
assert!(particles[1].x.abs() > 5.0, "unpinned particle should have moved under repulsion");
}
#[test]
fn reheat_raises_alpha_and_clears_settled() {
let mut layout = ForceDirectedLayout3D::default();
let degree = vec![0u32; 2];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(2, &edges, °ree, vec![4.0; 2]);
let mut particles = vec![Particle::at3(-5.0, 0.0, 0.0), Particle::at3(5.0, 0.0, 0.0)];
for _ in 0..500 {
layout.tick(&t, &mut particles, 1.0 / 60.0);
}
assert!(layout.is_settled());
layout.reheat(0.5);
assert!(!layout.is_settled());
}
#[test]
fn coincident_particle_collision_nudge_perturbs_all_three_axes() {
let particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(0.0, 0.0, 0.0)];
let radii = vec![4.0, 4.0];
let mut force = vec![(0f32, 0f32, 0f32); 2];
apply_collision_3d(&particles, &radii, 0.7, &mut force);
assert_ne!(force[0].1, 0.0, "the y-axis component must be perturbed too, not left at 0.0");
assert_ne!(force[0].2, 0.0, "the z-axis component must be perturbed too — this is the exact bug the owner caught live");
assert_eq!(force[0].0, -force[1].0);
assert_eq!(force[0].1, -force[1].1);
assert_eq!(force[0].2, -force[1].2);
}
#[test]
fn empty_particle_set_reports_settled_immediately() {
let mut layout = ForceDirectedLayout3D::default();
let edges: Vec<SimEdge> = Vec::new();
let degree: Vec<u32> = Vec::new();
let t = topo(0, &edges, °ree, Vec::new());
let mut particles: Vec<Particle> = Vec::new();
let r = layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(r.settled);
}
#[test]
fn random_3d_seed_settles_without_nan_or_inf() {
let n = 60;
let mut particles = deterministic_particles_3d(n);
let mut layout = ForceDirectedLayout3D::default();
let degree = vec![0u32; n];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(n, &edges, °ree, vec![4.0; n]);
let mut settled = false;
for _ in 0..2000 {
let r = layout.tick(&t, &mut particles, 1.0 / 60.0);
for p in &particles {
assert!(p.x.is_finite() && p.y.is_finite() && p.z.is_finite(), "position went non-finite: {p:?}");
assert!(p.vx.is_finite() && p.vy.is_finite() && p.vz.is_finite(), "velocity went non-finite: {p:?}");
}
if r.settled {
settled = true;
break;
}
}
assert!(settled, "3D simulation with a random seed never settled within 2000 ticks");
}
#[test]
fn collision_stays_active_above_the_brute_force_threshold_and_separates_a_coincident_cluster() {
let params = ForceParams3D { brute_force_threshold: 2, collision: true, seed_degenerate_positions: false, ..ForceParams3D::default() };
let mut layout = ForceDirectedLayout3D::new(params);
let n = 6;
let mut particles = vec![Particle::at3(0.0, 0.0, 0.0); n]; let degree = vec![0u32; n];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(n, &edges, °ree, vec![4.0; n]);
for _ in 0..5 {
layout.tick(&t, &mut particles, 1.0 / 60.0);
}
let spread = particles.iter().map(|p| p.x.abs() + p.y.abs() + p.z.abs()).fold(0.0f32, f32::max);
assert!(spread > 0.5, "particles must separate above the brute-force threshold, spread={spread}");
}
#[test]
fn unseeded_sim_above_500_nodes_separates_instead_of_freezing_forever() {
let n = 600;
let mut particles = vec![Particle::default(); n];
let degree = vec![0u32; n];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(n, &edges, °ree, vec![4.0; n]);
let mut layout = ForceDirectedLayout3D::default();
layout.tick(&t, &mut particles, 1.0 / 60.0);
let (x0, y0, z0) = (particles[0].x, particles[0].y, particles[0].z);
let moved = particles.iter().any(|p| (p.x - x0).abs() > 1e-3 || (p.y - y0).abs() > 1e-3 || (p.z - z0).abs() > 1e-3);
assert!(moved, "particles above the collision brute-force threshold must separate after seeding, not stay coincident");
}
#[test]
fn a_pre_seeded_non_degenerate_start_is_unaffected_by_the_seeding_fallback() {
let make_particles = || vec![Particle::at3(-10.0, 0.0, 0.0), Particle::at3(10.0, 0.0, 0.0), Particle::at3(0.0, 15.0, 5.0)];
let degree = vec![0u32; 3];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(3, &edges, °ree, vec![4.0; 3]);
let mut particles_on = make_particles();
let mut layout_on = ForceDirectedLayout3D::new(ForceParams3D { seed_degenerate_positions: true, ..ForceParams3D::default() });
let mut particles_off = make_particles();
let mut layout_off = ForceDirectedLayout3D::new(ForceParams3D { seed_degenerate_positions: false, ..ForceParams3D::default() });
for _ in 0..10 {
layout_on.tick(&t, &mut particles_on, 1.0 / 60.0);
layout_off.tick(&t, &mut particles_off, 1.0 / 60.0);
}
assert_eq!(particles_on, particles_off, "a non-degenerate start must be byte-identical regardless of the seeding fallback");
}
#[test]
fn phyllotaxis_sphere_seed_varies_all_three_axes_deterministically_and_avoids_collisions() {
let mut a = vec![Particle::default(); 40];
let mut b = vec![Particle::default(); 40];
assert!(positions_are_degenerate_3d(&a));
seed_phyllotaxis_sphere_positions(&mut a, (0.0, 0.0, 0.0));
seed_phyllotaxis_sphere_positions(&mut b, (0.0, 0.0, 0.0));
assert_eq!(a, b, "phyllotaxis seeding must be deterministic — no RNG");
assert!(!positions_are_degenerate_3d(&a));
let z_min = a.iter().map(|p| p.z).fold(f32::MAX, f32::min);
let z_max = a.iter().map(|p| p.z).fold(f32::MIN, f32::max);
assert!(z_max - z_min > 1.0, "z must genuinely vary, not stay flat");
for i in 0..a.len() {
for j in (i + 1)..a.len() {
let dx = a[i].x - a[j].x;
let dy = a[i].y - a[j].y;
let dz = a[i].z - a[j].z;
assert!(dx * dx + dy * dy + dz * dz > 1e-6, "indices {i} and {j} landed on the same point");
}
}
}
#[test]
fn new_public_step_params_default_to_the_prior_hardcoded_constants() {
let p = ForceParams3D::default();
assert_eq!(p.normalize_fps, 60.0);
assert_eq!(p.max_step, 4.0);
assert_eq!(p.settle_displacement_eps, 0.05);
assert!(p.seed_degenerate_positions);
assert_eq!(p.min_dist2, barnes_hut_3d::MIN_DIST2);
assert_eq!(p.min_split_dist2, barnes_hut_3d::MIN_SPLIT_DIST2);
assert_eq!(p.min_cell_size, barnes_hut_3d::MIN_CELL_SIZE);
}
#[test]
fn a_larger_min_dist2_caps_repulsion_between_near_coincident_particles_more_aggressively() {
let degree = vec![0u32; 2];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(2, &edges, °ree, vec![0.001; 2]);
let mut default_particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(1e-4, 0.0, 0.0)];
let mut default_layout =
ForceDirectedLayout3D::new(ForceParams3D { seed_degenerate_positions: false, collision: false, ..ForceParams3D::default() });
default_layout.tick(&t, &mut default_particles, 1.0 / 60.0);
let mut softened_particles = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(1e-4, 0.0, 0.0)];
let mut softened_layout = ForceDirectedLayout3D::new(ForceParams3D {
seed_degenerate_positions: false,
collision: false,
min_dist2: barnes_hut_3d::MIN_DIST2 * 100.0,
..ForceParams3D::default()
});
softened_layout.tick(&t, &mut softened_particles, 1.0 / 60.0);
let default_speed = (default_particles[0].vx.powi(2) + default_particles[0].vy.powi(2) + default_particles[0].vz.powi(2)).sqrt();
let softened_speed =
(softened_particles[0].vx.powi(2) + softened_particles[0].vy.powi(2) + softened_particles[0].vz.powi(2)).sqrt();
assert!(
softened_speed < default_speed,
"a larger min_dist2 softening floor must cap the resulting velocity lower: default={default_speed} softened={softened_speed}"
);
}
#[test]
fn max_displacement_per_tick_default_matches_the_2d_layouts_own_measured_value() {
assert_eq!(ForceParams3D::default().max_displacement_per_tick, 20_000.0);
}
#[test]
fn max_displacement_per_tick_actually_bounds_output_when_overridden_low() {
let n = 600;
let mut particles = vec![Particle::default(); n];
let degree = vec![0u32; n];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(n, &edges, °ree, vec![4.0; n]);
let mut layout = ForceDirectedLayout3D::new(ForceParams3D {
seed_degenerate_positions: false,
max_displacement_per_tick: 10.0,
..ForceParams3D::default()
});
for _ in 0..30 {
let r = layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(
r.max_displacement <= 10.0 + 1e-3,
"an overridden 10.0 clamp must never let any tick's own max_displacement exceed it: got {}",
r.max_displacement
);
}
for p in &particles {
assert!(p.x.is_finite() && p.y.is_finite() && p.z.is_finite(), "every coordinate must stay finite under the clamp");
}
}
#[test]
fn an_ordinary_non_exploding_simulation_is_byte_identical_with_the_clamp_effectively_disabled() {
let make_particles = || vec![Particle::at3(-10.0, 0.0, 0.0), Particle::at3(10.0, 0.0, 0.0), Particle::at3(0.0, 15.0, 5.0)];
let degree = vec![0u32; 3];
let edges: Vec<SimEdge> = Vec::new();
let t = topo(3, &edges, °ree, vec![4.0; 3]);
let mut particles_default = make_particles();
let mut layout_default = ForceDirectedLayout3D::default();
let mut particles_disabled = make_particles();
let mut layout_disabled =
ForceDirectedLayout3D::new(ForceParams3D { max_displacement_per_tick: f32::INFINITY, ..ForceParams3D::default() });
for _ in 0..60 {
layout_default.tick(&t, &mut particles_default, 1.0 / 60.0);
layout_disabled.tick(&t, &mut particles_disabled, 1.0 / 60.0);
}
assert_eq!(particles_default, particles_disabled, "the default clamp must be a complete no-op for an ordinary simulation");
}
#[test]
fn weighted_links_defaults_to_true_and_a_default_layout_already_scales_link_strength_by_edge_weight() {
assert!(ForceParams3D::default().weighted_links, "weighted_links must default to true — graph-strengthening arc flip");
let degree = vec![1u32; 2];
let params = ForceParams3D {
charge_strength: 0.0,
center_strength: 0.0,
collision: false,
seed_degenerate_positions: false,
..ForceParams3D::default()
};
let mut particles_light = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0)];
let mut layout_light = ForceDirectedLayout3D::new(params);
let edges_light = vec![SimEdge { from: NodeIndex(0), to: NodeIndex(1), weight: 1.0 }];
let t_light = topo(2, &edges_light, °ree, vec![4.0; 2]);
layout_light.tick(&t_light, &mut particles_light, 1.0 / 60.0);
let mut particles_heavy = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0)];
let mut layout_heavy = ForceDirectedLayout3D::new(params);
let edges_heavy = vec![SimEdge { from: NodeIndex(0), to: NodeIndex(1), weight: 5.0 }];
let t_heavy = topo(2, &edges_heavy, °ree, vec![4.0; 2]);
layout_heavy.tick(&t_heavy, &mut particles_heavy, 1.0 / 60.0);
let speed_light = (particles_light[0].vx.powi(2) + particles_light[0].vy.powi(2) + particles_light[0].vz.powi(2)).sqrt();
let speed_heavy = (particles_heavy[0].vx.powi(2) + particles_heavy[0].vy.powi(2) + particles_heavy[0].vz.powi(2)).sqrt();
assert!(speed_light > 1e-6, "sanity: the light edge must produce SOME motion to compare against");
assert!(
(speed_heavy - 5.0 * speed_light).abs() < speed_light * 0.01,
"with weighted_links at its default (true), a 5x-heavier edge must pull ~5x harder: light={speed_light} heavy={speed_heavy}"
);
}
#[test]
fn weighted_links_explicitly_disabled_ignores_edge_weight_variance() {
let degree = vec![1u32; 2];
let params = ForceParams3D {
charge_strength: 0.0,
center_strength: 0.0,
collision: false,
seed_degenerate_positions: false,
weighted_links: false,
..ForceParams3D::default()
};
let mut particles_light = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0)];
let mut layout_light = ForceDirectedLayout3D::new(params);
let edges_light = vec![SimEdge { from: NodeIndex(0), to: NodeIndex(1), weight: 1.0 }];
let t_light = topo(2, &edges_light, °ree, vec![4.0; 2]);
let mut particles_heavy = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0)];
let mut layout_heavy = ForceDirectedLayout3D::new(params);
let edges_heavy = vec![SimEdge { from: NodeIndex(0), to: NodeIndex(1), weight: 5.0 }];
let t_heavy = topo(2, &edges_heavy, °ree, vec![4.0; 2]);
for _ in 0..10 {
layout_light.tick(&t_light, &mut particles_light, 1.0 / 60.0);
layout_heavy.tick(&t_heavy, &mut particles_heavy, 1.0 / 60.0);
}
assert_eq!(particles_light, particles_heavy, "weighted_links: false must still fully ignore edge weight variance");
}
#[test]
fn weighted_links_true_scales_the_link_forces_strength_by_edge_weight() {
let degree = vec![1u32; 2];
let params = ForceParams3D {
charge_strength: 0.0,
center_strength: 0.0,
collision: false,
seed_degenerate_positions: false,
weighted_links: true,
..ForceParams3D::default()
};
let mut particles_light = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0)];
let mut layout_light = ForceDirectedLayout3D::new(params);
let edges_light = vec![SimEdge { from: NodeIndex(0), to: NodeIndex(1), weight: 1.0 }];
let t_light = topo(2, &edges_light, °ree, vec![4.0; 2]);
layout_light.tick(&t_light, &mut particles_light, 1.0 / 60.0);
let mut particles_heavy = vec![Particle::at3(0.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0)];
let mut layout_heavy = ForceDirectedLayout3D::new(params);
let edges_heavy = vec![SimEdge { from: NodeIndex(0), to: NodeIndex(1), weight: 5.0 }];
let t_heavy = topo(2, &edges_heavy, °ree, vec![4.0; 2]);
layout_heavy.tick(&t_heavy, &mut particles_heavy, 1.0 / 60.0);
let speed_light = (particles_light[0].vx.powi(2) + particles_light[0].vy.powi(2) + particles_light[0].vz.powi(2)).sqrt();
let speed_heavy = (particles_heavy[0].vx.powi(2) + particles_heavy[0].vy.powi(2) + particles_heavy[0].vz.powi(2)).sqrt();
assert!(speed_light > 1e-6, "sanity: the light edge must produce SOME motion to compare against");
assert!(
(speed_heavy - 5.0 * speed_light).abs() < speed_light * 0.01,
"a 5x-heavier edge weight must pull almost exactly 5x harder: light={speed_light} heavy={speed_heavy}"
);
}
#[test]
fn degree_masses_3d_gives_a_higher_degree_particle_more_repulsive_mass() {
let degree = vec![10u32, 0, 3];
let masses = degree_masses_3d(°ree, 3);
assert_eq!(masses, vec![11.0, 1.0, 4.0]);
}
#[test]
fn mass_from_degree_default_false_ignores_degree_variance() {
let edges: Vec<SimEdge> = Vec::new();
let degree_uniform = vec![0u32, 0, 0];
let degree_skewed = vec![50u32, 0, 0];
let params = ForceParams3D { collision: false, seed_degenerate_positions: false, ..ForceParams3D::default() };
let make_particles = || vec![Particle::at3(-100.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0), Particle::at3(0.0, 0.0, 0.0)];
let mut particles_uniform = make_particles();
let mut layout_uniform = ForceDirectedLayout3D::new(params);
let t_uniform = topo(3, &edges, °ree_uniform, vec![4.0; 3]);
let mut particles_skewed = make_particles();
let mut layout_skewed = ForceDirectedLayout3D::new(params);
let t_skewed = topo(3, &edges, °ree_skewed, vec![4.0; 3]);
layout_uniform.tick(&t_uniform, &mut particles_uniform, 1.0 / 60.0);
layout_skewed.tick(&t_skewed, &mut particles_skewed, 1.0 / 60.0);
assert_eq!(particles_uniform, particles_skewed, "mass_from_degree defaults to false — degree variance must have zero effect on the sim");
}
#[test]
fn mass_from_degree_default_false_ignores_degree_variance_through_the_tree_path_too() {
let edges: Vec<SimEdge> = Vec::new();
let degree_uniform = vec![0u32, 0, 0];
let degree_skewed = vec![50u32, 0, 0];
let params =
ForceParams3D { collision: false, seed_degenerate_positions: false, brute_force_threshold: 2, ..ForceParams3D::default() };
let make_particles = || vec![Particle::at3(-100.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0), Particle::at3(0.0, 0.0, 0.0)];
let mut particles_uniform = make_particles();
let mut layout_uniform = ForceDirectedLayout3D::new(params);
let t_uniform = topo(3, &edges, °ree_uniform, vec![4.0; 3]);
let mut particles_skewed = make_particles();
let mut layout_skewed = ForceDirectedLayout3D::new(params);
let t_skewed = topo(3, &edges, °ree_skewed, vec![4.0; 3]);
layout_uniform.tick(&t_uniform, &mut particles_uniform, 1.0 / 60.0);
layout_skewed.tick(&t_skewed, &mut particles_skewed, 1.0 / 60.0);
assert_eq!(particles_uniform, particles_skewed, "mass_from_degree=false must be a no-op via the tree path too");
}
#[test]
fn mass_from_degree_true_makes_a_higher_degree_hub_repel_a_probe_more_strongly_than_a_leaf() {
let edges: Vec<SimEdge> = Vec::new();
let degree = vec![50u32, 0, 0];
let params =
ForceParams3D { collision: false, seed_degenerate_positions: false, mass_from_degree: true, ..ForceParams3D::default() };
let mut particles = vec![Particle::at3(-100.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0), Particle::at3(0.0, 0.0, 0.0)];
let mut layout = ForceDirectedLayout3D::new(params);
let t = topo(3, &edges, °ree, vec![4.0; 3]);
layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(particles[2].vx > 0.0, "the probe must be pushed away from the higher-mass hub, not stay put: vx={}", particles[2].vx);
}
#[test]
fn mass_from_degree_true_produces_the_same_asymmetric_push_via_the_tree_path_above_the_threshold() {
let edges: Vec<SimEdge> = Vec::new();
let degree = vec![50u32, 0, 0];
let params = ForceParams3D {
collision: false,
seed_degenerate_positions: false,
mass_from_degree: true,
brute_force_threshold: 2,
..ForceParams3D::default()
};
let mut particles = vec![Particle::at3(-100.0, 0.0, 0.0), Particle::at3(100.0, 0.0, 0.0), Particle::at3(0.0, 0.0, 0.0)];
let mut layout = ForceDirectedLayout3D::new(params);
let t = topo(3, &edges, °ree, vec![4.0; 3]);
layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(
particles[2].vx > 0.0,
"the tree path must apply the same degree-scaled repulsion as brute force: vx={}",
particles[2].vx
);
}
}