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 @@ + @@ -43,6 +44,11 @@ + + + + + diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs index 911835000..0925e450c 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowViewModel.cs @@ -26,6 +26,7 @@ public class NodeScriptWindowViewModel : NodeScriptWindowViewModelBase private readonly IProfileService _profileService; private readonly ISettingsService _settingsService; private readonly IWindowService _windowService; + private bool _pauseUpdate; public NodeScriptWindowViewModel(NodeScript nodeScript, INodeService nodeService, @@ -56,6 +57,7 @@ public class NodeScriptWindowViewModel : NodeScriptWindowViewModelBase Categories = categories; CreateNode = ReactiveCommand.Create(ExecuteCreateNode); + AutoArrange = ReactiveCommand.CreateFromTask(ExecuteAutoArrange); Export = ReactiveCommand.CreateFromTask(ExecuteExport); Import = ReactiveCommand.CreateFromTask(ExecuteImport); @@ -83,6 +85,7 @@ public class NodeScriptWindowViewModel : NodeScriptWindowViewModelBase public ReactiveCommand OpenUri { get; set; } public ReadOnlyObservableCollection> Categories { get; } public ReactiveCommand CreateNode { get; } + public ReactiveCommand AutoArrange { get; } public ReactiveCommand Export { get; } public ReactiveCommand Import { get; } @@ -108,6 +111,27 @@ public class NodeScriptWindowViewModel : NodeScriptWindowViewModelBase _nodeEditorService.ExecuteCommand(NodeScript, new AddNode(NodeScript, node)); } + private async Task ExecuteAutoArrange() + { + try + { + if (!NodeScript.ExitNodeConnected) + { + await _windowService.ShowConfirmContentDialog("Cannot auto-arrange", "The exit node must be connected in order to perform auto-arrange.", "Close", null); + return; + } + + _pauseUpdate = true; + _nodeEditorService.ExecuteCommand(NodeScript, new OrganizeScript(NodeScript)); + await Task.Delay(200); + NodeScriptViewModel.RequestAutoFit(); + } + finally + { + _pauseUpdate = false; + } + } + private async Task ExecuteExport() { // Might not cover everything but then the dialog will complain and that's good enough @@ -131,17 +155,26 @@ public class NodeScriptWindowViewModel : NodeScriptWindowViewModelBase if (result == null) return; - string json = await File.ReadAllTextAsync(result[0]); - _nodeService.ImportScript(json, NodeScript); - History.Clear(); + try + { + _pauseUpdate = true; + string json = await File.ReadAllTextAsync(result[0]); + _nodeService.ImportScript(json, NodeScript); + History.Clear(); - await Task.Delay(200); - NodeScriptViewModel.RequestAutoFit(); + await Task.Delay(200); + NodeScriptViewModel.RequestAutoFit(); + } + finally + { + _pauseUpdate = false; + } } private void Update(object? sender, EventArgs e) { - NodeScript.Run(); + if (!_pauseUpdate) + NodeScript.Run(); } private void Save(object? sender, EventArgs e)