use std::collections::VecDeque;
use std::f32::consts::TAU;
use glam::Vec3;
use crate::graph::{NodeIndex, SimTopology};
use crate::particle::Particle;
use super::layering::{compute_layering, DepthMetric};
use super::{Layout, LayoutTickResult};
const DEFAULT_GOLDEN_ANGLE: f32 = 2.399_963_2;
const DEFAULT_ROOT_SPREAD_HALF_ANGLE: f32 = 1.047_198;
const DEFAULT_CHILD_CONE_HALF_ANGLE: f32 = 0.698_132;
const DEFAULT_MIN_ROOT_RING_RADIUS: f32 = 20.0;
#[derive(Debug, Clone)]
pub struct RadialParams3D {
pub shell_spacing: f32,
pub roots: Vec<NodeIndex>,
pub golden_angle: f32,
pub root_spread_half_angle: f32,
pub child_cone_half_angle: f32,
pub min_root_ring_radius: f32,
pub depth_metric: DepthMetric,
pub radius_aware_spacing: bool,
}
impl Default for RadialParams3D {
fn default() -> Self {
Self {
shell_spacing: 90.0,
roots: Vec::new(),
golden_angle: DEFAULT_GOLDEN_ANGLE,
root_spread_half_angle: DEFAULT_ROOT_SPREAD_HALF_ANGLE,
child_cone_half_angle: DEFAULT_CHILD_CONE_HALF_ANGLE,
min_root_ring_radius: DEFAULT_MIN_ROOT_RING_RADIUS,
depth_metric: DepthMetric::ShortestPath,
radius_aware_spacing: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RadialLayout3D {
params: RadialParams3D,
computed: bool,
}
impl RadialLayout3D {
pub fn new(params: RadialParams3D) -> Self {
Self { params, computed: false }
}
pub fn params(&self) -> &RadialParams3D {
&self.params
}
pub fn set_params(&mut self, params: RadialParams3D) {
self.params = params;
self.computed = false;
}
}
struct BfsTree {
depth: Vec<u32>,
children: Vec<Vec<usize>>,
roots: Vec<usize>,
}
fn bfs_fill(adjacency: &[Vec<usize>], visited: &mut [bool], depth: &mut [u32], children: &mut [Vec<usize>], queue: &mut VecDeque<usize>) {
while let Some(u) = queue.pop_front() {
for &v in &adjacency[u] {
if !visited[v] {
visited[v] = true;
depth[v] = depth[u] + 1;
children[u].push(v);
queue.push_back(v);
}
}
}
}
fn compute_bfs_tree(topo: &SimTopology<'_>, explicit_roots: &[NodeIndex]) -> BfsTree {
let n = topo.node_count;
let mut depth = vec![0u32; n];
let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut visited = vec![false; n];
let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); n];
for e in topo.edges {
let a = e.from.index();
let b = e.to.index();
if a < n && b < n && a != b {
adjacency[a].push(b);
}
}
let mut roots: Vec<usize> = explicit_roots.iter().map(|r| r.index()).filter(|&i| i < n).collect();
if roots.is_empty() {
let mut in_degree = vec![0u32; n];
for e in topo.edges {
let a = e.from.index();
let b = e.to.index();
if a < n && b < n && a != b {
in_degree[b] += 1;
}
}
roots = (0..n).filter(|&i| in_degree[i] == 0).collect();
}
if roots.is_empty() && n > 0 {
roots = vec![0];
}
let mut root_order: Vec<usize> = Vec::new();
let mut queue: VecDeque<usize> = VecDeque::new();
for &r in &roots {
if !visited[r] {
visited[r] = true;
depth[r] = 0;
root_order.push(r);
queue.push_back(r);
}
}
bfs_fill(&adjacency, &mut visited, &mut depth, &mut children, &mut queue);
for i in 0..n {
if !visited[i] {
visited[i] = true;
depth[i] = 0;
root_order.push(i);
queue.push_back(i);
bfs_fill(&adjacency, &mut visited, &mut depth, &mut children, &mut queue);
}
}
BfsTree { depth, children, roots: root_order }
}
fn orthonormal_basis(dir: Vec3) -> (Vec3, Vec3) {
let reference = if dir.y.abs() > 0.99 { Vec3::X } else { Vec3::Y };
let u = dir.cross(reference).normalize_or_zero();
let v = dir.cross(u);
(u, v)
}
fn child_direction(parent_dir: Vec3, sibling_index: usize, cone_half_angle: f32, golden_angle: f32) -> Vec3 {
let (u, v) = orthonormal_basis(parent_dir);
let phi = sibling_index as f32 * golden_angle;
let (sin_c, cos_c) = cone_half_angle.sin_cos();
let (sin_p, cos_p) = phi.sin_cos();
let dir = parent_dir * cos_c + (u * cos_p + v * sin_p) * sin_c;
dir.normalize_or_zero()
}
fn assign_directions(tree: &BfsTree, golden_angle: f32, root_spread_half_angle: f32, child_cone_half_angle: f32) -> Vec<Vec3> {
let n = tree.depth.len();
let mut dir = vec![Vec3::Y; n];
for (i, &r) in tree.roots.iter().enumerate() {
dir[r] = child_direction(Vec3::Y, i, root_spread_half_angle, golden_angle);
}
let mut queue: VecDeque<usize> = tree.roots.iter().copied().collect();
while let Some(u) = queue.pop_front() {
for (i, &v) in tree.children[u].iter().enumerate() {
dir[v] = child_direction(dir[u], i, child_cone_half_angle, golden_angle);
queue.push_back(v);
}
}
dir
}
fn root_ring_radius(root_count: usize, shell_spacing: f32, min_root_ring_radius: f32) -> f32 {
let count = root_count.max(1) as f32;
(shell_spacing * count / TAU).max(min_root_ring_radius)
}
impl Layout for RadialLayout3D {
fn tick(&mut self, topo: &SimTopology<'_>, particles: &mut [Particle], _dt: f32) -> LayoutTickResult {
if self.computed {
return LayoutTickResult { alpha: 0.0, max_displacement: 0.0, settled: true };
}
let tree = compute_bfs_tree(topo, &self.params.roots);
let dir = assign_directions(&tree, self.params.golden_angle, self.params.root_spread_half_angle, self.params.child_cone_half_angle);
let depth_for_radius: Vec<u32> = match self.params.depth_metric {
DepthMetric::ShortestPath => tree.depth.clone(),
DepthMetric::LongestPath => compute_layering(topo, &self.params.roots).layer,
};
let root_count = depth_for_radius.iter().filter(|&&d| d == 0).count();
let root_ring = root_ring_radius(root_count, self.params.shell_spacing, self.params.min_root_ring_radius);
for i in 0..tree.depth.len() {
let Some(p) = particles.get_mut(i) else { continue };
if let (Some(fx), Some(fy), Some(fz)) = (p.fx, p.fy, p.fz) {
p.x = fx;
p.y = fy;
p.z = fz;
p.vx = 0.0;
p.vy = 0.0;
p.vz = 0.0;
continue;
}
let depth = depth_for_radius.get(i).copied().unwrap_or(0);
let base_radius = if depth == 0 { root_ring } else { depth as f32 * self.params.shell_spacing };
let radius = if self.params.radius_aware_spacing {
base_radius + topo.radii.get(i).copied().unwrap_or(1.0).max(0.0)
} else {
base_radius
};
let d = dir[i];
p.x = d.x * radius;
p.y = d.y * radius;
p.z = d.z * radius;
p.vx = 0.0;
p.vy = 0.0;
p.vz = 0.0;
}
self.computed = true;
LayoutTickResult { alpha: 0.0, max_displacement: 0.0, settled: true }
}
fn reheat(&mut self, _alpha: f32) {
self.computed = false;
}
fn is_settled(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::SimEdge;
fn topo(node_count: usize, edges: &[SimEdge]) -> SimTopology<'_> {
SimTopology { node_count, edges, degree: &[], radii: vec![1.0; node_count] }
}
fn e(from: u32, to: u32) -> SimEdge {
SimEdge { from: NodeIndex(from), to: NodeIndex(to), weight: 1.0 }
}
fn radius(p: &Particle) -> f32 {
(p.x * p.x + p.y * p.y + p.z * p.z).sqrt()
}
#[test]
fn single_root_sits_at_a_small_nonzero_ring_radius_not_pinned_to_the_origin() {
let edges = [e(0, 1)];
let t = topo(2, &edges);
let mut particles = vec![Particle::default(); 2];
let mut layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: false, ..RadialParams3D::default() });
layout.tick(&t, &mut particles, 1.0 / 60.0);
let params = RadialParams3D::default();
let expected = (params.shell_spacing / TAU).max(params.min_root_ring_radius);
assert!(expected > 0.0, "sanity: the expected ring radius itself must be nonzero");
assert!(
(radius(&particles[0]) - expected).abs() < 1e-3,
"a single root must land on the min_root_ring_radius-floored ring, got r={} expected={expected}",
radius(&particles[0])
);
}
#[test]
fn multiple_disconnected_roots_land_on_a_shared_nonzero_ring_at_distinct_positions() {
let t = topo(4, &[]);
let mut particles = vec![Particle::default(); 4];
let mut layout = RadialLayout3D::default();
layout.tick(&t, &mut particles, 1.0 / 60.0);
let radii: Vec<f32> = particles.iter().map(radius).collect();
for r in &radii {
assert!(*r > 0.0, "every root must sit on a nonzero-radius ring, not the literal origin: {radii:?}");
}
for r in &radii[1..] {
assert!((r - radii[0]).abs() < 1e-3, "every depth-0 root must share the same ring radius: {radii:?}");
}
let mut positions: Vec<(f32, f32, f32)> = particles.iter().map(|p| (p.x, p.y, p.z)).collect();
positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
for pair in positions.windows(2) {
assert_ne!(pair[0], pair[1], "distinct roots must not collapse onto the same point");
}
}
#[test]
fn depth_shells_have_strictly_increasing_radius_with_depth_and_no_nan() {
let edges = [e(0, 1), e(1, 2), e(2, 3)];
let t = topo(4, &edges);
let mut particles = vec![Particle::default(); 4];
let mut layout = RadialLayout3D::default();
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());
}
let radii: Vec<f32> = particles.iter().map(radius).collect();
for pair in radii.windows(2) {
assert!(pair[1] > pair[0] + 1e-3, "shell radius must strictly increase with depth: {radii:?}");
}
}
#[test]
fn siblings_at_the_same_depth_dont_collapse_onto_one_point() {
let edges = [e(0, 1), e(0, 2), e(0, 3), e(0, 4), e(0, 5)];
let t = topo(6, &edges);
let mut particles = vec![Particle::default(); 6];
let mut layout = RadialLayout3D::default();
layout.tick(&t, &mut particles, 1.0 / 60.0);
let mut positions: Vec<(f32, f32, f32)> = (1..=5).map(|i| (particles[i].x, particles[i].y, particles[i].z)).collect();
positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
for pair in positions.windows(2) {
assert_ne!(pair[0], pair[1], "siblings at the same depth must not collapse onto one point");
}
let r0 = radius(&particles[1]);
for i in 2..=5 {
assert!((radius(&particles[i]) - r0).abs() < 1e-3, "same-depth siblings must share the same shell radius");
}
}
#[test]
fn one_shot_layout_freezes_after_first_tick() {
let edges = [e(0, 1)];
let t = topo(2, &edges);
let mut particles = vec![Particle::default(); 2];
let mut layout = RadialLayout3D::default();
layout.tick(&t, &mut particles, 1.0 / 60.0);
let after_first = particles.clone();
particles[1].x = 999.0;
let r2 = layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(r2.settled);
assert_eq!(particles[1].x, 999.0);
assert_eq!(particles[0], after_first[0]);
}
#[test]
fn reheat_forces_a_fresh_recompute() {
let edges = [e(0, 1)];
let t = topo(2, &edges);
let mut particles = vec![Particle::default(); 2];
let mut layout = RadialLayout3D::default();
layout.tick(&t, &mut particles, 1.0 / 60.0);
particles[1].x = 999.0;
layout.reheat(1.0);
layout.tick(&t, &mut particles, 1.0 / 60.0);
assert_ne!(particles[1].x, 999.0, "reheat must force a fresh compute, overwriting the manual nudge");
}
#[test]
fn pinned_particle_is_never_overwritten_by_the_bfs_shell_math() {
let edges = [e(0, 1), e(0, 2)];
let t = topo(3, &edges);
let mut particles = vec![Particle::default(); 3];
particles[1].pin3(42.0, -7.0, 13.0);
let mut layout = RadialLayout3D::default();
layout.tick(&t, &mut particles, 1.0 / 60.0);
assert_eq!((particles[1].x, particles[1].y, particles[1].z), (42.0, -7.0, 13.0));
}
#[test]
fn radial_3d_layout_is_deterministic_across_repeated_ticks_after_reheat() {
let edges = [e(0, 1), e(0, 2), e(1, 3)];
let t = topo(4, &edges);
let mut particles = vec![Particle::default(); 4];
let mut layout = RadialLayout3D::default();
layout.tick(&t, &mut particles, 1.0 / 60.0);
let first: Vec<(f32, f32, f32)> = particles.iter().map(|p| (p.x, p.y, p.z)).collect();
layout.reheat(1.0);
layout.tick(&t, &mut particles, 1.0 / 60.0);
let second: Vec<(f32, f32, f32)> = particles.iter().map(|p| (p.x, p.y, p.z)).collect();
assert_eq!(first, second);
}
#[test]
fn a_cyclic_graph_still_layers_cleanly_with_no_nan_or_panic() {
let edges = [e(0, 1), e(1, 2), e(2, 0)];
let t = topo(3, &edges);
let mut particles = vec![Particle::default(); 3];
let mut layout = RadialLayout3D::default();
let result = layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(result.settled);
for p in &particles {
assert!(p.x.is_finite() && p.y.is_finite() && p.z.is_finite());
}
let radii: Vec<f32> = particles.iter().map(radius).collect();
assert!(radii[0] > 0.0, "the fallback root must sit on the nonzero root ring, not the origin: {radii:?}");
assert!(radii[1] > radii[0]);
assert!(radii[2] > radii[1]);
}
#[test]
fn empty_topology_produces_no_particles_touched_and_settles() {
let t = topo(0, &[]);
let mut particles: Vec<Particle> = Vec::new();
let mut layout = RadialLayout3D::default();
let result = layout.tick(&t, &mut particles, 1.0 / 60.0);
assert!(result.settled);
}
#[test]
fn radial_params_3d_default_matches_the_prior_hardcoded_constants() {
let p = RadialParams3D::default();
assert_eq!(p.golden_angle, 2.399_963_2);
assert!((p.root_spread_half_angle - 1.047_198).abs() < 1e-6);
assert!((p.child_cone_half_angle - 0.698_132).abs() < 1e-6);
assert_eq!(p.min_root_ring_radius, 20.0);
assert_eq!(p.depth_metric, DepthMetric::ShortestPath, "RadialLayout3D's pre-existing behavior — must not change silently");
assert!(p.radius_aware_spacing, "must default ON — graph-strengthening arc owner-approved flip");
}
#[test]
fn depth_metric_longest_path_places_a_multi_parent_node_on_a_different_shell_than_the_default_shortest_path() {
let edges = [e(0, 1), e(2, 3), e(3, 4), e(1, 5), e(4, 5)];
let t = topo(6, &edges);
let mut shortest_particles = vec![Particle::default(); 6];
let mut shortest_layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: false, ..RadialParams3D::default() }); shortest_layout.tick(&t, &mut shortest_particles, 1.0 / 60.0);
let mut longest_particles = vec![Particle::default(); 6];
let mut longest_layout = RadialLayout3D::new(RadialParams3D {
depth_metric: DepthMetric::LongestPath,
radius_aware_spacing: false,
..RadialParams3D::default()
});
longest_layout.tick(&t, &mut longest_particles, 1.0 / 60.0);
let r_shortest = radius(&shortest_particles[5]); let r_longest = radius(&longest_particles[5]);
assert!(
r_longest > r_shortest,
"D's LongestPath layer (3) must sit on a strictly larger shell than its ShortestPath depth (2): {r_longest} vs {r_shortest}"
);
let default_params = RadialParams3D::default();
let expected_shortest = 2.0 * default_params.shell_spacing;
let expected_longest = 3.0 * default_params.shell_spacing;
assert!((r_shortest - expected_shortest).abs() < 1e-2, "got {r_shortest}, expected {expected_shortest}");
assert!((r_longest - expected_longest).abs() < 1e-2, "got {r_longest}, expected {expected_longest}");
}
#[test]
fn radius_aware_spacing_pushes_a_large_radius_node_further_out_along_its_own_direction() {
let edges = [e(0, 1), e(1, 2)];
let mut radii = vec![1.0f32; 3];
radii[2] = 40.0; let t = SimTopology { node_count: 3, edges: &edges, degree: &[], radii };
let mut off_particles = vec![Particle::default(); 3];
let mut off_layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: false, ..RadialParams3D::default() });
off_layout.tick(&t, &mut off_particles, 1.0 / 60.0);
let mut on_particles = vec![Particle::default(); 3];
let mut on_layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: true, ..RadialParams3D::default() });
on_layout.tick(&t, &mut on_particles, 1.0 / 60.0);
let r_off = radius(&off_particles[2]);
let r_on = radius(&on_particles[2]);
assert!((r_on - r_off - 40.0).abs() < 1e-2, "the push-out must add exactly the node's own radius: off={r_off} on={r_on}");
let r_off_small = radius(&off_particles[1]);
let r_on_small = radius(&on_particles[1]);
assert!(
(r_on_small - r_off_small - 1.0).abs() < 1e-2,
"a node at the default topology radius (1.0) must shift by its own small radius only: off={r_off_small} on={r_on_small}"
);
}
#[test]
fn radial_3d_radius_aware_spacing_is_a_true_no_op_when_every_radius_is_exactly_zero() {
let edges = [e(0, 1), e(0, 2), e(1, 3), e(1, 4), e(2, 5)];
let radii = vec![0.0f32; 6];
let t = SimTopology { node_count: 6, edges: &edges, degree: &[], radii };
let mut off_particles = vec![Particle::default(); 6];
let mut off_layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: false, ..RadialParams3D::default() });
off_layout.tick(&t, &mut off_particles, 1.0 / 60.0);
let mut on_particles = vec![Particle::default(); 6];
let mut on_layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: true, ..RadialParams3D::default() });
on_layout.tick(&t, &mut on_particles, 1.0 / 60.0);
assert_eq!(off_particles, on_particles, "radii of exactly 0.0 must be a true no-op regardless of radius_aware_spacing");
}
#[test]
fn radial_3d_uniform_nonzero_radii_shift_every_node_by_the_same_amount_and_never_change_direction() {
let edges = [e(0, 1), e(0, 2), e(1, 3), e(1, 4), e(2, 5)];
const UNIFORM_RADIUS: f32 = 12.0;
let radii = vec![UNIFORM_RADIUS; 6];
let t = SimTopology { node_count: 6, edges: &edges, degree: &[], radii };
let mut off_particles = vec![Particle::default(); 6];
let mut off_layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: false, ..RadialParams3D::default() });
off_layout.tick(&t, &mut off_particles, 1.0 / 60.0);
let mut on_particles = vec![Particle::default(); 6];
let mut on_layout = RadialLayout3D::new(RadialParams3D { radius_aware_spacing: true, ..RadialParams3D::default() });
on_layout.tick(&t, &mut on_particles, 1.0 / 60.0);
for i in 0..6 {
let off = off_particles[i];
let on = on_particles[i];
let r_off = radius(&off);
let r_on = radius(&on);
assert!(
(r_on - r_off - UNIFORM_RADIUS).abs() < 1e-2,
"node {i}: radial distance must grow by exactly the uniform radius: off={r_off} on={r_on}"
);
if r_off > 1e-3 && r_on > 1e-3 {
let dir_off = (off.x / r_off, off.y / r_off, off.z / r_off);
let dir_on = (on.x / r_on, on.y / r_on, on.z / r_on);
assert!(
(dir_off.0 - dir_on.0).abs() < 1e-4 && (dir_off.1 - dir_on.1).abs() < 1e-4 && (dir_off.2 - dir_on.2).abs() < 1e-4,
"node {i}: direction must be unchanged — this must be a pure radial push, not a rotation: off_dir={dir_off:?} on_dir={dir_on:?}"
);
}
}
}
#[test]
fn a_wider_child_cone_half_angle_spreads_same_depth_siblings_farther_apart() {
let edges = [e(0, 1), e(0, 2)];
let t = topo(3, &edges);
let mut default_particles = vec![Particle::default(); 3];
let mut default_layout = RadialLayout3D::default();
default_layout.tick(&t, &mut default_particles, 1.0 / 60.0);
let default_gap = {
let dx = default_particles[1].x - default_particles[2].x;
let dy = default_particles[1].y - default_particles[2].y;
let dz = default_particles[1].z - default_particles[2].z;
(dx * dx + dy * dy + dz * dz).sqrt()
};
let mut wide_particles = vec![Particle::default(); 3];
let mut wide_layout =
RadialLayout3D::new(RadialParams3D { child_cone_half_angle: std::f32::consts::FRAC_PI_2, ..RadialParams3D::default() });
wide_layout.tick(&t, &mut wide_particles, 1.0 / 60.0);
let wide_gap = {
let dx = wide_particles[1].x - wide_particles[2].x;
let dy = wide_particles[1].y - wide_particles[2].y;
let dz = wide_particles[1].z - wide_particles[2].z;
(dx * dx + dy * dy + dz * dz).sqrt()
};
assert!(
wide_gap > default_gap,
"a wider child_cone_half_angle must spread same-depth siblings farther apart: default={default_gap} wide={wide_gap}"
);
}
}