From 08a9253f4e0e32b258accfc14190164869980fa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:53:16 +0000 Subject: [PATCH] Add StateMachine double-click to open sub-graph tab Read BakedStateMachines from the animation blueprint class to extract machine names. Associate FAnimNode_StateMachine nodes with their machine name via StateMachineIndexInClass, and mark internal state root nodes with BelongsToStateMachine for correct layer naming. Extend TryOpenSubGraph to handle both LinkedAnimLayer and StateMachine node types for double-click tab navigation. Co-authored-by: LoogLong <86428208+LoogLong@users.noreply.github.com> --- FModel/ViewModels/AnimGraphViewModel.cs | 89 +++++++++++++++++++++++++ FModel/Views/AnimGraphViewer.xaml.cs | 21 +++--- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/FModel/ViewModels/AnimGraphViewModel.cs b/FModel/ViewModels/AnimGraphViewModel.cs index c6be01cf..54dbffe3 100644 --- a/FModel/ViewModels/AnimGraphViewModel.cs +++ b/FModel/ViewModels/AnimGraphViewModel.cs @@ -131,6 +131,9 @@ public class AnimGraphViewModel ResolveConnections(cdo, animNodeProps, nodeByName, vm); } + // Associate state machine nodes with their baked machine names + AssociateStateMachineNames(animBlueprintClass, cdo, animNodeProps, nodeByName); + // Group nodes into layers (connected subgraphs) BuildLayers(vm); @@ -225,6 +228,14 @@ public class AnimGraphViewModel !string.IsNullOrEmpty(rootName)) return rootName; + // Check if any node belongs to a baked state machine + var smNode = nodes.FirstOrDefault(n => + n.AdditionalProperties.TryGetValue("BelongsToStateMachine", out _)); + if (smNode != null && + smNode.AdditionalProperties.TryGetValue("BelongsToStateMachine", out var smName) && + !string.IsNullOrEmpty(smName)) + return smName; + var stateMachine = nodes.FirstOrDefault(n => n.ExportType.Contains("StateMachine", StringComparison.OrdinalIgnoreCase)); if (stateMachine != null) @@ -250,6 +261,84 @@ public class AnimGraphViewModel return exportType; } + /// + /// 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. + /// + private static void AssociateStateMachineNames(UClass animBlueprintClass, UObject? cdo, + List<(string name, string structType)> animNodeProps, + Dictionary nodeByName) + { + // BakedStateMachines is a UPROPERTY on UAnimBlueprintGeneratedClass + // Try reading from both the class and CDO + UScriptArray? bakedMachines = null; + if (animBlueprintClass.TryGetValue(out UScriptArray classBaked, "BakedStateMachines")) + bakedMachines = classBaked; + else if (cdo != null && cdo.TryGetValue(out UScriptArray cdoBaked, "BakedStateMachines")) + bakedMachines = cdoBaked; + + if (bakedMachines == null || bakedMachines.Properties.Count == 0) + return; + + for (var machineIdx = 0; machineIdx < bakedMachines.Properties.Count; machineIdx++) + { + if (bakedMachines.Properties[machineIdx].GetValue(typeof(FStructFallback)) is not FStructFallback machineStruct) + continue; + + // Extract MachineName + var machineName = string.Empty; + foreach (var prop in machineStruct.Properties) + { + if (prop.Name.Text == "MachineName") + { + machineName = prop.Tag?.GenericValue?.ToString() ?? string.Empty; + break; + } + } + if (string.IsNullOrEmpty(machineName)) + continue; + + // Associate FAnimNode_StateMachine nodes that reference this machine index + var machineIdxStr = machineIdx.ToString(); + foreach (var (propName, structType) in animNodeProps) + { + if (!structType.Contains("StateMachine", StringComparison.OrdinalIgnoreCase)) + continue; + if (!nodeByName.TryGetValue(propName, out var smNode)) + continue; + if (!smNode.AdditionalProperties.TryGetValue("StateMachineIndexInClass", out var idxStr)) + continue; + if (idxStr == machineIdxStr) + smNode.AdditionalProperties["StateMachineName"] = machineName; + } + + // Mark state root nodes with BelongsToStateMachine so their layers get the machine name + 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) + { + if (stateProp.GetValue(typeof(FStructFallback)) is not FStructFallback stateStruct) + continue; + + if (!stateStruct.TryGetValue(out int stateRootIndex, "StateRootNodeIndex")) + continue; + + if (stateRootIndex < 0 || stateRootIndex >= animNodeProps.Count) + continue; + + var rootPropName = animNodeProps[stateRootIndex].name; + if (nodeByName.TryGetValue(rootPropName, out var rootNode)) + rootNode.AdditionalProperties["BelongsToStateMachine"] = machineName; + } + break; + } + } + } + /// /// Arranges nodes within a layer in a left-to-right flow layout /// based on connection topology (sinks on the left, sources on the right). diff --git a/FModel/Views/AnimGraphViewer.xaml.cs b/FModel/Views/AnimGraphViewer.xaml.cs index 4cb6c787..f40f5cc4 100644 --- a/FModel/Views/AnimGraphViewer.xaml.cs +++ b/FModel/Views/AnimGraphViewer.xaml.cs @@ -312,7 +312,7 @@ public partial class AnimGraphViewer { if (e.ClickCount == 2) { - TryOpenLinkedLayer(node); + TryOpenSubGraph(node); e.Handled = true; return; } @@ -402,16 +402,21 @@ public partial class AnimGraphViewer } /// - /// When a LinkedAnimLayer node is double-clicked, opens its corresponding - /// layer sub-graph in a new tab. The layer is identified by matching the - /// node's "Layer" property with a layer whose Root node has the same "Name". + /// When a LinkedAnimLayer or StateMachine node is double-clicked, opens its + /// corresponding sub-graph in a new tab. + /// - LinkedAnimLayer: matches "Layer" property → layer name from Root node's "Name" + /// - StateMachine: matches "StateMachineName" property → layer name from BakedStateMachines /// - private void TryOpenLinkedLayer(AnimGraphNode node) + private void TryOpenSubGraph(AnimGraphNode node) { - if (!node.ExportType.Contains("LinkedAnimLayer", StringComparison.OrdinalIgnoreCase)) - return; + string? layerName = null; - if (!node.AdditionalProperties.TryGetValue("Layer", out var layerName) || string.IsNullOrEmpty(layerName)) + if (node.ExportType.Contains("LinkedAnimLayer", StringComparison.OrdinalIgnoreCase)) + node.AdditionalProperties.TryGetValue("Layer", out layerName); + else if (node.ExportType.Contains("StateMachine", StringComparison.OrdinalIgnoreCase)) + node.AdditionalProperties.TryGetValue("StateMachineName", out layerName); + + if (string.IsNullOrEmpty(layerName)) return; var targetLayer = _viewModel.Layers.FirstOrDefault(l =>