Add state machine overview layers with Entry, State nodes, and Transition connections

Parse States and Transitions from BakedStateMachines to build state-level
overview layers for each state machine. Each overview shows:
- Entry node (filled circle) connecting to the initial state
- State nodes (rounded rectangles) with their names
- Directional transition arrows between states

The overview layer replaces the old internal per-state layer and is opened
via double-click on StateMachine nodes in the parent graph.

Co-authored-by: LoogLong <86428208+LoogLong@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-03 10:17:04 +00:00
parent 58a0a50ac5
commit 22ba7668eb
2 changed files with 440 additions and 30 deletions

View File

@ -15,6 +15,8 @@ public class AnimGraphNode
public string NodeComment { get; set; } = string.Empty;
public int NodePosX { get; set; }
public int NodePosY { get; set; }
public bool IsStateMachineState { get; set; }
public bool IsEntryNode { get; set; }
public List<AnimGraphPin> Pins { get; set; } = [];
public Dictionary<string, string> AdditionalProperties { get; set; } = new();
@ -50,11 +52,24 @@ public class AnimGraphLayer
public List<AnimGraphConnection> Connections { get; } = [];
}
/// <summary>
/// Holds metadata extracted from BakedStateMachines for building
/// state machine overview layers (Entry + State nodes + Transition connections).
/// </summary>
internal class StateMachineMetadata
{
public string MachineName { get; init; } = string.Empty;
public List<string> StateNames { get; } = [];
public List<(int PreviousState, int NextState)> Transitions { get; } = [];
}
public class AnimGraphViewModel
{
private const int GridColumns = 4;
private const int NodeHorizontalSpacing = 300;
private const int NodeVerticalSpacing = 200;
private const int StateNodeHorizontalSpacing = 250;
private const int StateNodeVerticalSpacing = 150;
private const int MaxPropertyValueDisplayLength = 100;
internal const string SubGraphPathSeparator = " > ";
@ -133,7 +148,9 @@ public class AnimGraphViewModel
}
// Associate state machine nodes with their baked machine names
AssociateStateMachineNames(animBlueprintClass, cdo, animNodeProps, nodeByName);
// and collect state machine metadata for overview layers
var smMetadata = new List<StateMachineMetadata>();
AssociateStateMachineNames(animBlueprintClass, cdo, animNodeProps, nodeByName, smMetadata);
// Group nodes into layers (connected subgraphs)
BuildLayers(vm);
@ -141,6 +158,9 @@ public class AnimGraphViewModel
// Prefix state machine internal layers with their parent path to avoid name collisions
PrefixStateMachineLayerNames(vm);
// Build state machine overview layers (Entry + State nodes + Transitions)
BuildStateMachineOverviewLayers(vm, smMetadata);
return vm;
}
@ -257,6 +277,142 @@ public class AnimGraphViewModel
}
}
/// <summary>
/// Creates state machine overview layers with synthetic Entry + State nodes
/// and transition connections between states, providing a UE-like state machine
/// editor view. The overview layer is named with the path prefix to match
/// double-click navigation from StateMachine nodes.
/// </summary>
private static void BuildStateMachineOverviewLayers(AnimGraphViewModel vm, List<StateMachineMetadata> smMetadata)
{
// Map: machineName → parent layer name (where the StateMachine node lives)
var smParentLayer = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var layer in vm.Layers)
{
foreach (var node in layer.Nodes)
{
if (node.AdditionalProperties.TryGetValue("StateMachineName", out var machineName))
smParentLayer.TryAdd(machineName, layer.Name);
}
}
foreach (var sm in smMetadata)
{
if (sm.StateNames.Count == 0) continue;
// Determine the path-prefixed layer name
var parentName = smParentLayer.GetValueOrDefault(sm.MachineName, "AnimGraph");
var overviewLayerName = $"{parentName}{SubGraphPathSeparator}{sm.MachineName}";
// Remove existing internal layers with this name (they'll be replaced by the overview)
vm.Layers.RemoveAll(l => l.Name.Equals(overviewLayerName, StringComparison.OrdinalIgnoreCase));
var overviewLayer = new AnimGraphLayer { Name = overviewLayerName };
var stateNodes = new List<AnimGraphNode>();
// Create Entry node
var entryNode = new AnimGraphNode
{
Name = "Entry",
ExportType = "Entry",
IsEntryNode = true
};
entryNode.Pins.Add(new AnimGraphPin
{
PinName = "Output",
IsOutput = true,
PinType = "transition",
OwnerNode = entryNode
});
overviewLayer.Nodes.Add(entryNode);
// Create State nodes
for (var i = 0; i < sm.StateNames.Count; i++)
{
var stateNode = new AnimGraphNode
{
Name = sm.StateNames[i],
ExportType = "State",
IsStateMachineState = true
};
stateNode.Pins.Add(new AnimGraphPin
{
PinName = "In",
IsOutput = false,
PinType = "transition",
OwnerNode = stateNode
});
stateNode.Pins.Add(new AnimGraphPin
{
PinName = "Out",
IsOutput = true,
PinType = "transition",
OwnerNode = stateNode
});
stateNodes.Add(stateNode);
overviewLayer.Nodes.Add(stateNode);
}
// Entry connects to first state (state index 0)
if (stateNodes.Count > 0)
{
overviewLayer.Connections.Add(new AnimGraphConnection
{
SourceNode = entryNode,
SourcePinName = "Output",
TargetNode = stateNodes[0],
TargetPinName = "In"
});
}
// Transition connections between states
foreach (var (prevIdx, nextIdx) in sm.Transitions)
{
if (prevIdx < stateNodes.Count && nextIdx < stateNodes.Count)
{
overviewLayer.Connections.Add(new AnimGraphConnection
{
SourceNode = stateNodes[prevIdx],
SourcePinName = "Out",
TargetNode = stateNodes[nextIdx],
TargetPinName = "In"
});
}
}
// Layout state nodes in a grid arrangement
LayoutStateMachineOverview(overviewLayer, entryNode, stateNodes);
vm.Layers.Add(overviewLayer);
}
}
/// <summary>
/// Arranges state machine overview nodes: Entry on the left, state nodes in a grid.
/// </summary>
private static void LayoutStateMachineOverview(AnimGraphLayer layer, AnimGraphNode entryNode, List<AnimGraphNode> stateNodes)
{
// Place Entry on the far left
entryNode.NodePosX = 0;
entryNode.NodePosY = 0;
if (stateNodes.Count == 0) return;
// Arrange state nodes in a grid to the right of Entry
var cols = Math.Max(1, (int)Math.Ceiling(Math.Sqrt(stateNodes.Count)));
for (var i = 0; i < stateNodes.Count; i++)
{
var col = i % cols;
var row = i / cols;
stateNodes[i].NodePosX = StateNodeHorizontalSpacing + col * StateNodeHorizontalSpacing;
stateNodes[i].NodePosY = row * StateNodeVerticalSpacing;
}
// Center Entry vertically relative to state nodes
var maxRow = (stateNodes.Count - 1) / cols;
entryNode.NodePosY = maxRow * StateNodeVerticalSpacing / 2;
}
/// <summary>
/// Determines a display name for a layer based on the types of nodes it contains.
/// A Root node's "Name" property defines the layer/sub-graph name
@ -307,12 +463,13 @@ public class AnimGraphViewModel
/// <summary>
/// Reads BakedStateMachines from the animation blueprint class to associate
/// FAnimNode_StateMachine nodes with their machine names and mark internal
/// state root nodes so they can be grouped into correctly named layers.
/// FAnimNode_StateMachine nodes with their machine names, mark internal
/// state root nodes, and collect state/transition metadata for overview layers.
/// </summary>
private static void AssociateStateMachineNames(UClass animBlueprintClass, UObject? cdo,
List<(string name, string structType)> animNodeProps,
Dictionary<string, AnimGraphNode> nodeByName)
Dictionary<string, AnimGraphNode> nodeByName,
List<StateMachineMetadata> smMetadata)
{
// BakedStateMachines is a UPROPERTY on UAnimBlueprintGeneratedClass
// Try reading from both the class and CDO
@ -357,17 +514,30 @@ public class AnimGraphViewModel
smNode.AdditionalProperties["StateMachineName"] = machineName;
}
// Mark state root nodes with BelongsToStateMachine so their layers get the machine name
var metadata = new StateMachineMetadata { MachineName = machineName };
// Extract state names and mark root nodes with BelongsToStateMachine
foreach (var prop in machineStruct.Properties)
{
if (prop.Name.Text != "States") continue;
if (prop.Tag?.GenericValue is not UScriptArray states) break;
foreach (var stateProp in states.Properties)
for (var stateIdx = 0; stateIdx < states.Properties.Count; stateIdx++)
{
if (stateProp.GetValue(typeof(FStructFallback)) is not FStructFallback stateStruct)
if (states.Properties[stateIdx].GetValue(typeof(FStructFallback)) is not FStructFallback stateStruct)
{
metadata.StateNames.Add($"State_{stateIdx}");
continue;
}
// Extract state name
var stateName = $"State_{stateIdx}";
if (stateStruct.TryGetValue(out FName stateNameProp, "StateName"))
stateName = stateNameProp.Text;
metadata.StateNames.Add(stateName);
// Mark root node
if (!stateStruct.TryGetValue(out int stateRootIndex, "StateRootNodeIndex"))
continue;
@ -380,6 +550,33 @@ public class AnimGraphViewModel
}
break;
}
// Extract machine-level transitions (PreviousState → NextState)
foreach (var prop in machineStruct.Properties)
{
if (prop.Name.Text != "Transitions") continue;
if (prop.Tag?.GenericValue is not UScriptArray transitions) break;
foreach (var transProp in transitions.Properties)
{
if (transProp.GetValue(typeof(FStructFallback)) is not FStructFallback transStruct)
continue;
if (!transStruct.TryGetValue(out int previousState, "PreviousState"))
continue;
if (!transStruct.TryGetValue(out int nextState, "NextState"))
continue;
if (previousState >= 0 && nextState >= 0 &&
previousState < metadata.StateNames.Count && nextState < metadata.StateNames.Count)
{
metadata.Transitions.Add((previousState, nextState));
}
}
break;
}
smMetadata.Add(metadata);
}
}

View File

@ -21,6 +21,11 @@ public partial class AnimGraphViewer
private const double PinLabelOffset = 14; // PinCircleRadius * 2 + padding
private const double HeaderGradientDarkenFactor = 0.6;
private const double DefaultGraphWidthRatio = 0.65;
private const double StateNodeWidth = 180;
private const double StateNodeHeight = 50;
private const double StateNodeCornerRadius = 24;
private const double EntryNodeSize = 30;
private const double TransitionArrowSize = 10;
private readonly AnimGraphViewModel _viewModel;
@ -160,17 +165,22 @@ public partial class AnimGraphViewer
state.NodePositions[node] = new Point(node.NodePosX, node.NodePosY);
}
// Draw nodes
foreach (var node in state.Layer.Nodes)
{
DrawNode(state, node);
}
// Draw connections
// Draw connections first (behind nodes) for state machine overview
foreach (var conn in state.Layer.Connections)
{
DrawConnectionLine(state, conn);
}
// Draw nodes
foreach (var node in state.Layer.Nodes)
{
if (node.IsEntryNode)
DrawEntryNode(state, node);
else if (node.IsStateMachineState)
DrawStateNode(state, node);
else
DrawNode(state, node);
}
}
private void DrawNode(LayerCanvasState state, AnimGraphNode node)
@ -321,6 +331,142 @@ public partial class AnimGraphViewer
};
}
/// <summary>
/// Draws an Entry node as a small filled circle, matching UE's state machine editor.
/// </summary>
private void DrawEntryNode(LayerCanvasState state, AnimGraphNode node)
{
var pos = state.NodePositions[node];
var circle = new Ellipse
{
Width = EntryNodeSize,
Height = EntryNodeSize,
Fill = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
Stroke = new SolidColorBrush(Color.FromRgb(200, 200, 200)),
StrokeThickness = 2,
SnapsToDevicePixels = true
};
Canvas.SetLeft(circle, pos.X);
Canvas.SetTop(circle, pos.Y);
Panel.SetZIndex(circle, 1);
state.Canvas.Children.Add(circle);
var label = new TextBlock
{
Text = "Entry",
Foreground = Brushes.White,
FontSize = 10,
FontWeight = FontWeights.SemiBold,
TextAlignment = TextAlignment.Center
};
label.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
Canvas.SetLeft(label, pos.X + EntryNodeSize / 2 - label.DesiredSize.Width / 2);
Canvas.SetTop(label, pos.Y + EntryNodeSize + 4);
Panel.SetZIndex(label, 1);
state.Canvas.Children.Add(label);
// Output pin position (right edge of circle)
state.PinPositions[(node, "Output", true)] = new Point(
pos.X + EntryNodeSize, pos.Y + EntryNodeSize / 2);
// Store visuals with a dummy border for selection
var hitArea = new Border
{
Width = EntryNodeSize,
Height = EntryNodeSize,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(EntryNodeSize / 2)
};
Canvas.SetLeft(hitArea, pos.X);
Canvas.SetTop(hitArea, pos.Y);
Panel.SetZIndex(hitArea, 2);
state.Canvas.Children.Add(hitArea);
state.NodeVisuals[node] = (hitArea, EntryNodeSize, EntryNodeSize);
hitArea.MouseLeftButtonDown += (s, e) =>
{
SelectNode(node, hitArea);
e.Handled = true;
};
}
/// <summary>
/// Draws a state machine state node as a rounded rectangle with a centered name,
/// matching UE's state machine editor visual style.
/// </summary>
private void DrawStateNode(LayerCanvasState state, AnimGraphNode node)
{
var pos = state.NodePositions[node];
// Shadow
var shadow = new Border
{
Width = StateNodeWidth,
Height = StateNodeHeight,
CornerRadius = new CornerRadius(StateNodeCornerRadius),
Background = Brushes.Black,
Opacity = 0.4,
Effect = new BlurEffect { Radius = 6 }
};
Canvas.SetLeft(shadow, pos.X + 2);
Canvas.SetTop(shadow, pos.Y + 2);
Panel.SetZIndex(shadow, 0);
state.Canvas.Children.Add(shadow);
// State body
var border = new Border
{
Width = StateNodeWidth,
Height = StateNodeHeight,
CornerRadius = new CornerRadius(StateNodeCornerRadius),
Background = new SolidColorBrush(Color.FromArgb(240, 55, 55, 55)),
BorderBrush = new SolidColorBrush(Color.FromRgb(120, 120, 120)),
BorderThickness = new Thickness(2),
SnapsToDevicePixels = true
};
var nameText = new TextBlock
{
Text = node.Name,
Foreground = Brushes.White,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextAlignment = TextAlignment.Center
};
border.Child = nameText;
Canvas.SetLeft(border, pos.X);
Canvas.SetTop(border, pos.Y);
Panel.SetZIndex(border, 1);
state.Canvas.Children.Add(border);
state.NodeVisuals[node] = (border, StateNodeWidth, StateNodeHeight);
// Pin positions (left = input, right = output)
state.PinPositions[(node, "In", false)] = new Point(
pos.X, pos.Y + StateNodeHeight / 2);
state.PinPositions[(node, "Out", true)] = new Point(
pos.X + StateNodeWidth, pos.Y + StateNodeHeight / 2);
border.ToolTip = $"State: {node.Name}";
border.MouseLeftButtonDown += (s, e) =>
{
if (e.ClickCount == 2)
{
TryOpenSubGraph(node);
e.Handled = true;
return;
}
SelectNode(node, border);
e.Handled = true;
};
}
private void AddPinVisual(Canvas pinsCanvas, AnimGraphPin pin, double y, bool isInput, Color pinColor)
{
var displayName = string.IsNullOrEmpty(pin.PinName) ? "(unnamed)" : pin.PinName;
@ -415,6 +561,13 @@ public partial class AnimGraphViewer
{
node.AdditionalProperties.TryGetValue("Layer", out layerName);
}
else if (node.IsStateMachineState)
{
// State nodes within an overview: try to open internal per-state layer
// Internal layers share the state machine's path prefix name
// (future: individual state layers could be opened here)
return;
}
else if (node.ExportType.Contains("StateMachine", StringComparison.OrdinalIgnoreCase))
{
// State machine internal layers are prefixed with parent path
@ -571,28 +724,87 @@ public partial class AnimGraphViewer
// Determine wire color from source pin type
var sourcePin = conn.SourceNode.Pins.FirstOrDefault(p => p.PinName == conn.SourcePinName && p.IsOutput);
var wireColor = sourcePin != null ? GetPinColor(sourcePin.PinType) : Color.FromRgb(200, 200, 220);
var isTransition = (conn.SourceNode.IsStateMachineState || conn.SourceNode.IsEntryNode) &&
(conn.TargetNode.IsStateMachineState || conn.TargetNode.IsEntryNode);
var dx = Math.Max(Math.Abs(endPos.X - startPos.X) * 0.5, 50);
var pathFigure = new PathFigure { StartPoint = startPos };
pathFigure.Segments.Add(new BezierSegment(
new Point(startPos.X + dx, startPos.Y),
new Point(endPos.X - dx, endPos.Y),
endPos, true));
var pathGeometry = new PathGeometry();
pathGeometry.Figures.Add(pathFigure);
var path = new Path
if (isTransition)
{
Data = pathGeometry,
Stroke = new SolidColorBrush(wireColor),
DrawTransitionArrow(state, startPos, endPos, wireColor);
}
else
{
var dx = Math.Max(Math.Abs(endPos.X - startPos.X) * 0.5, 50);
var pathFigure = new PathFigure { StartPoint = startPos };
pathFigure.Segments.Add(new BezierSegment(
new Point(startPos.X + dx, startPos.Y),
new Point(endPos.X - dx, endPos.Y),
endPos, true));
var pathGeometry = new PathGeometry();
pathGeometry.Figures.Add(pathFigure);
var path = new Path
{
Data = pathGeometry,
Stroke = new SolidColorBrush(wireColor),
StrokeThickness = 2.5,
Opacity = 0.85,
SnapsToDevicePixels = true,
IsHitTestVisible = false
};
Panel.SetZIndex(path, 0);
state.Canvas.Children.Add(path);
}
}
/// <summary>
/// Draws a directional transition arrow between state machine state nodes,
/// with an arrowhead at the target end.
/// </summary>
private static void DrawTransitionArrow(LayerCanvasState state, Point startPos, Point endPos, Color wireColor)
{
var brush = new SolidColorBrush(wireColor);
// Main line
var line = new Line
{
X1 = startPos.X,
Y1 = startPos.Y,
X2 = endPos.X,
Y2 = endPos.Y,
Stroke = brush,
StrokeThickness = 2.5,
Opacity = 0.85,
SnapsToDevicePixels = true,
IsHitTestVisible = false
};
Panel.SetZIndex(path, 0);
state.Canvas.Children.Add(path);
Panel.SetZIndex(line, 0);
state.Canvas.Children.Add(line);
// Arrowhead
var dx = endPos.X - startPos.X;
var dy = endPos.Y - startPos.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length < 1) return;
var ux = dx / length;
var uy = dy / length;
var arrowBase = new Point(endPos.X - ux * TransitionArrowSize, endPos.Y - uy * TransitionArrowSize);
var perpX = -uy * TransitionArrowSize * 0.5;
var perpY = ux * TransitionArrowSize * 0.5;
var arrowHead = new Polygon
{
Points =
[
endPos,
new Point(arrowBase.X + perpX, arrowBase.Y + perpY),
new Point(arrowBase.X - perpX, arrowBase.Y - perpY)
],
Fill = brush,
IsHitTestVisible = false
};
Panel.SetZIndex(arrowHead, 0);
state.Canvas.Children.Add(arrowHead);
}
private static string GetNodeDisplayName(AnimGraphNode node)
@ -642,6 +854,7 @@ public partial class AnimGraphViewer
"string" or "text" or "name" => Color.FromRgb(255, 80, 180),
"delegate" => Color.FromRgb(255, 56, 56),
"pose" => Color.FromRgb(0, 160, 100),
"transition" => Color.FromRgb(200, 200, 200),
_ => Color.FromRgb(180, 180, 200)
};
}