Editor UI utilities for arranging Prism graph nodes. Provides alignment, distribution, snap-to-grid, auto-layout (layering, ordering, straightening), and helpers for measuring node sizes and applying batched moves via GraphMutations.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Undo;
namespace Editor.Prism.Ui;
/// <summary>Which edge or axis an alignment operation snaps to.</summary>
public enum AlignEdge
{
/// <summary>Align left edges.</summary>
Left,
/// <summary>Align horizontal centres.</summary>
CenterX,
/// <summary>Align right edges.</summary>
Right,
/// <summary>Align top edges.</summary>
Top,
/// <summary>Align vertical middles.</summary>
Middle,
/// <summary>Align bottom edges.</summary>
Bottom
}
/// <summary>Tuning for <see cref="AlignTools.AutoLayout"/>.</summary>
public sealed record AutoLayoutOptions
{
/// <summary>Horizontal gap between one layer's right edge and the next layer's left edge.</summary>
public float LayerGap { get; init; } = 96f;
/// <summary>Vertical gap between two cards in the same layer.</summary>
public float NodeGap { get; init; } = 32f;
/// <summary>How many median-ordering sweeps to run. Four is where the returns stop.</summary>
public int OrderingPasses { get; init; } = 4;
/// <summary>How many straightening passes to run after ordering.</summary>
public int StraightenPasses { get; init; } = 6;
/// <summary>Grid the result snaps to. Zero disables snapping.</summary>
/// <remarks>
/// Initialised from <c>PrismConstants.GridSize</c> rather than <c>PrismTheme.GridSize</c> on purpose.
/// s&box's code generator copies an auto-property's initialiser verbatim into a generated
/// <c>[DefaultValue( ... )]</c>, so the initialiser of an int/float/bool/enum auto-property has to be a
/// compile-time constant. <c>PrismTheme.GridSize</c> is a mutable static so a theme can change it,
/// which would make the generated attribute fail to compile in the editor.
/// </remarks>
public float Grid { get; init; } = PrismConstants.GridSize;
/// <summary>The defaults, which are what the toolbar and the Graph menu use.</summary>
public static readonly AutoLayoutOptions Default = new();
}
/// <summary>
/// Align, distribute and lay out.
/// <para>
/// Everything here is a pure function of node positions plus a measuring delegate, and every mutation
/// goes through <c>GraphMutations.Move</c> inside one scope — so aligning eleven nodes is one entry in
/// the History panel, and undoing it puts all eleven back.
/// </para>
/// <para>
/// The auto-layout is a real layered layout, not a grid sort: longest-path layering, median-heuristic
/// crossing reduction with adjacent-transpose refinement, then a priority straightening pass that pulls
/// each node towards the average of what it is wired to without letting cards overlap. On a shader
/// graph — wide, shallow, almost always a DAG — that produces something a human would accept.
/// </para>
/// </summary>
public static class AlignTools
{
/// <summary>Fallback card size when nothing better is known, in scene units.</summary>
static readonly Vector2 s_defaultSize = new( PrismTheme.NodeMinWidth, 96f );
// ---------------------------------------------------------------- align ----
/// <summary>Align a selection to one edge. Returns how many nodes moved.</summary>
public static int Align( GraphMutations mutations, IEnumerable<PrismNode> nodes, AlignEdge edge,
Func<PrismNode, Vector2> measure = null )
{
var targets = Materialise( nodes );
if ( mutations is null || targets.Count < 2 ) return 0;
measure ??= Measure;
var horizontal = edge is AlignEdge.Left or AlignEdge.CenterX or AlignEdge.Right;
var anchor = edge switch
{
AlignEdge.Left or AlignEdge.Top => 0f,
AlignEdge.CenterX or AlignEdge.Middle => 0.5f,
_ => 1f
};
var min = float.MaxValue;
var max = float.MinValue;
foreach ( var node in targets )
{
var size = measure( node );
var position = node.Position;
var low = horizontal ? position.x : position.y;
var high = low + ( horizontal ? size.x : size.y );
min = MathF.Min( min, low );
max = MathF.Max( max, high );
}
var line = min + ( max - min ) * anchor;
var moves = new Dictionary<NodeId, Vector2>();
foreach ( var node in targets )
{
var size = measure( node );
var position = node.Position;
if ( horizontal ) position.x = line - size.x * anchor;
else position.y = line - size.y * anchor;
moves[node.Id] = Snap( position, PrismTheme.GridSize );
}
return Apply( mutations, moves, "Align" );
}
// ---------------------------------------------------------------- distribute ----
/// <summary>
/// Space a selection evenly between its two extremes, keeping the outermost nodes where they are.
/// Gaps are equalised, not centres, so cards of different heights read as evenly spaced.
/// </summary>
public static int Distribute( GraphMutations mutations, IEnumerable<PrismNode> nodes, bool horizontal,
Func<PrismNode, Vector2> measure = null )
{
var targets = Materialise( nodes );
if ( mutations is null || targets.Count < 3 ) return 0;
measure ??= Measure;
var ordered = targets
.OrderBy( x => horizontal ? x.Position.x : x.Position.y )
.ToList();
var first = ordered[0];
var last = ordered[^1];
var start = horizontal ? first.Position.x : first.Position.y;
var end = horizontal
? last.Position.x + measure( last ).x
: last.Position.y + measure( last ).y;
var occupied = 0f;
foreach ( var node in ordered )
{
var size = measure( node );
occupied += horizontal ? size.x : size.y;
}
var gap = ( end - start - occupied ) / ( ordered.Count - 1 );
var moves = new Dictionary<NodeId, Vector2>();
var cursor = start;
foreach ( var node in ordered )
{
var size = measure( node );
var position = node.Position;
if ( horizontal ) position.x = cursor;
else position.y = cursor;
moves[node.Id] = Snap( position, PrismTheme.GridSize );
cursor += ( horizontal ? size.x : size.y ) + gap;
}
return Apply( mutations, moves, "Distribute" );
}
/// <summary>
/// Snap a selection — or the whole document — to the grid. A non-positive grid means "the theme's",
/// which cannot be a default parameter value because the grid size is a mutable theme token.
/// </summary>
public static int SnapToGrid( GraphMutations mutations, IEnumerable<PrismNode> nodes,
float grid = -1f )
{
if ( grid <= 0f ) grid = PrismTheme.GridSize;
var targets = Materialise( nodes );
if ( mutations is null || targets.Count == 0 || grid <= 0f ) return 0;
var moves = new Dictionary<NodeId, Vector2>();
foreach ( var node in targets )
{
moves[node.Id] = Snap( node.Position, grid );
}
return Apply( mutations, moves, "Snap To Grid" );
}
// ---------------------------------------------------------------- auto-layout ----
/// <summary>
/// Lay out a graph — or a subset of it — left to right by data flow.
/// <para>
/// Nodes outside <paramref name="subset"/> are not moved and do not participate, so auto-laying a
/// selection rearranges only that selection, in place, within its own bounding box.
/// </para>
/// </summary>
public static int AutoLayout( GraphMutations mutations, PrismGraph graph, IEnumerable<PrismNode> subset = null,
AutoLayoutOptions options = null, Func<PrismNode, Vector2> measure = null )
{
if ( mutations is null || graph is null ) return 0;
options ??= AutoLayoutOptions.Default;
measure ??= Measure;
var nodes = Materialise( subset );
if ( nodes.Count == 0 ) nodes = graph.Nodes.Where( x => x is not null ).ToList();
if ( nodes.Count < 2 ) return 0;
var index = new Dictionary<NodeId, int>();
for ( int i = 0; i < nodes.Count; i++ ) index[nodes[i].Id] = i;
var successors = BuildAdjacency( graph, nodes, index, out var predecessors );
// Where the block starts now, so the result lands roughly where the user was looking rather
// than at the origin.
var origin = Origin( nodes );
var layers = Layer( nodes, predecessors );
var columns = GroupIntoColumns( nodes, layers );
OrderColumns( columns, successors, predecessors, options.OrderingPasses );
var moves = Place( columns, nodes, successors, predecessors, measure, options, origin );
return Apply( mutations, moves, "Auto-Layout" );
}
/// <summary>Nodes that feed nothing reachable from an output. The "Clean Up Unused" candidates.</summary>
public static IReadOnlyList<PrismNode> UnusedNodes( PrismGraph graph )
{
if ( graph is null ) return Array.Empty<PrismNode>();
var reachable = PrismLog.Guard<IReadOnlyCollection<NodeId>>( "Find reachable nodes",
() => GraphQueries.ReachableFromOutputs( graph ), Array.Empty<NodeId>() );
if ( reachable is null || reachable.Count == 0 ) return Array.Empty<PrismNode>();
var keep = new HashSet<NodeId>( reachable );
return graph.Nodes
.Where( x => x is not null && !keep.Contains( x.Id ) )
.ToArray();
}
// ---------------------------------------------------------------- layering ----
static Dictionary<int, List<int>> BuildAdjacency( PrismGraph graph, IReadOnlyList<PrismNode> nodes,
IReadOnlyDictionary<NodeId, int> index, out Dictionary<int, List<int>> predecessors )
{
var successors = new Dictionary<int, List<int>>();
predecessors = new Dictionary<int, List<int>>();
for ( int i = 0; i < nodes.Count; i++ )
{
successors[i] = new List<int>();
predecessors[i] = new List<int>();
}
foreach ( var edge in graph.Edges )
{
if ( edge is null ) continue;
if ( !index.TryGetValue( edge.FromNode, out var from ) ) continue;
if ( !index.TryGetValue( edge.ToNode, out var to ) ) continue;
if ( from == to ) continue;
if ( !successors[from].Contains( to ) ) successors[from].Add( to );
if ( !predecessors[to].Contains( from ) ) predecessors[to].Add( from );
}
return successors;
}
/// <summary>
/// Longest-path layering. Cycles cannot make this diverge: the depth-first walk carries a visiting
/// set and treats a back edge as absent, which is exactly how a cyclic graph should lay out — as
/// though the loop had been cut once.
/// </summary>
static int[] Layer( IReadOnlyList<PrismNode> nodes, IReadOnlyDictionary<int, List<int>> predecessors )
{
var layers = new int[nodes.Count];
var state = new byte[nodes.Count];
for ( int i = 0; i < nodes.Count; i++ ) layers[i] = -1;
for ( int i = 0; i < nodes.Count; i++ )
{
Depth( i );
}
return layers;
int Depth( int node )
{
if ( layers[node] >= 0 ) return layers[node];
// Already on the stack: a cycle. Report zero so the caller carries on without recursing.
if ( state[node] == 1 ) return 0;
state[node] = 1;
var depth = 0;
foreach ( var predecessor in predecessors[node] )
{
depth = Math.Max( depth, Depth( predecessor ) + 1 );
}
state[node] = 2;
layers[node] = depth;
return depth;
}
}
static List<List<int>> GroupIntoColumns( IReadOnlyList<PrismNode> nodes, int[] layers )
{
var count = 0;
for ( int i = 0; i < layers.Length; i++ ) count = Math.Max( count, layers[i] + 1 );
var columns = new List<List<int>>( Math.Max( 1, count ) );
for ( int i = 0; i < count; i++ ) columns.Add( new List<int>() );
// Seeding each column in current vertical order means a graph that was already tidy stays close
// to where it was, which makes auto-layout feel like a nudge rather than a shuffle.
var order = Enumerable.Range( 0, nodes.Count )
.OrderBy( i => nodes[i].Position.y )
.ThenBy( i => nodes[i].Position.x );
foreach ( var i in order )
{
var layer = Math.Clamp( layers[i], 0, count - 1 );
columns[layer].Add( i );
}
return columns;
}
// ---------------------------------------------------------------- ordering ----
static void OrderColumns( List<List<int>> columns, IReadOnlyDictionary<int, List<int>> successors,
IReadOnlyDictionary<int, List<int>> predecessors, int passes )
{
var rank = new Dictionary<int, int>();
Rerank();
for ( int pass = 0; pass < Math.Max( 1, passes ); pass++ )
{
var forward = ( pass & 1 ) == 0;
if ( forward )
{
for ( int c = 1; c < columns.Count; c++ ) Median( columns[c], predecessors );
}
else
{
for ( int c = columns.Count - 2; c >= 0; c-- ) Median( columns[c], successors );
}
Rerank();
foreach ( var column in columns ) Transpose( column );
Rerank();
}
void Rerank()
{
rank.Clear();
foreach ( var column in columns )
{
for ( int i = 0; i < column.Count; i++ ) rank[column[i]] = i;
}
}
void Median( List<int> column, IReadOnlyDictionary<int, List<int>> neighbours )
{
if ( column.Count < 2 ) return;
var keys = new Dictionary<int, float>( column.Count );
for ( int i = 0; i < column.Count; i++ )
{
keys[column[i]] = MedianOf( column[i], neighbours, rank, i );
}
column.Sort( ( a, b ) => keys[a].CompareTo( keys[b] ) );
}
void Transpose( List<int> column )
{
if ( column.Count < 2 ) return;
var improved = true;
var guard = 0;
while ( improved && guard++ < 8 )
{
improved = false;
for ( int i = 0; i + 1 < column.Count; i++ )
{
var a = column[i];
var b = column[i + 1];
if ( Crossings( a, b ) <= Crossings( b, a ) ) continue;
column[i] = b;
column[i + 1] = a;
rank[b] = i;
rank[a] = i + 1;
improved = true;
}
}
}
int Crossings( int upper, int lower )
{
var count = 0;
foreach ( var a in Neighbours( upper ) )
{
foreach ( var b in Neighbours( lower ) )
{
if ( Position( a ) > Position( b ) ) count++;
}
}
return count;
}
IEnumerable<int> Neighbours( int node ) =>
predecessors[node].Concat( successors[node] );
int Position( int node ) => rank.TryGetValue( node, out var value ) ? value : 0;
}
static float MedianOf( int node, IReadOnlyDictionary<int, List<int>> neighbours,
IReadOnlyDictionary<int, int> rank, int fallback )
{
if ( !neighbours.TryGetValue( node, out var list ) || list.Count == 0 ) return fallback;
var positions = new List<int>( list.Count );
foreach ( var neighbour in list )
{
if ( rank.TryGetValue( neighbour, out var value ) ) positions.Add( value );
}
if ( positions.Count == 0 ) return fallback;
positions.Sort();
var middle = positions.Count / 2;
return positions.Count % 2 == 1
? positions[middle]
: ( positions[middle - 1] + positions[middle] ) * 0.5f;
}
// ---------------------------------------------------------------- placement ----
static Dictionary<NodeId, Vector2> Place( List<List<int>> columns, IReadOnlyList<PrismNode> nodes,
IReadOnlyDictionary<int, List<int>> successors, IReadOnlyDictionary<int, List<int>> predecessors,
Func<PrismNode, Vector2> measure, AutoLayoutOptions options, Vector2 origin )
{
var sizes = new Vector2[nodes.Count];
var tops = new float[nodes.Count];
// Column x positions, packed left to right by the widest card in each column.
var x = origin.x;
var columnX = new float[columns.Count];
for ( int c = 0; c < columns.Count; c++ )
{
columnX[c] = x;
var widest = PrismTheme.NodeMinWidth;
foreach ( var slot in columns[c] )
{
var size = SizeOf( slot );
widest = MathF.Max( widest, size.x );
}
x += widest + options.LayerGap;
}
// First pass: stack each column from the top.
foreach ( var column in columns )
{
var y = origin.y;
foreach ( var slot in column )
{
tops[slot] = y;
y += SizeOf( slot ).y + options.NodeGap;
}
}
// Straightening: pull every node towards the average centre of what it is wired to, then push
// the column apart again so nothing overlaps. Alternating direction keeps long chains straight
// instead of dragging the whole graph one way.
for ( int pass = 0; pass < Math.Max( 0, options.StraightenPasses ); pass++ )
{
var forward = ( pass & 1 ) == 0;
for ( int i = 0; i < columns.Count; i++ )
{
var c = forward ? i : columns.Count - 1 - i;
var column = columns[c];
if ( column.Count == 0 ) continue;
foreach ( var slot in column )
{
var wanted = Barycentre( slot, forward );
if ( float.IsNaN( wanted ) ) continue;
tops[slot] = wanted - SizeOf( slot ).y * 0.5f;
}
Separate( column );
}
}
var moves = new Dictionary<NodeId, Vector2>();
for ( int c = 0; c < columns.Count; c++ )
{
foreach ( var slot in columns[c] )
{
var position = new Vector2( columnX[c], tops[slot] );
moves[nodes[slot].Id] = Snap( position, options.Grid );
}
}
return moves;
Vector2 SizeOf( int slot )
{
if ( sizes[slot] != default ) return sizes[slot];
var node = nodes[slot];
var size = node is null ? s_defaultSize : measure( node );
if ( size.x <= 1f || size.y <= 1f ) size = s_defaultSize;
sizes[slot] = size;
return size;
}
float Barycentre( int slot, bool fromPredecessors )
{
var neighbours = fromPredecessors ? predecessors[slot] : successors[slot];
if ( neighbours.Count == 0 ) return float.NaN;
var total = 0f;
foreach ( var neighbour in neighbours )
{
total += tops[neighbour] + SizeOf( neighbour ).y * 0.5f;
}
return total / neighbours.Count;
}
void Separate( List<int> column )
{
// Order is fixed by the crossing-reduction pass, so separation only has to push downwards
// from the first node and then lift the whole column back to where its centre wanted to be.
var before = 0f;
var after = 0f;
foreach ( var slot in column ) before += tops[slot];
for ( int i = 1; i < column.Count; i++ )
{
var previous = column[i - 1];
var current = column[i];
var floor = tops[previous] + SizeOf( previous ).y + options.NodeGap;
if ( tops[current] < floor ) tops[current] = floor;
}
foreach ( var slot in column ) after += tops[slot];
var drift = ( after - before ) / column.Count;
if ( MathF.Abs( drift ) < 0.01f ) return;
foreach ( var slot in column ) tops[slot] -= drift;
}
}
// ---------------------------------------------------------------- helpers ----
static List<PrismNode> Materialise( IEnumerable<PrismNode> nodes ) =>
nodes is null
? new List<PrismNode>()
: nodes.Where( x => x is not null && x.Id.IsValid ).Distinct().ToList();
static Vector2 Origin( IReadOnlyList<PrismNode> nodes )
{
var min = new Vector2( float.MaxValue, float.MaxValue );
foreach ( var node in nodes )
{
min.x = MathF.Min( min.x, node.Position.x );
min.y = MathF.Min( min.y, node.Position.y );
}
return min.x == float.MaxValue ? Vector2.Zero : min;
}
static Vector2 Snap( Vector2 position, float grid )
{
if ( grid <= 0f ) return position;
return new Vector2(
MathF.Round( position.x / grid ) * grid,
MathF.Round( position.y / grid ) * grid );
}
/// <summary>
/// A card size when the canvas is not around to ask. Derived from the port count, which is what
/// actually drives card height, so a headless layout is still roughly right.
/// </summary>
public static Vector2 Measure( PrismNode node )
{
if ( node is null ) return s_defaultSize;
var rows = Math.Max( node.Inputs?.Count ?? 0, node.Outputs?.Count ?? 0 );
var height = PrismTheme.NodeHeaderHeight + Math.Max( 1, rows ) * PrismTheme.PortPitch + PrismTheme.Rhythm;
if ( ( node.Flags & NodeFlags.Preview ) != 0 ) height += PrismTheme.ThumbnailSize;
if ( ( node.Flags & NodeFlags.Collapsed ) != 0 ) height = PrismTheme.NodeHeaderHeight;
var title = node.Descriptor?.Title ?? string.Empty;
var width = Math.Clamp( PrismTheme.NodeMinWidth + title.Length * 2f,
PrismTheme.NodeMinWidth, PrismTheme.NodeMaxWidth );
return new Vector2( width, height );
}
static int Apply( GraphMutations mutations, IReadOnlyDictionary<NodeId, Vector2> moves, string label )
{
if ( mutations is null || moves is null || moves.Count == 0 ) return 0;
using ( mutations.Begin( label ) )
{
return mutations.Move( moves, label );
}
}
}