diff --git a/src/Artemis.Core/Artemis.Core.csproj.DotSettings b/src/Artemis.Core/Artemis.Core.csproj.DotSettings
index c23015925..57c6a2f5f 100644
--- a/src/Artemis.Core/Artemis.Core.csproj.DotSettings
+++ b/src/Artemis.Core/Artemis.Core.csproj.DotSettings
@@ -93,6 +93,7 @@
True
True
True
+ True
True
True
True
\ No newline at end of file
diff --git a/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs b/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs
new file mode 100644
index 000000000..8d6d8424d
--- /dev/null
+++ b/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Artemis.Core;
+
+///
+/// Provides extension methods for nodes.
+///
+public static class NodeExtension
+{
+ #region Methods
+
+ ///
+ /// Estimates a height of a node in the editor.
+ ///
+ /// The node whose height to estimate.
+ /// The estimated height in pixels.
+ public static double EstimateHeight(this INode node)
+ {
+ const double PIN_HEIGHT = 26;
+ const double TITLE_HEIGHT = 46;
+
+ int inputPinCount = node.Pins.Count(x => x.Direction == PinDirection.Input)
+ + node.PinCollections.Where(x => x.Direction == PinDirection.Input).Sum(x => x.Count() + 1);
+ int outputPinCount = node.Pins.Count(x => x.Direction == PinDirection.Output)
+ + node.PinCollections.Where(x => x.Direction == PinDirection.Output).Sum(x => x.Count() + 1);
+
+ return TITLE_HEIGHT + Math.Max(inputPinCount, outputPinCount) * PIN_HEIGHT;
+ }
+
+ ///
+ /// Estimates a width a node in the editor.
+ ///
+ /// The node whose width to estimate.
+ /// The estimated width in pixels.
+ public static double EstimateWidth(this INode node)
+ {
+ // DarthAffe 13.09.2022: For now just assume they are all the same size
+ return 120;
+ }
+
+ ///
+ /// Determines whether the node is part of a loop when the provided pending connecting would be connected.
+ ///
+ /// The node to check
+ /// The node to which a connection is pending
+ /// if there would be a loop; otherwise .
+ public static bool IsInLoop(this INode node, INode pendingConnection)
+ {
+ HashSet checkedNodes = new();
+
+ bool CheckNode(INode checkNode, INode? pending)
+ {
+ if (checkedNodes.Contains(checkNode)) return false;
+
+ checkedNodes.Add(checkNode);
+
+ List connectedNodes = checkNode.Pins
+ .Where(x => x.Direction == PinDirection.Input)
+ .SelectMany(x => x.ConnectedTo)
+ .Select(x => x.Node)
+ .Concat(checkNode.PinCollections
+ .Where(x => x.Direction == PinDirection.Input)
+ .SelectMany(x => x)
+ .SelectMany(x => x.ConnectedTo)
+ .Select(x => x.Node))
+ .Distinct()
+ .ToList();
+ if (pending != null)
+ connectedNodes.Add(pending);
+
+ foreach (INode connectedNode in connectedNodes)
+ {
+ if (connectedNode == node)
+ return true;
+ else if (CheckNode(connectedNode, null))
+ return true;
+ }
+
+ return false;
+ }
+
+ return CheckNode(node, pendingConnection);
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs b/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs
new file mode 100644
index 000000000..da206c2f3
--- /dev/null
+++ b/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Artemis.Core;
+
+///
+/// Provides extension methods for node scripts.
+///
+public static class NodeScriptExtension
+{
+ #region Methods
+
+ ///
+ /// Organize a node script, attempting to lay out nodes in a logical manner.
+ ///
+ /// The node script to organize.
+ public static void Organize(this NodeScript nodeScript)
+ {
+ const double SPACING_HORIZONTAL = 160;
+ const double SPACING_VERTICAL = 20;
+
+ Dictionary levels = nodeScript.Nodes.ToDictionary(node => node, _ => -1);
+
+ List currentLevelNodes = nodeScript.Nodes.Where(x => x.IsExitNode).ToList();
+ foreach (INode currentLevelNode in currentLevelNodes)
+ levels[currentLevelNode] = 0; // DarthAffe 13.09.2022: Init-exit nodes as zero
+
+ int currentLevel = 1;
+ while (currentLevelNodes.Count > 0 && currentLevel < 1000)
+ {
+ List nextLevelNodes = currentLevelNodes.SelectMany(node => node.Pins
+ .Where(x => x.Direction == PinDirection.Input)
+ .SelectMany(x => x.ConnectedTo)
+ .Select(x => x.Node)
+ .Concat(node.PinCollections
+ .Where(x => x.Direction == PinDirection.Input)
+ .SelectMany(x => x)
+ .SelectMany(x => x.ConnectedTo)
+ .Select(x => x.Node)))
+ .Distinct()
+ .ToList();
+
+ foreach (INode nextLevelNode in nextLevelNodes)
+ {
+ if (currentLevel > levels[nextLevelNode])
+ levels[nextLevelNode] = currentLevel;
+ }
+
+ currentLevelNodes = nextLevelNodes;
+ currentLevel++;
+ }
+
+ void LayoutLevel(IList nodes, double posX, double posY)
+ {
+ foreach (INode node in nodes)
+ {
+ node.X = posX;
+ node.Y = posY;
+
+ posY += SPACING_VERTICAL + node.EstimateHeight();
+ }
+ }
+
+ List? unusedNodes = null;
+ double unusedPosY = 0;
+ double level0Width = 0;
+
+ double positionX = 0;
+ foreach (IGrouping levelGroup in levels.GroupBy(x => x.Value, x => x.Key).OrderBy(x => x.Key))
+ {
+ List nodes = levelGroup.ToList();
+ double levelHeight = nodes.Sum(x => x.EstimateHeight()) + (nodes.Count - 1) * SPACING_VERTICAL;
+ double levelWidth = nodes.Max(x => x.EstimateWidth());
+ double positionY = -(levelHeight / 2.0);
+
+ if (levelGroup.Key == -1)
+ {
+ unusedNodes = nodes;
+ unusedPosY = positionY;
+ }
+ else
+ {
+ if (levelGroup.Key == 0)
+ level0Width = levelWidth;
+
+ LayoutLevel(nodes, positionX, positionY);
+
+ positionX -= SPACING_HORIZONTAL + levelWidth;
+ }
+ }
+
+ if (unusedNodes != null)
+ LayoutLevel(unusedNodes, level0Width + SPACING_HORIZONTAL / 2.0, unusedPosY);
+ }
+
+ #endregion
+}
\ No newline at end of file
diff --git a/src/Artemis.UI.Shared/Services/NodeEditor/Commands/OrganizeScript.cs b/src/Artemis.UI.Shared/Services/NodeEditor/Commands/OrganizeScript.cs
new file mode 100644
index 000000000..cd7f3ec23
--- /dev/null
+++ b/src/Artemis.UI.Shared/Services/NodeEditor/Commands/OrganizeScript.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.Linq;
+using Artemis.Core;
+
+namespace Artemis.UI.Shared.Services.NodeEditor.Commands;
+
+///
+/// Represents a node editor command that can be used to organize a script
+///
+public class OrganizeScript : INodeEditorCommand
+{
+ private readonly NodeScript _script;
+ private readonly List<(INode node, double x, double y)> _originalPositions;
+
+ ///
+ /// Creates a new instance of the class.
+ ///
+ /// The script to organize.
+ public OrganizeScript(NodeScript script)
+ {
+ _script = script;
+ _originalPositions = script.Nodes.Select(n => (n, n.X, n.Y)).ToList();
+ }
+
+ ///
+ public string DisplayName => "Organize script";
+
+ ///
+ public void Execute()
+ {
+ _script.Organize();
+ }
+
+ ///
+ public void Undo()
+ {
+ foreach ((INode? node, double x, double y) in _originalPositions)
+ {
+ node.X = x;
+ node.Y = y;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs b/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs
index 039d5f4fe..8c0adc5b9 100644
--- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs
+++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Diagnostics;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
@@ -197,8 +198,14 @@ public class NodeScriptViewModel : ActivatableViewModelBase
DragViewModel = new DragCableViewModel(sourcePinViewModel);
DragViewModel.DragPoint = position;
+ if (targetPinVmModel == null)
+ return true;
+ if (!targetPinVmModel.IsCompatibleWith(sourcePinViewModel))
+ return false;
- return targetPinVmModel == null || targetPinVmModel.IsCompatibleWith(sourcePinViewModel);
+ return sourcePinViewModel.Pin.Direction == PinDirection.Output
+ ? !targetPinVmModel.Pin.Node.IsInLoop(sourcePinViewModel.Pin.Node)
+ : !sourcePinViewModel.Pin.Node.IsInLoop(targetPinVmModel.Pin.Node);
}
public void FinishPinDrag(PinViewModel sourcePinViewModel, PinViewModel? targetPinVmModel, Point position)
@@ -211,7 +218,10 @@ public class NodeScriptViewModel : ActivatableViewModelBase
// If dropped on top of a compatible pin, connect to it
if (targetPinVmModel != null && targetPinVmModel.IsCompatibleWith(sourcePinViewModel))
{
- _nodeEditorService.ExecuteCommand(NodeScript, new ConnectPins(sourcePinViewModel.Pin, targetPinVmModel.Pin));
+ if (sourcePinViewModel.Pin.Direction == PinDirection.Output && !targetPinVmModel.Pin.Node.IsInLoop(sourcePinViewModel.Pin.Node))
+ _nodeEditorService.ExecuteCommand(NodeScript, new ConnectPins(sourcePinViewModel.Pin, targetPinVmModel.Pin));
+ else if (sourcePinViewModel.Pin.Direction == PinDirection.Input && !sourcePinViewModel.Pin.Node.IsInLoop(targetPinVmModel.Pin.Node))
+ _nodeEditorService.ExecuteCommand(NodeScript, new ConnectPins(sourcePinViewModel.Pin, targetPinVmModel.Pin));
}
// If not dropped on a pin allow the user to create a new node
else if (targetPinVmModel == null)
diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml
index e37a84702..aa862508f 100644
--- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml
+++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml
@@ -16,6 +16,7 @@
+