From 67924e5b9e319cadb0bdddbd4d70ebb930f5ac6e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 02:34:24 +0100 Subject: [PATCH] Add structural graph analysis and fold groups --- README.md | 54 ++++- src/lib.rs | 681 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 728 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f6b88da..c3e8060 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/lib.rs b/src/lib.rs index 37d9b96..ed63c68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, pub constraints: ConstraintSet, + pub analysis: GraphAnalysis, + pub fold_groups: Vec, +} + +/// Structural analysis used to turn graph anatomy into layout pressure. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GraphAnalysis { + pub weak_components: Vec, + pub strongly_connected_components: Vec, + pub articulation_points: Vec, + pub bridges: Vec, + pub node_metrics: Vec, + pub edge_roles: Vec, + pub node_to_weak_component: Vec, + pub node_to_strong_component: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Component { + pub id: usize, + pub nodes: Vec, +} + +#[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, + pub center: [f32; 3], + pub radius: f32, + pub parent: Option, +} + +#[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::>(); 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 { @@ -339,6 +462,265 @@ fn assign_ranks(graph: &Graph) -> Vec { ranks } +fn directed_adjacency(graph: &Graph) -> Vec> { + 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> { + 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 { + 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]) -> (Vec, Vec) { + struct Tarjan<'a> { + adjacency: &'a [Vec], + index: usize, + stack: Vec, + on_stack: Vec, + indices: Vec>, + lowlink: Vec, + components: Vec, + node_to_component: Vec, + } + + 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, Vec) { + struct Dfs<'a> { + adjacency: &'a [Vec<(usize, EdgeId)>], + time: usize, + visited: Vec, + discovery: Vec, + low: Vec, + articulation: Vec, + bridges: Vec, + } + + impl Dfs<'_> { + fn visit(&mut self, node: usize, parent_edge: Option) { + 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 { + 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 { + 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 { + 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, + positions: &[(f32, f32, f32)], + parent: Option, +) -> 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) + ); + } }