Add structural graph analysis and fold groups
This commit is contained in:
parent
4d1f2fc94f
commit
67924e5b9e
2 changed files with 728 additions and 7 deletions
54
README.md
54
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
|
||||
|
|
|
|||
681
src/lib.rs
681
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<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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue