diff --git a/FModel/ViewModels/AnimGraphViewModel.cs b/FModel/ViewModels/AnimGraphViewModel.cs
index 7228a49e..99562970 100644
--- a/FModel/ViewModels/AnimGraphViewModel.cs
+++ b/FModel/ViewModels/AnimGraphViewModel.cs
@@ -38,6 +38,10 @@ public class AnimGraphConnection
public string SourcePinName { get; set; } = string.Empty;
public AnimGraphNode TargetNode { get; set; } = null!;
public string TargetPinName { get; set; } = string.Empty;
+ ///
+ /// Additional properties for state machine transitions (e.g. CrossfadeDuration, LogicType).
+ ///
+ public Dictionary TransitionProperties { get; set; } = new();
}
///
@@ -61,7 +65,7 @@ internal class StateMachineMetadata
public string MachineName { get; init; } = string.Empty;
public List StateNames { get; } = [];
public List StateRootPropNames { get; } = [];
- public List<(int PreviousState, int NextState)> Transitions { get; } = [];
+ public List<(int PreviousState, int NextState, Dictionary Properties)> Transitions { get; } = [];
}
public class AnimGraphViewModel
@@ -471,17 +475,20 @@ public class AnimGraphViewModel
}
// Transition connections between states
- foreach (var (prevIdx, nextIdx) in sm.Transitions)
+ foreach (var (prevIdx, nextIdx, transProps) in sm.Transitions)
{
if (prevIdx < stateNodes.Count && nextIdx < stateNodes.Count)
{
- overviewLayer.Connections.Add(new AnimGraphConnection
+ var conn = new AnimGraphConnection
{
SourceNode = stateNodes[prevIdx],
SourcePinName = "Out",
TargetNode = stateNodes[nextIdx],
TargetPinName = "In"
- });
+ };
+ foreach (var (k, v) in transProps)
+ conn.TransitionProperties[k] = v;
+ overviewLayer.Connections.Add(conn);
}
}
@@ -690,7 +697,17 @@ public class AnimGraphViewModel
if (previousState >= 0 && nextState >= 0 &&
previousState < metadata.StateNames.Count && nextState < metadata.StateNames.Count)
{
- metadata.Transitions.Add((previousState, nextState));
+ var transProps = new Dictionary();
+ foreach (var tp in transStruct.Properties)
+ {
+ var name = tp.Name.Text;
+ if (name is "PreviousState" or "NextState") continue;
+ var val = tp.Tag?.GenericValue?.ToString();
+ if (!string.IsNullOrEmpty(val))
+ transProps[name] = val.Length > MaxPropertyValueDisplayLength
+ ? val[..MaxPropertyValueDisplayLength] + "…" : val;
+ }
+ metadata.Transitions.Add((previousState, nextState, transProps));
}
}
break;
diff --git a/FModel/Views/AnimGraphViewer.xaml.cs b/FModel/Views/AnimGraphViewer.xaml.cs
index d51faf1c..0d125f50 100644
--- a/FModel/Views/AnimGraphViewer.xaml.cs
+++ b/FModel/Views/AnimGraphViewer.xaml.cs
@@ -26,6 +26,7 @@ public partial class AnimGraphViewer
private const double StateNodeCornerRadius = 24;
private const double EntryNodeSize = 30;
private const double TransitionArrowSize = 10;
+ private const double DistanceEpsilon = 0.001;
private readonly AnimGraphViewModel _viewModel;
@@ -37,6 +38,11 @@ public partial class AnimGraphViewer
private AnimGraphNode? _selectedNode;
private Border? _selectedBorder;
+ // Currently selected transition (for properties panel)
+ private AnimGraphConnection? _selectedTransition;
+ private Path? _selectedTransitionPath;
+ private Color _selectedTransitionOriginalColor;
+
private bool _isPanning;
private bool _potentialPan;
private Point _panStartPos;
@@ -530,13 +536,24 @@ public partial class AnimGraphViewer
///
private void SelectNode(AnimGraphNode node, Border border)
{
- // Deselect previous
+ // Deselect previous node
if (_selectedBorder != null)
{
_selectedBorder.BorderBrush = new SolidColorBrush(Color.FromRgb(20, 20, 20));
_selectedBorder.BorderThickness = new Thickness(1.5);
}
+ // Deselect previous transition
+ if (_selectedTransitionPath != null)
+ {
+ var restoreBrush = new SolidColorBrush(_selectedTransitionOriginalColor);
+ _selectedTransitionPath.Stroke = restoreBrush;
+ _selectedTransitionPath.Fill = restoreBrush;
+ _selectedTransitionPath.StrokeThickness = 2.5;
+ _selectedTransitionPath = null;
+ }
+ _selectedTransition = null;
+
// Highlight selected
_selectedNode = node;
_selectedBorder = border;
@@ -719,34 +736,37 @@ public partial class AnimGraphViewer
var sourceKey = (conn.SourceNode, conn.SourcePinName, true);
var targetKey = (conn.TargetNode, conn.TargetPinName, false);
- if (!state.PinPositions.TryGetValue(sourceKey, out var startPos))
- {
- if (state.NodePositions.TryGetValue(conn.SourceNode, out var srcNodePos))
- startPos = new Point(srcNodePos.X + NodeWidth, srcNodePos.Y + NodeHeaderHeight + 10);
- else
- return;
- }
-
- if (!state.PinPositions.TryGetValue(targetKey, out var endPos))
- {
- if (state.NodePositions.TryGetValue(conn.TargetNode, out var tgtNodePos))
- endPos = new Point(tgtNodePos.X, tgtNodePos.Y + NodeHeaderHeight + 10);
- else
- return;
- }
+ var isTransition = (conn.SourceNode.IsStateMachineState || conn.SourceNode.IsEntryNode) &&
+ (conn.TargetNode.IsStateMachineState || conn.TargetNode.IsEntryNode);
// 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);
if (isTransition)
{
- DrawTransitionArrow(state, startPos, endPos, wireColor);
+ // Compute edge-to-edge shortest path between node bounding boxes
+ var (startPos, endPos) = ComputeEdgeToEdgePoints(state, conn.SourceNode, conn.TargetNode);
+ DrawTransitionArrow(state, conn, startPos, endPos, wireColor);
}
else
{
+ if (!state.PinPositions.TryGetValue(sourceKey, out var startPos))
+ {
+ if (state.NodePositions.TryGetValue(conn.SourceNode, out var srcNodePos))
+ startPos = new Point(srcNodePos.X + NodeWidth, srcNodePos.Y + NodeHeaderHeight + 10);
+ else
+ return;
+ }
+
+ if (!state.PinPositions.TryGetValue(targetKey, out var endPos))
+ {
+ if (state.NodePositions.TryGetValue(conn.TargetNode, out var tgtNodePos))
+ endPos = new Point(tgtNodePos.X, tgtNodePos.Y + NodeHeaderHeight + 10);
+ else
+ return;
+ }
+
var dx = Math.Max(Math.Abs(endPos.X - startPos.X) * 0.5, 50);
var pathFigure = new PathFigure { StartPoint = startPos };
pathFigure.Segments.Add(new BezierSegment(
@@ -772,29 +792,114 @@ public partial class AnimGraphViewer
}
///
- /// Draws a directional transition arrow between state machine state nodes,
- /// with an arrowhead at the target end.
+ /// Computes the shortest straight-line connection points between two node edges.
+ /// For state nodes (rounded rectangles) and entry nodes (circles), finds the
+ /// intersection of the center-to-center line with each node's bounding shape.
///
- private static void DrawTransitionArrow(LayerCanvasState state, Point startPos, Point endPos, Color wireColor)
+ private (Point start, Point end) ComputeEdgeToEdgePoints(LayerCanvasState state, AnimGraphNode sourceNode, AnimGraphNode targetNode)
+ {
+ var srcCenter = GetNodeCenter(state, sourceNode);
+ var tgtCenter = GetNodeCenter(state, targetNode);
+
+ var startEdge = ClipToNodeEdge(state, sourceNode, srcCenter, tgtCenter);
+ var endEdge = ClipToNodeEdge(state, targetNode, tgtCenter, srcCenter);
+
+ return (startEdge, endEdge);
+ }
+
+ private static Point GetNodeCenter(LayerCanvasState state, AnimGraphNode node)
+ {
+ if (!state.NodePositions.TryGetValue(node, out var pos))
+ return default;
+
+ if (node.IsEntryNode)
+ return new Point(pos.X + EntryNodeSize / 2, pos.Y + EntryNodeSize / 2);
+
+ if (node.IsStateMachineState)
+ return new Point(pos.X + StateNodeWidth / 2, pos.Y + StateNodeHeight / 2);
+
+ return new Point(pos.X + NodeWidth / 2, pos.Y + NodeHeaderHeight / 2);
+ }
+
+ ///
+ /// Clips a line from toward
+ /// to the edge of the node's bounding shape.
+ ///
+ private static Point ClipToNodeEdge(LayerCanvasState state, AnimGraphNode node, Point from, Point to)
+ {
+ if (!state.NodePositions.TryGetValue(node, out var pos))
+ return from;
+
+ if (node.IsEntryNode)
+ {
+ // Circle clipping
+ var cx = pos.X + EntryNodeSize / 2;
+ var cy = pos.Y + EntryNodeSize / 2;
+ var radius = EntryNodeSize / 2;
+ var dx = to.X - cx;
+ var dy = to.Y - cy;
+ var dist = Math.Sqrt(dx * dx + dy * dy);
+ if (dist < DistanceEpsilon) return from;
+ return new Point(cx + dx / dist * radius, cy + dy / dist * radius);
+ }
+
+ // Rectangle clipping (state nodes or regular nodes)
+ double w, h;
+ if (node.IsStateMachineState)
+ {
+ w = StateNodeWidth;
+ h = StateNodeHeight;
+ }
+ else
+ {
+ w = NodeWidth;
+ h = state.NodeVisuals.TryGetValue(node, out var vis) ? vis.height : 60;
+ }
+
+ var rectCx = pos.X + w / 2;
+ var rectCy = pos.Y + h / 2;
+ var dirX = to.X - rectCx;
+ var dirY = to.Y - rectCy;
+ if (Math.Abs(dirX) < DistanceEpsilon && Math.Abs(dirY) < DistanceEpsilon)
+ return from;
+
+ // Find intersection with rectangle edges
+ var halfW = w / 2;
+ var halfH = h / 2;
+ double tMin = double.MaxValue;
+
+ // Check left/right edges
+ if (Math.Abs(dirX) > DistanceEpsilon)
+ {
+ var t = (dirX > 0 ? halfW : -halfW) / dirX;
+ var iy = dirY * t;
+ if (t > 0 && Math.Abs(iy) <= halfH)
+ tMin = Math.Min(tMin, t);
+ }
+ // Check top/bottom edges
+ if (Math.Abs(dirY) > DistanceEpsilon)
+ {
+ var t = (dirY > 0 ? halfH : -halfH) / dirY;
+ var ix = dirX * t;
+ if (t > 0 && Math.Abs(ix) <= halfW)
+ tMin = Math.Min(tMin, t);
+ }
+
+ if (tMin < double.MaxValue)
+ return new Point(rectCx + dirX * tMin, rectCy + dirY * tMin);
+
+ return from;
+ }
+
+ ///
+ /// Draws a directional transition arrow between state machine state nodes,
+ /// with an arrowhead at the target end. The arrow is clickable to select
+ /// the transition and view its properties.
+ ///
+ private void DrawTransitionArrow(LayerCanvasState state, AnimGraphConnection conn, 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,
- SnapsToDevicePixels = true,
- IsHitTestVisible = false
- };
- 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);
@@ -802,23 +907,123 @@ public partial class AnimGraphViewer
var ux = dx / length;
var uy = dy / length;
- var arrowBase = new Point(endPos.X - ux * TransitionArrowSize, endPos.Y - uy * TransitionArrowSize);
+
+ // Pull the endpoint back by the arrowhead size so the line ends at the arrow base
+ var lineEnd = new Point(endPos.X - ux * TransitionArrowSize, endPos.Y - uy * TransitionArrowSize);
+
+ // Build a single path containing the line + arrowhead for hit testing
+ var pathFigure = new PathFigure { StartPoint = startPos };
+ pathFigure.Segments.Add(new LineSegment(lineEnd, true));
+
+ var arrowBase = lineEnd;
var perpX = -uy * TransitionArrowSize * 0.5;
var perpY = ux * TransitionArrowSize * 0.5;
+ var arrowFigure = new PathFigure { StartPoint = endPos, IsFilled = true };
+ arrowFigure.Segments.Add(new LineSegment(new Point(arrowBase.X + perpX, arrowBase.Y + perpY), true));
+ arrowFigure.Segments.Add(new LineSegment(new Point(arrowBase.X - perpX, arrowBase.Y - perpY), true));
+ arrowFigure.IsClosed = true;
- var arrowHead = new Polygon
+ var pathGeometry = new PathGeometry();
+ pathGeometry.Figures.Add(pathFigure);
+ pathGeometry.Figures.Add(arrowFigure);
+ pathGeometry.Freeze();
+
+ var path = new Path
{
- Points =
- [
- endPos,
- new Point(arrowBase.X + perpX, arrowBase.Y + perpY),
- new Point(arrowBase.X - perpX, arrowBase.Y - perpY)
- ],
+ Data = pathGeometry,
+ Stroke = brush,
+ StrokeThickness = 2.5,
Fill = brush,
- IsHitTestVisible = false
+ SnapsToDevicePixels = true,
+ IsHitTestVisible = true,
+ Cursor = System.Windows.Input.Cursors.Hand
};
- Panel.SetZIndex(arrowHead, 0);
- state.Canvas.Children.Add(arrowHead);
+ Panel.SetZIndex(path, 0);
+ state.Canvas.Children.Add(path);
+
+ // Invisible wider hit area for easier clicking
+ var hitPath = new Path
+ {
+ Data = pathGeometry,
+ Stroke = Brushes.Transparent,
+ StrokeThickness = 10,
+ Fill = Brushes.Transparent,
+ IsHitTestVisible = true,
+ Cursor = System.Windows.Input.Cursors.Hand
+ };
+ Panel.SetZIndex(hitPath, 0);
+ state.Canvas.Children.Add(hitPath);
+
+ hitPath.MouseLeftButtonDown += (s, e) =>
+ {
+ SelectTransition(conn, path, wireColor);
+ e.Handled = true;
+ };
+ path.MouseLeftButtonDown += (s, e) =>
+ {
+ SelectTransition(conn, path, wireColor);
+ e.Handled = true;
+ };
+ }
+
+ ///
+ /// Selects a transition arrow and shows its properties in the properties panel.
+ ///
+ private void SelectTransition(AnimGraphConnection conn, Path transitionPath, Color originalColor)
+ {
+ // Deselect previous node selection
+ if (_selectedBorder != null)
+ {
+ _selectedBorder.BorderBrush = new SolidColorBrush(Color.FromRgb(20, 20, 20));
+ _selectedBorder.BorderThickness = new Thickness(1.5);
+ _selectedBorder = null;
+ }
+ _selectedNode = null;
+
+ // Deselect previous transition
+ if (_selectedTransitionPath != null)
+ {
+ var restoreBrush = new SolidColorBrush(_selectedTransitionOriginalColor);
+ _selectedTransitionPath.Stroke = restoreBrush;
+ _selectedTransitionPath.Fill = restoreBrush;
+ _selectedTransitionPath.StrokeThickness = 2.5;
+ }
+
+ // Highlight selected transition
+ _selectedTransition = conn;
+ _selectedTransitionPath = transitionPath;
+ _selectedTransitionOriginalColor = originalColor;
+ var highlightBrush = new SolidColorBrush(Color.FromRgb(230, 160, 0));
+ transitionPath.Stroke = highlightBrush;
+ transitionPath.Fill = highlightBrush;
+ transitionPath.StrokeThickness = 3.5;
+
+ var sourceName = conn.SourceNode.Name;
+ var targetName = conn.TargetNode.Name;
+ SelectedNodeText.Text = $"Selected: Transition {sourceName} → {targetName}";
+ PopulateTransitionProperties(conn);
+ }
+
+ ///
+ /// Fills the properties panel with the selected transition's information.
+ ///
+ private void PopulateTransitionProperties(AnimGraphConnection conn)
+ {
+ PropertiesPanel.Children.Clear();
+ PropertiesTitleText.Text = $"Properties - Transition";
+
+ AddPropertySection("Transition Info");
+ AddPropertyRow("From", conn.SourceNode.Name);
+ AddPropertyRow("To", conn.TargetNode.Name);
+
+ if (conn.TransitionProperties.Count > 0)
+ {
+ AddPropertySection("Details");
+ foreach (var (key, value) in conn.TransitionProperties)
+ {
+ AddPropertyRow(key, value);
+ }
+ }
}
private static string GetNodeDisplayName(AnimGraphNode node)