Add realtime 3D layout solver

This commit is contained in:
Meta Vens 2026-05-07 03:11:33 +01:00
commit fb23fbd5af
3 changed files with 327 additions and 71 deletions

View file

@ -72,6 +72,40 @@ For Bevy, map `NodeLayout3d::as_xyz()` directly into `Vec3::new(x, y, z)`.
The crate does not depend on Bevy just to borrow its vector type; the layout
core stays small and renderer-agnostic.
## Realtime Solver
Use `Layout3dSolver` when Bevy needs to iterate layout over frames:
```rust
use epiphany_graph_rs::{Graph, Layout3dConfig, Layout3dSolver};
let graph = Graph::new();
let mut solver = Layout3dSolver::new(graph, Layout3dConfig::default());
// In a Bevy system, run a small number of iterations per frame.
solver.tick(2);
for position in solver.positions() {
let (x, y, z) = *position;
// Transform::from_translation(Vec3::new(x, y, z))
}
```
The solver caches graph analysis, Sugiyama constraints, ranks, order, adjacency,
positions, and force buffers. Use `with_initial_positions` to warm-start after a
graph edit. That keeps the layout from starting over like it suffered a small
bureaucratic head injury.
`Layout3dConfig::repulsion_mode` controls the expensive part:
- `RepulsionMode::SpatialGrid` is the default realtime path.
- `RepulsionMode::Exact` keeps all-pairs repulsion for small graphs or quality
comparisons.
The grid path is approximate local repulsion, not Barnes-Hut. It is the first
practical step toward interactive scale; large graphs still want multilevel
coarsening and a better far-field approximation.
## Graph Analysis And Folding
The crate exposes `analyze(&graph)` for layout-independent structure reads:

View file

@ -14,6 +14,9 @@ solver express it.
These features are implemented in the crate now:
- Persistent `Layout3dSolver` for realtime frame-by-frame iteration.
- Warm-start positions for graph edits and interaction.
- Spatial-grid approximate repulsion for the 3D solver.
- SCC detection for cyclic bodies.
- weak components for broad spatial islands.
- articulation points and bridges for hinge-like connectors.
@ -69,9 +72,15 @@ crate is adaptive step control and Barnes-Hut for large interactive layouts.
Implementation direction:
- Replace fixed cooling with adaptive speed.
- Add Barnes-Hut or grid approximation for repulsion.
- Upgrade spatial-grid repulsion to Barnes-Hut for far-field approximation.
- Expose live-tick APIs for Bevy interaction instead of only batch layout.
Current state:
- `Layout3dSolver` exists.
- `RepulsionMode::SpatialGrid` is the default realtime path.
- `RepulsionMode::Exact` remains available for small graphs and comparisons.
Source:
https://doi.org/10.1371/journal.pone.0098679

View file

@ -5,7 +5,7 @@
//! relaxes those constraints alongside springs, repulsion, and collision.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, VecDeque};
use std::collections::{BinaryHeap, HashMap, VecDeque};
/// Index of a node in a [`Graph`].
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@ -147,6 +147,12 @@ pub struct Layout3dConfig {
pub core_anchor_strength: f32,
/// Force keeping high-clustering neighborhoods compact.
pub clustering_cohesion_strength: f32,
/// Repulsion strategy used by the 3D solver.
pub repulsion_mode: RepulsionMode,
/// Spatial-grid cell size for approximate repulsion.
pub grid_cell_size: f32,
/// Number of neighboring cells searched around each node.
pub grid_radius: i32,
}
impl Default for Layout3dConfig {
@ -166,10 +172,22 @@ impl Default for Layout3dConfig {
community_cohesion_strength: 0.025,
core_anchor_strength: 0.018,
clustering_cohesion_strength: 0.02,
repulsion_mode: RepulsionMode::SpatialGrid,
grid_cell_size: 180.0,
grid_radius: 1,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum RepulsionMode {
/// Exact all-pairs repulsion. Best for quality on small graphs.
Exact,
/// Approximate local repulsion through a uniform spatial grid.
#[default]
SpatialGrid,
}
/// Output for one laid-out node.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NodeLayout {
@ -215,6 +233,147 @@ pub struct Layout3d {
pub fold_groups: Vec<FoldGroup>,
}
/// Cached, incremental 3D layout solver for realtime render loops.
#[derive(Clone, Debug)]
pub struct Layout3dSolver {
graph: Graph,
config: Layout3dConfig,
analysis: GraphAnalysis,
ranks: Vec<usize>,
orders: Vec<usize>,
constraints: ConstraintSet,
undirected: Vec<Vec<(usize, EdgeId)>>,
positions: Vec<(f32, f32, f32)>,
forces: Vec<(f32, f32, f32)>,
tick_count: usize,
}
impl Layout3dSolver {
/// Build a solver with fresh deterministic initial positions.
pub fn new(graph: Graph, config: Layout3dConfig) -> Self {
Self::with_initial_positions(graph, config, &[])
}
/// Build a solver that reuses any supplied coordinates by node index.
///
/// This is the warm-start path for graph edits and Bevy interactions.
pub fn with_initial_positions(
graph: Graph,
config: Layout3dConfig,
initial_positions: &[[f32; 3]],
) -> Self {
let analysis = analyze(&graph);
let ranks = assign_ranks(&graph);
let orders = order_ranks(&graph, &ranks);
let constraints = build_constraints(&graph, &ranks, &orders, &config.base);
let undirected = undirected_adjacency(&graph);
let mut positions = initial_positions_3d(&ranks, &orders, &config);
for (position, initial) in positions.iter_mut().zip(initial_positions.iter()) {
*position = (initial[0], initial[1], initial[2]);
}
let forces = vec![(0.0, 0.0, 0.0); graph.node_count()];
Self {
graph,
config,
analysis,
ranks,
orders,
constraints,
undirected,
positions,
forces,
tick_count: 0,
}
}
/// Advance the solver by a small number of iterations.
pub fn tick(&mut self, iterations: usize) {
for _ in 0..iterations {
self.step();
}
}
/// Current node positions.
pub fn positions(&self) -> &[(f32, f32, f32)] {
&self.positions
}
/// Cached graph analysis.
pub fn analysis(&self) -> &GraphAnalysis {
&self.analysis
}
/// Cached Sugiyama-derived constraints.
pub fn constraints(&self) -> &ConstraintSet {
&self.constraints
}
/// Convert the current solver state into a layout snapshot.
pub fn snapshot(&self) -> Layout3d {
let mut nodes = (0..self.graph.node_count())
.map(|idx| NodeLayout3d {
id: NodeId(idx),
x: self.positions[idx].0,
y: self.positions[idx].1,
z: self.positions[idx].2,
rank: self.ranks[idx],
order: self.orders[idx],
})
.collect::<Vec<_>>();
nodes.sort_by_key(|node| node.id.0);
Layout3d {
nodes,
constraints: self.constraints.clone(),
analysis: self.analysis.clone(),
fold_groups: build_fold_groups(&self.analysis, &self.positions),
}
}
fn step(&mut self) {
self.forces.fill((0.0, 0.0, 0.0));
match self.config.repulsion_mode {
RepulsionMode::Exact => {
apply_repulsion_3d_exact(&self.positions, &mut self.forces, &self.config)
}
RepulsionMode::SpatialGrid => {
apply_repulsion_3d_grid(&self.positions, &mut self.forces, &self.config)
}
}
apply_springs_3d(&self.graph, &self.positions, &mut self.forces, &self.config);
apply_constraints_3d(
&self.constraints,
&self.positions,
&mut self.forces,
&self.config,
);
apply_structural_forces_3d(
&self.graph,
&self.undirected,
&self.analysis,
&self.positions,
&mut self.forces,
&self.config,
);
apply_grounding_3d(&self.positions, &mut self.forces, &self.config);
let cooling =
1.0 - (self.tick_count as f32 / self.config.base.iterations.max(1) as f32).min(1.0);
let step = self.config.base.step_size * cooling.max(0.08);
for (position, force) in self.positions.iter_mut().zip(self.forces.iter()) {
position.0 += clamp(force.0, -24.0, 24.0) * step;
position.1 += clamp(force.1, -24.0, 24.0) * step;
position.2 += clamp(force.2, -24.0, 24.0) * step;
}
self.tick_count += 1;
}
}
/// Structural analysis used to turn graph anatomy into layout pressure.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GraphAnalysis {
@ -350,44 +509,9 @@ pub fn layout(graph: &Graph, config: &LayoutConfig) -> Layout {
/// nodes across X/Z, while ground and depth forces keep the structure shallow
/// enough to read in a 3D scene.
pub fn layout_3d(graph: &Graph, config: &Layout3dConfig) -> Layout3d {
let n = graph.node_count();
if n == 0 {
return Layout3d {
nodes: Vec::new(),
constraints: ConstraintSet::default(),
analysis: GraphAnalysis::default(),
fold_groups: Vec::new(),
};
}
let analysis = analyze(graph);
let ranks = assign_ranks(graph);
let orders = order_ranks(graph, &ranks);
let constraints = build_constraints(graph, &ranks, &orders, &config.base);
let mut positions = initial_positions_3d(&ranks, &orders, config);
relax_3d(graph, &constraints, &analysis, config, &mut positions);
let mut nodes = (0..n)
.map(|idx| NodeLayout3d {
id: NodeId(idx),
x: positions[idx].0,
y: positions[idx].1,
z: positions[idx].2,
rank: ranks[idx],
order: orders[idx],
})
.collect::<Vec<_>>();
nodes.sort_by_key(|node| node.id.0);
let fold_groups = build_fold_groups(&analysis, &positions);
Layout3d {
nodes,
constraints,
analysis,
fold_groups,
}
let mut solver = Layout3dSolver::new(graph.clone(), config.clone());
solver.tick(config.base.iterations);
solver.snapshot()
}
/// Analyze graph structure without running layout.
@ -1146,36 +1270,6 @@ fn relax(
}
}
fn relax_3d(
graph: &Graph,
constraints: &ConstraintSet,
analysis: &GraphAnalysis,
config: &Layout3dConfig,
positions: &mut [(f32, f32, f32)],
) {
let n = graph.node_count();
let mut forces = vec![(0.0f32, 0.0f32, 0.0f32); n];
let undirected = undirected_adjacency(graph);
for iter in 0..config.base.iterations {
forces.fill((0.0, 0.0, 0.0));
apply_repulsion_3d(positions, &mut forces, config);
apply_springs_3d(graph, positions, &mut forces, config);
apply_constraints_3d(constraints, positions, &mut forces, config);
apply_structural_forces_3d(graph, &undirected, analysis, positions, &mut forces, config);
apply_grounding_3d(positions, &mut forces, config);
let cooling = 1.0 - (iter as f32 / config.base.iterations.max(1) as f32);
let step = config.base.step_size * cooling.max(0.08);
for (position, force) in positions.iter_mut().zip(forces.iter()) {
position.0 += clamp(force.0, -24.0, 24.0) * step;
position.1 += clamp(force.1, -24.0, 24.0) * step;
position.2 += clamp(force.2, -24.0, 24.0) * step;
}
}
}
fn apply_structural_forces_3d(
graph: &Graph,
undirected: &[Vec<(usize, EdgeId)>],
@ -1539,7 +1633,7 @@ fn apply_repulsion(positions: &[(f32, f32)], forces: &mut [(f32, f32)], strength
}
}
fn apply_repulsion_3d(
fn apply_repulsion_3d_exact(
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig,
@ -1565,6 +1659,76 @@ fn apply_repulsion_3d(
}
}
fn apply_repulsion_3d_grid(
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig,
) {
if positions.len() < 2 {
return;
}
let cell_size = config.grid_cell_size.max(1.0);
let radius = config.grid_radius.max(0);
let mut grid = HashMap::<(i32, i32, i32), Vec<usize>>::new();
for (idx, &position) in positions.iter().enumerate() {
grid.entry(grid_cell(position, cell_size))
.or_default()
.push(idx);
}
for a in 0..positions.len() {
let (cx, cy, cz) = grid_cell(positions[a], cell_size);
for x in (cx - radius)..=(cx + radius) {
for y in (cy - radius)..=(cy + radius) {
for z in (cz - radius)..=(cz + radius) {
if let Some(candidates) = grid.get(&(x, y, z)) {
for &b in candidates {
if b <= a {
continue;
}
apply_pair_repulsion_3d(a, b, positions, forces, config);
}
}
}
}
}
}
}
fn grid_cell(position: (f32, f32, f32), cell_size: f32) -> (i32, i32, i32) {
(
(position.0 / cell_size).floor() as i32,
(position.1 / cell_size).floor() as i32,
(position.2 / cell_size).floor() as i32,
)
}
fn apply_pair_repulsion_3d(
a: usize,
b: usize,
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig,
) {
let dx = positions[a].0 - positions[b].0;
let dy = positions[a].1 - positions[b].1;
let dz = positions[a].2 - positions[b].2;
let dist2 = (dx * dx + dy * dy + dz * dz).max(0.01);
let dist = dist2.sqrt();
let magnitude = config.base.repulsion_strength / dist2;
let fx = dx / dist * magnitude * config.horizontal_repulsion;
let fy = dy / dist * magnitude * config.vertical_repulsion;
let fz = dz / dist * magnitude * config.horizontal_repulsion;
forces[a].0 += fx;
forces[a].1 += fy;
forces[a].2 += fz;
forces[b].0 -= fx;
forces[b].1 -= fy;
forces[b].2 -= fz;
}
fn apply_springs(
graph: &Graph,
positions: &[(f32, f32)],
@ -1886,4 +2050,53 @@ mod tests {
.any(|group| group.kind == FoldGroupKind::CoreShell)
);
}
#[test]
fn solver_ticks_incrementally_and_snapshots_layout() {
let mut graph = Graph::new();
let a = graph.add_node(1.0);
let b = graph.add_node(1.0);
graph.add_edge(a, b);
let mut solver = Layout3dSolver::new(graph, Layout3dConfig::default());
let before = solver.positions().to_vec();
solver.tick(4);
let after = solver.positions().to_vec();
let snapshot = solver.snapshot();
assert_ne!(before, after);
assert_eq!(snapshot.nodes.len(), 2);
assert_eq!(snapshot.constraints.ranks.len(), 2);
}
#[test]
fn solver_accepts_warm_start_positions() {
let mut graph = Graph::new();
graph.add_node(1.0);
graph.add_node(1.0);
let initial = [[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]];
let solver =
Layout3dSolver::with_initial_positions(graph, Layout3dConfig::default(), &initial);
assert_eq!(solver.positions()[0], (10.0, 20.0, 30.0));
assert_eq!(solver.positions()[1], (40.0, 50.0, 60.0));
}
#[test]
fn exact_repulsion_mode_still_runs() {
let mut graph = Graph::new();
let a = graph.add_node(1.0);
let b = graph.add_node(1.0);
graph.add_edge(a, b);
let config = Layout3dConfig {
repulsion_mode: RepulsionMode::Exact,
..Layout3dConfig::default()
};
let mut solver = Layout3dSolver::new(graph, config);
solver.tick(2);
assert_eq!(solver.snapshot().nodes.len(), 2);
}
}