Add structural graph analysis and fold groups

This commit is contained in:
Meta Vens 2026-05-07 02:34:24 +01:00
commit 67924e5b9e
2 changed files with 728 additions and 7 deletions

View file

@ -15,11 +15,15 @@ The result is a spectrum:
## Current Pipeline
1. Assign ranks from directed edges using a longest-path DAG pass.
2. Preserve partial hierarchy for cyclic leftovers with local edge pressure.
3. Order nodes inside each rank with repeated barycenter sweeps.
4. Generate rank, order, and edge-direction constraints.
5. Relax the graph with deterministic force iterations.
1. Analyze graph anatomy: weak components, SCCs, bridges, articulation points,
edge roles, degree metrics, and cyclic nodes.
2. Assign ranks from directed edges using a longest-path DAG pass.
3. Preserve partial hierarchy for cyclic leftovers with local edge pressure.
4. Order nodes inside each rank with repeated barycenter sweeps.
5. Generate rank, order, and edge-direction constraints.
6. Relax the graph with deterministic force iterations.
7. Emit fold groups for renderer-side collapse, expansion, highlighting, or
protein-ish folding.
## Example
@ -68,6 +72,46 @@ 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.
## Graph Analysis And Folding
The crate exposes `analyze(&graph)` for layout-independent structure reads:
- weak components
- strongly connected components
- articulation points
- bridge edges
- per-node degree metrics
- edge roles
- cyclic node flags
`layout_3d` uses that same analysis to add structural pressure:
- SCC cohesion folds cycles and tight feedback regions into local bodies.
- weak component cohesion keeps broad regions visually legible.
- bridge hinges keep connector edges readable between bodies.
- centrality anchoring keeps important nodes from drifting into decorative exile.
- cycle folding uses depth so feedback does not have to lose every argument
against hierarchy.
The result includes `fold_groups`, which are renderer-facing bodies:
```rust
for group in result.fold_groups {
println!(
"{:?}: center={:?} radius={} nodes={}",
group.kind,
group.center,
group.radius,
group.nodes.len()
);
}
```
Fold groups currently cover weak components, strongly connected components, and
cycle bodies. They are meant to be useful handles for Bevy interactions:
collapse a component, pulse a cycle, dim a subtree, or unfold a dense region
without asking the raw node soup for permission.
## Notes
The first release favors a small deterministic core over a sprawling layout

View file

@ -130,6 +130,16 @@ pub struct Layout3dConfig {
pub horizontal_repulsion: f32,
/// Extra multiplier for vertical repulsion.
pub vertical_repulsion: f32,
/// Force pulling strongly connected components into foldable bodies.
pub component_cohesion_strength: f32,
/// Force pulling weakly connected components into broad local regions.
pub weak_component_cohesion_strength: f32,
/// Force letting bridge edges behave like hinges between folded bodies.
pub bridge_hinge_strength: f32,
/// Force making cyclic nodes fold into depth instead of fighting hierarchy.
pub cycle_fold_strength: f32,
/// Force anchoring high-centrality nodes closer to their component center.
pub centrality_anchor_strength: f32,
}
impl Default for Layout3dConfig {
@ -141,6 +151,11 @@ impl Default for Layout3dConfig {
depth_strength: 0.025,
horizontal_repulsion: 1.0,
vertical_repulsion: 0.35,
component_cohesion_strength: 0.045,
weak_component_cohesion_strength: 0.012,
bridge_hinge_strength: 0.025,
cycle_fold_strength: 0.035,
centrality_anchor_strength: 0.02,
}
}
}
@ -186,6 +201,65 @@ impl NodeLayout3d {
pub struct Layout3d {
pub nodes: Vec<NodeLayout3d>,
pub constraints: ConstraintSet,
pub analysis: GraphAnalysis,
pub fold_groups: Vec<FoldGroup>,
}
/// Structural analysis used to turn graph anatomy into layout pressure.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GraphAnalysis {
pub weak_components: Vec<Component>,
pub strongly_connected_components: Vec<Component>,
pub articulation_points: Vec<NodeId>,
pub bridges: Vec<EdgeId>,
pub node_metrics: Vec<NodeMetrics>,
pub edge_roles: Vec<EdgeRole>,
pub node_to_weak_component: Vec<usize>,
pub node_to_strong_component: Vec<usize>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Component {
pub id: usize,
pub nodes: Vec<NodeId>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct NodeMetrics {
pub in_degree: usize,
pub out_degree: usize,
pub undirected_degree: usize,
pub degree_centrality: f32,
pub is_articulation: bool,
pub is_cyclic: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum EdgeRole {
Tree,
Back,
#[default]
Cross,
Bridge,
IntraComponent,
}
/// A renderer-facing body that can be folded, collapsed, or highlighted.
#[derive(Clone, Debug, PartialEq)]
pub struct FoldGroup {
pub id: usize,
pub kind: FoldGroupKind,
pub nodes: Vec<NodeId>,
pub center: [f32; 3],
pub radius: f32,
pub parent: Option<usize>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FoldGroupKind {
WeakComponent,
StrongComponent,
Cycle,
}
/// Constraints generated by the Sugiyama pass.
@ -259,15 +333,18 @@ pub fn layout_3d(graph: &Graph, config: &Layout3dConfig) -> Layout3d {
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, config, &mut positions);
relax_3d(graph, &constraints, &analysis, config, &mut positions);
let mut nodes = (0..n)
.map(|idx| NodeLayout3d {
@ -281,7 +358,53 @@ pub fn layout_3d(graph: &Graph, config: &Layout3dConfig) -> Layout3d {
.collect::<Vec<_>>();
nodes.sort_by_key(|node| node.id.0);
Layout3d { nodes, constraints }
let fold_groups = build_fold_groups(&analysis, &positions);
Layout3d {
nodes,
constraints,
analysis,
fold_groups,
}
}
/// Analyze graph structure without running layout.
pub fn analyze(graph: &Graph) -> GraphAnalysis {
let n = graph.node_count();
if n == 0 {
return GraphAnalysis::default();
}
let directed = directed_adjacency(graph);
let undirected = undirected_adjacency(graph);
let weak_components = weak_components(&undirected);
let (sccs, node_to_scc) = strongly_connected_components(&directed);
let (articulation_points, bridges) = articulation_points_and_bridges(&undirected);
let edge_roles = classify_edges(graph, &node_to_scc, &bridges);
let node_metrics = node_metrics(
graph,
&undirected,
&articulation_points,
&sccs,
&node_to_scc,
);
let mut node_to_weak_component = vec![0usize; n];
for component in &weak_components {
for node in &component.nodes {
node_to_weak_component[node.0] = component.id;
}
}
GraphAnalysis {
weak_components,
strongly_connected_components: sccs,
articulation_points,
bridges,
node_metrics,
edge_roles,
node_to_weak_component,
node_to_strong_component: node_to_scc,
}
}
fn assign_ranks(graph: &Graph) -> Vec<usize> {
@ -339,6 +462,265 @@ fn assign_ranks(graph: &Graph) -> Vec<usize> {
ranks
}
fn directed_adjacency(graph: &Graph) -> Vec<Vec<usize>> {
let mut adjacency = vec![Vec::new(); graph.node_count()];
for &(source, target) in graph.edges() {
adjacency[source.0].push(target.0);
}
adjacency
}
fn undirected_adjacency(graph: &Graph) -> Vec<Vec<(usize, EdgeId)>> {
let mut adjacency = vec![Vec::new(); graph.node_count()];
for (idx, &(source, target)) in graph.edges().iter().enumerate() {
let edge = EdgeId(idx);
adjacency[source.0].push((target.0, edge));
adjacency[target.0].push((source.0, edge));
}
adjacency
}
fn weak_components(adjacency: &[Vec<(usize, EdgeId)>]) -> Vec<Component> {
let mut components = Vec::new();
let mut seen = vec![false; adjacency.len()];
for start in 0..adjacency.len() {
if seen[start] {
continue;
}
let mut queue = VecDeque::from([start]);
let mut nodes = Vec::new();
seen[start] = true;
while let Some(node) = queue.pop_front() {
nodes.push(NodeId(node));
for &(next, _) in &adjacency[node] {
if !seen[next] {
seen[next] = true;
queue.push_back(next);
}
}
}
components.push(Component {
id: components.len(),
nodes,
});
}
components
}
fn strongly_connected_components(adjacency: &[Vec<usize>]) -> (Vec<Component>, Vec<usize>) {
struct Tarjan<'a> {
adjacency: &'a [Vec<usize>],
index: usize,
stack: Vec<usize>,
on_stack: Vec<bool>,
indices: Vec<Option<usize>>,
lowlink: Vec<usize>,
components: Vec<Component>,
node_to_component: Vec<usize>,
}
impl Tarjan<'_> {
fn visit(&mut self, node: usize) {
self.indices[node] = Some(self.index);
self.lowlink[node] = self.index;
self.index += 1;
self.stack.push(node);
self.on_stack[node] = true;
for &next in &self.adjacency[node] {
if self.indices[next].is_none() {
self.visit(next);
self.lowlink[node] = self.lowlink[node].min(self.lowlink[next]);
} else if self.on_stack[next] {
self.lowlink[node] = self.lowlink[node].min(self.indices[next].unwrap_or(0));
}
}
if self.lowlink[node] == self.indices[node].unwrap_or(0) {
let id = self.components.len();
let mut nodes = Vec::new();
while let Some(member) = self.stack.pop() {
self.on_stack[member] = false;
self.node_to_component[member] = id;
nodes.push(NodeId(member));
if member == node {
break;
}
}
nodes.sort_by_key(|node| node.0);
self.components.push(Component { id, nodes });
}
}
}
let n = adjacency.len();
let mut tarjan = Tarjan {
adjacency,
index: 0,
stack: Vec::new(),
on_stack: vec![false; n],
indices: vec![None; n],
lowlink: vec![0; n],
components: Vec::new(),
node_to_component: vec![0; n],
};
for node in 0..n {
if tarjan.indices[node].is_none() {
tarjan.visit(node);
}
}
(tarjan.components, tarjan.node_to_component)
}
fn articulation_points_and_bridges(
adjacency: &[Vec<(usize, EdgeId)>],
) -> (Vec<NodeId>, Vec<EdgeId>) {
struct Dfs<'a> {
adjacency: &'a [Vec<(usize, EdgeId)>],
time: usize,
visited: Vec<bool>,
discovery: Vec<usize>,
low: Vec<usize>,
articulation: Vec<bool>,
bridges: Vec<EdgeId>,
}
impl Dfs<'_> {
fn visit(&mut self, node: usize, parent_edge: Option<EdgeId>) {
self.visited[node] = true;
self.discovery[node] = self.time;
self.low[node] = self.time;
self.time += 1;
let mut child_count = 0usize;
for &(next, edge) in &self.adjacency[node] {
if Some(edge) == parent_edge {
continue;
}
if self.visited[next] {
self.low[node] = self.low[node].min(self.discovery[next]);
continue;
}
child_count += 1;
self.visit(next, Some(edge));
self.low[node] = self.low[node].min(self.low[next]);
if parent_edge.is_some() && self.low[next] >= self.discovery[node] {
self.articulation[node] = true;
}
if self.low[next] > self.discovery[node] {
self.bridges.push(edge);
}
}
if parent_edge.is_none() && child_count > 1 {
self.articulation[node] = true;
}
}
}
let n = adjacency.len();
let mut dfs = Dfs {
adjacency,
time: 0,
visited: vec![false; n],
discovery: vec![0; n],
low: vec![0; n],
articulation: vec![false; n],
bridges: Vec::new(),
};
for node in 0..n {
if !dfs.visited[node] {
dfs.visit(node, None);
}
}
let articulation_points = dfs
.articulation
.iter()
.enumerate()
.filter_map(|(idx, &is_articulation)| is_articulation.then_some(NodeId(idx)))
.collect();
dfs.bridges.sort_by_key(|edge| edge.0);
(articulation_points, dfs.bridges)
}
fn classify_edges(graph: &Graph, node_to_scc: &[usize], bridges: &[EdgeId]) -> Vec<EdgeRole> {
let mut roles = vec![EdgeRole::Cross; graph.edge_count()];
let mut bridge_marks = vec![false; graph.edge_count()];
for edge in bridges {
bridge_marks[edge.0] = true;
}
for (idx, &(source, target)) in graph.edges().iter().enumerate() {
roles[idx] = if bridge_marks[idx] {
EdgeRole::Bridge
} else if node_to_scc[source.0] == node_to_scc[target.0] {
EdgeRole::IntraComponent
} else if source.0 < target.0 {
EdgeRole::Tree
} else {
EdgeRole::Back
};
}
roles
}
fn node_metrics(
graph: &Graph,
undirected: &[Vec<(usize, EdgeId)>],
articulation_points: &[NodeId],
sccs: &[Component],
node_to_scc: &[usize],
) -> Vec<NodeMetrics> {
let n = graph.node_count();
let mut in_degree = vec![0usize; n];
let mut out_degree = vec![0usize; n];
for &(source, target) in graph.edges() {
out_degree[source.0] += 1;
in_degree[target.0] += 1;
}
let mut articulation = vec![false; n];
for node in articulation_points {
articulation[node.0] = true;
}
let mut cyclic_scc = vec![false; sccs.len()];
for component in sccs {
if component.nodes.len() > 1 {
cyclic_scc[component.id] = true;
}
}
for &(source, target) in graph.edges() {
if source == target {
cyclic_scc[node_to_scc[source.0]] = true;
}
}
let centrality_denominator = n.saturating_sub(1).max(1) as f32;
(0..n)
.map(|idx| NodeMetrics {
in_degree: in_degree[idx],
out_degree: out_degree[idx],
undirected_degree: undirected[idx].len(),
degree_centrality: undirected[idx].len() as f32 / centrality_denominator,
is_articulation: articulation[idx],
is_cyclic: cyclic_scc[node_to_scc[idx]],
})
.collect()
}
fn normalize_ranks(ranks: &mut [usize]) {
let mut used = ranks.to_vec();
used.sort_unstable();
@ -515,6 +897,7 @@ fn relax(
fn relax_3d(
graph: &Graph,
constraints: &ConstraintSet,
analysis: &GraphAnalysis,
config: &Layout3dConfig,
positions: &mut [(f32, f32, f32)],
) {
@ -527,6 +910,7 @@ fn relax_3d(
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, 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);
@ -539,6 +923,242 @@ fn relax_3d(
}
}
fn apply_structural_forces_3d(
graph: &Graph,
analysis: &GraphAnalysis,
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig,
) {
apply_component_cohesion(
&analysis.strongly_connected_components,
positions,
forces,
config.component_cohesion_strength,
);
apply_component_cohesion(
&analysis.weak_components,
positions,
forces,
config.weak_component_cohesion_strength,
);
apply_centrality_anchors(analysis, positions, forces, config);
apply_cycle_folds(analysis, positions, forces, config);
apply_bridge_hinges(graph, analysis, positions, forces, config);
}
fn apply_component_cohesion(
components: &[Component],
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
strength: f32,
) {
if strength <= 0.0 {
return;
}
for component in components {
if component.nodes.len() < 2 {
continue;
}
let center = component_center(&component.nodes, positions);
for node in &component.nodes {
let idx = node.0;
forces[idx].0 += (center[0] - positions[idx].0) * strength;
forces[idx].1 += (center[1] - positions[idx].1) * strength * 0.25;
forces[idx].2 += (center[2] - positions[idx].2) * strength;
}
}
}
fn apply_centrality_anchors(
analysis: &GraphAnalysis,
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig,
) {
if config.centrality_anchor_strength <= 0.0 {
return;
}
for component in &analysis.weak_components {
if component.nodes.len() < 3 {
continue;
}
let center = component_center(&component.nodes, positions);
for node in &component.nodes {
let idx = node.0;
let metric = analysis.node_metrics[idx];
let strength = config.centrality_anchor_strength * metric.degree_centrality;
forces[idx].0 += (center[0] - positions[idx].0) * strength;
forces[idx].2 += (center[2] - positions[idx].2) * strength;
}
}
}
fn apply_cycle_folds(
analysis: &GraphAnalysis,
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig,
) {
if config.cycle_fold_strength <= 0.0 {
return;
}
for component in &analysis.strongly_connected_components {
if component.nodes.len() < 2 {
continue;
}
let center = component_center(&component.nodes, positions);
for (offset, node) in component.nodes.iter().enumerate() {
let idx = node.0;
let side = if offset % 2 == 0 { 1.0 } else { -1.0 };
let target_z =
center[2] + side * config.depth_gap * component.nodes.len() as f32 * 0.35;
forces[idx].2 += (target_z - positions[idx].2) * config.cycle_fold_strength;
forces[idx].1 -= positions[idx].1 * config.cycle_fold_strength * 0.08;
}
}
}
fn apply_bridge_hinges(
graph: &Graph,
analysis: &GraphAnalysis,
positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig,
) {
if config.bridge_hinge_strength <= 0.0 {
return;
}
for bridge in &analysis.bridges {
let (source, target) = graph.edges()[bridge.0];
let source_component = analysis.node_to_weak_component[source.0];
let target_component = analysis.node_to_weak_component[target.0];
if source_component == target_component {
let midpoint = midpoint_3d(positions[source.0], positions[target.0]);
for node in [source, target] {
let idx = node.0;
let multiplier = if analysis.node_metrics[idx].is_articulation {
1.5
} else {
1.0
};
forces[idx].0 +=
(midpoint.0 - positions[idx].0) * config.bridge_hinge_strength * multiplier;
forces[idx].2 +=
(midpoint.2 - positions[idx].2) * config.bridge_hinge_strength * multiplier;
}
}
}
}
fn component_center(nodes: &[NodeId], positions: &[(f32, f32, f32)]) -> [f32; 3] {
let mut center = [0.0f32; 3];
for node in nodes {
center[0] += positions[node.0].0;
center[1] += positions[node.0].1;
center[2] += positions[node.0].2;
}
let len = nodes.len().max(1) as f32;
center[0] /= len;
center[1] /= len;
center[2] /= len;
center
}
fn midpoint_3d(a: (f32, f32, f32), b: (f32, f32, f32)) -> (f32, f32, f32) {
((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5, (a.2 + b.2) * 0.5)
}
fn build_fold_groups(analysis: &GraphAnalysis, positions: &[(f32, f32, f32)]) -> Vec<FoldGroup> {
let mut groups = Vec::new();
for component in &analysis.weak_components {
if component.nodes.is_empty() {
continue;
}
groups.push(fold_group(
groups.len(),
FoldGroupKind::WeakComponent,
component.nodes.clone(),
positions,
None,
));
}
for component in &analysis.strongly_connected_components {
if component.nodes.len() < 2 {
continue;
}
let parent = component
.nodes
.first()
.map(|node| analysis.node_to_weak_component[node.0]);
groups.push(fold_group(
groups.len(),
FoldGroupKind::StrongComponent,
component.nodes.clone(),
positions,
parent,
));
}
for component in &analysis.strongly_connected_components {
let is_cycle = component
.nodes
.iter()
.any(|node| analysis.node_metrics[node.0].is_cyclic);
if !is_cycle || component.nodes.len() < 2 {
continue;
}
let parent = component
.nodes
.first()
.map(|node| analysis.node_to_weak_component[node.0]);
groups.push(fold_group(
groups.len(),
FoldGroupKind::Cycle,
component.nodes.clone(),
positions,
parent,
));
}
groups
}
fn fold_group(
id: usize,
kind: FoldGroupKind,
nodes: Vec<NodeId>,
positions: &[(f32, f32, f32)],
parent: Option<usize>,
) -> FoldGroup {
let center = component_center(&nodes, positions);
let mut radius = 0.0f32;
for node in &nodes {
let dx = positions[node.0].0 - center[0];
let dy = positions[node.0].1 - center[1];
let dz = positions[node.0].2 - center[2];
radius = radius.max((dx * dx + dy * dy + dz * dz).sqrt());
}
FoldGroup {
id,
kind,
nodes,
center,
radius,
parent,
}
}
fn apply_repulsion(positions: &[(f32, f32)], forces: &mut [(f32, f32)], strength: f32) {
for a in 0..positions.len() {
for b in (a + 1)..positions.len() {
@ -827,4 +1447,61 @@ mod tests {
assert!(result.nodes.is_empty());
assert!(result.constraints.ranks.is_empty());
}
#[test]
fn analysis_finds_cycles_bridges_and_articulation_points() {
let mut graph = Graph::new();
let a = graph.add_node(1.0);
let b = graph.add_node(1.0);
let c = graph.add_node(1.0);
let d = graph.add_node(1.0);
graph.add_edge(a, b);
graph.add_edge(b, c);
graph.add_edge(c, a);
let bridge = graph.add_edge(c, d);
let analysis = analyze(&graph);
assert_eq!(analysis.weak_components.len(), 1);
assert_eq!(analysis.strongly_connected_components.len(), 2);
assert!(analysis.node_metrics[a.0].is_cyclic);
assert!(analysis.node_metrics[b.0].is_cyclic);
assert!(analysis.node_metrics[c.0].is_cyclic);
assert!(!analysis.node_metrics[d.0].is_cyclic);
assert!(analysis.articulation_points.contains(&c));
assert!(analysis.bridges.contains(&bridge));
assert_eq!(analysis.edge_roles[bridge.0], EdgeRole::Bridge);
}
#[test]
fn layout_3d_exposes_fold_groups() {
let mut graph = Graph::new();
let a = graph.add_node(1.0);
let b = graph.add_node(1.0);
let c = graph.add_node(1.0);
graph.add_edge(a, b);
graph.add_edge(b, c);
graph.add_edge(c, a);
let result = layout_3d(&graph, &Layout3dConfig::default());
assert!(
result
.fold_groups
.iter()
.any(|group| group.kind == FoldGroupKind::WeakComponent)
);
assert!(
result
.fold_groups
.iter()
.any(|group| group.kind == FoldGroupKind::StrongComponent)
);
assert!(
result
.fold_groups
.iter()
.any(|group| group.kind == FoldGroupKind::Cycle)
);
}
}