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>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-03 08:53:16 +00:00
parent 1d2ef5905e
commit 08a9253f4e
2 changed files with 102 additions and 8 deletions

View File

@ -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;
}
/// <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.
/// </summary>
private static void AssociateStateMachineNames(UClass animBlueprintClass, UObject? cdo,
List<(string name, string structType)> animNodeProps,
Dictionary<string, AnimGraphNode> 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;
}
}
}
/// <summary>
/// Arranges nodes within a layer in a left-to-right flow layout
/// based on connection topology (sinks on the left, sources on the right).

View File

@ -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
}
/// <summary>
/// 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
/// </summary>
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 =>