From 22ba7668eb1a054eecbd9a0ff3a95c79e9f68526 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:17:04 +0000 Subject: [PATCH] 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> --- FModel/ViewModels/AnimGraphViewModel.cs | 211 ++++++++++++++++++- FModel/Views/AnimGraphViewer.xaml.cs | 259 +++++++++++++++++++++--- 2 files changed, 440 insertions(+), 30 deletions(-) diff --git a/FModel/ViewModels/AnimGraphViewModel.cs b/FModel/ViewModels/AnimGraphViewModel.cs index 417db9cd..746212e8 100644 --- a/FModel/ViewModels/AnimGraphViewModel.cs +++ b/FModel/ViewModels/AnimGraphViewModel.cs @@ -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 Pins { get; set; } = []; public Dictionary AdditionalProperties { get; set; } = new(); @@ -50,11 +52,24 @@ public class AnimGraphLayer public List Connections { get; } = []; } +/// +/// Holds metadata extracted from BakedStateMachines for building +/// state machine overview layers (Entry + State nodes + Transition connections). +/// +internal class StateMachineMetadata +{ + public string MachineName { get; init; } = string.Empty; + public List 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(); + 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 } } + /// + /// 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. + /// + private static void BuildStateMachineOverviewLayers(AnimGraphViewModel vm, List smMetadata) + { + // Map: machineName → parent layer name (where the StateMachine node lives) + var smParentLayer = new Dictionary(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(); + + // 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); + } + } + + /// + /// Arranges state machine overview nodes: Entry on the left, state nodes in a grid. + /// + private static void LayoutStateMachineOverview(AnimGraphLayer layer, AnimGraphNode entryNode, List 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; + } + /// /// 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 /// /// 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. /// private static void AssociateStateMachineNames(UClass animBlueprintClass, UObject? cdo, List<(string name, string structType)> animNodeProps, - Dictionary nodeByName) + Dictionary nodeByName, + List 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); } } diff --git a/FModel/Views/AnimGraphViewer.xaml.cs b/FModel/Views/AnimGraphViewer.xaml.cs index 8eb77079..3b9ce7aa 100644 --- a/FModel/Views/AnimGraphViewer.xaml.cs +++ b/FModel/Views/AnimGraphViewer.xaml.cs @@ -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 }; } + /// + /// Draws an Entry node as a small filled circle, matching UE's state machine editor. + /// + 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; + }; + } + + /// + /// Draws a state machine state node as a rounded rectangle with a centered name, + /// matching UE's state machine editor visual style. + /// + 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); + } + } + + /// + /// Draws a directional transition arrow between state machine state nodes, + /// with an arrowhead at the target end. + /// + 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) }; }