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 ## Current Pipeline
1. Assign ranks from directed edges using a longest-path DAG pass. 1. Analyze graph anatomy: weak components, SCCs, bridges, articulation points,
2. Preserve partial hierarchy for cyclic leftovers with local edge pressure. edge roles, degree metrics, and cyclic nodes.
3. Order nodes inside each rank with repeated barycenter sweeps. 2. Assign ranks from directed edges using a longest-path DAG pass.
4. Generate rank, order, and edge-direction constraints. 3. Preserve partial hierarchy for cyclic leftovers with local edge pressure.
5. Relax the graph with deterministic force iterations. 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 ## 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 The crate does not depend on Bevy just to borrow its vector type; the layout
core stays small and renderer-agnostic. 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 ## Notes
The first release favors a small deterministic core over a sprawling layout 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, pub horizontal_repulsion: f32,
/// Extra multiplier for vertical repulsion. /// Extra multiplier for vertical repulsion.
pub vertical_repulsion: f32, 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 { impl Default for Layout3dConfig {
@ -141,6 +151,11 @@ impl Default for Layout3dConfig {
depth_strength: 0.025, depth_strength: 0.025,
horizontal_repulsion: 1.0, horizontal_repulsion: 1.0,
vertical_repulsion: 0.35, 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 struct Layout3d {
pub nodes: Vec<NodeLayout3d>, pub nodes: Vec<NodeLayout3d>,
pub constraints: ConstraintSet, 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. /// Constraints generated by the Sugiyama pass.
@ -259,15 +333,18 @@ pub fn layout_3d(graph: &Graph, config: &Layout3dConfig) -> Layout3d {
return Layout3d { return Layout3d {
nodes: Vec::new(), nodes: Vec::new(),
constraints: ConstraintSet::default(), constraints: ConstraintSet::default(),
analysis: GraphAnalysis::default(),
fold_groups: Vec::new(),
}; };
} }
let analysis = analyze(graph);
let ranks = assign_ranks(graph); let ranks = assign_ranks(graph);
let orders = order_ranks(graph, &ranks); let orders = order_ranks(graph, &ranks);
let constraints = build_constraints(graph, &ranks, &orders, &config.base); let constraints = build_constraints(graph, &ranks, &orders, &config.base);
let mut positions = initial_positions_3d(&ranks, &orders, config); 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) let mut nodes = (0..n)
.map(|idx| NodeLayout3d { .map(|idx| NodeLayout3d {
@ -281,7 +358,53 @@ pub fn layout_3d(graph: &Graph, config: &Layout3dConfig) -> Layout3d {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
nodes.sort_by_key(|node| node.id.0); 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> { fn assign_ranks(graph: &Graph) -> Vec<usize> {
@ -339,6 +462,265 @@ fn assign_ranks(graph: &Graph) -> Vec<usize> {
ranks 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]) { fn normalize_ranks(ranks: &mut [usize]) {
let mut used = ranks.to_vec(); let mut used = ranks.to_vec();
used.sort_unstable(); used.sort_unstable();
@ -515,6 +897,7 @@ fn relax(
fn relax_3d( fn relax_3d(
graph: &Graph, graph: &Graph,
constraints: &ConstraintSet, constraints: &ConstraintSet,
analysis: &GraphAnalysis,
config: &Layout3dConfig, config: &Layout3dConfig,
positions: &mut [(f32, f32, f32)], positions: &mut [(f32, f32, f32)],
) { ) {
@ -527,6 +910,7 @@ fn relax_3d(
apply_repulsion_3d(positions, &mut forces, config); apply_repulsion_3d(positions, &mut forces, config);
apply_springs_3d(graph, positions, &mut forces, config); apply_springs_3d(graph, positions, &mut forces, config);
apply_constraints_3d(constraints, 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); apply_grounding_3d(positions, &mut forces, config);
let cooling = 1.0 - (iter as f32 / config.base.iterations.max(1) as f32); 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) { fn apply_repulsion(positions: &[(f32, f32)], forces: &mut [(f32, f32)], strength: f32) {
for a in 0..positions.len() { for a in 0..positions.len() {
for b in (a + 1)..positions.len() { for b in (a + 1)..positions.len() {
@ -827,4 +1447,61 @@ mod tests {
assert!(result.nodes.is_empty()); assert!(result.nodes.is_empty());
assert!(result.constraints.ranks.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)
);
}
} }