From 1c6e7bde4656701c70451a111d29765a424c3cba Mon Sep 17 00:00:00 2001 From: Darth Affe Date: Tue, 13 Sep 2022 21:29:02 +0200 Subject: [PATCH 1/3] Added extension to organize node-graphs --- .../Artemis.Core.csproj.DotSettings | 1 + .../Extensions/NodeExtension.cs | 26 ++++++ .../Extensions/NodeScriptExtension.cs | 88 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs create mode 100644 src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs diff --git a/src/Artemis.Core/Artemis.Core.csproj.DotSettings b/src/Artemis.Core/Artemis.Core.csproj.DotSettings index a18836e5e..d42d584d5 100644 --- a/src/Artemis.Core/Artemis.Core.csproj.DotSettings +++ b/src/Artemis.Core/Artemis.Core.csproj.DotSettings @@ -93,4 +93,5 @@ 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..5c187a4c8 --- /dev/null +++ b/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; + +namespace Artemis.Core; + +public static class NodeExtension +{ + #region Methods + + 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); + } + + public static double EstimateWidth(this INode node) => 120; // DarthAffe 13.09.2022: For now just assume they are all the same size + + #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..f0961dfb6 --- /dev/null +++ b/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Artemis.Core; + +public static class NodeScriptExtension +{ + #region Methods + + 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) + { + 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 From 65b8b377ec356e8e603a01ae376a97f9908ad471 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 24 Sep 2022 12:13:12 +0200 Subject: [PATCH 2/3] Script editor - Added auto-arrange button --- .../Artemis.Core.csproj.DotSettings | 1 + .../Extensions/NodeScriptExtension.cs | 2 +- .../NodeEditor/Commands/OrganizeScript.cs | 43 ++++++++++++++++++ .../NodeScriptWindowView.axaml | 5 +++ .../NodeScriptWindowViewModel.cs | 45 ++++++++++++++++--- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 src/Artemis.UI.Shared/Services/NodeEditor/Commands/OrganizeScript.cs 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/NodeScriptExtension.cs b/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs index f0961dfb6..92d583d99 100644 --- a/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs +++ b/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs @@ -19,7 +19,7 @@ public static class NodeScriptExtension levels[currentLevelNode] = 0; // DarthAffe 13.09.2022: Init-exit nodes as zero int currentLevel = 1; - while (currentLevelNodes.Count > 0) + while (currentLevelNodes.Count > 0 && currentLevel < 1000) { List nextLevelNodes = currentLevelNodes.SelectMany(node => node.Pins .Where(x => x.Direction == PinDirection.Input) 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/NodeScriptWindowView.axaml b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml index e37a84702..3ccf5989b 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml @@ -43,6 +43,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) From 8e3b6c34596b0cc6ff975d37bd62300aecac7612 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Sep 2022 20:01:12 +0200 Subject: [PATCH 3/3] Node script editor - Prevent creating loops Node script popout editor - Added auto-layout hotkey (Ctrl+F) --- .../Extensions/NodeExtension.cs | 70 +++++++++++++++++-- .../Extensions/NodeScriptExtension.cs | 33 +++++---- .../VisualScripting/NodeScriptViewModel.cs | 14 +++- .../NodeScriptWindowView.axaml | 3 +- 4 files changed, 101 insertions(+), 19 deletions(-) diff --git a/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs b/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs index 5c187a4c8..8d6d8424d 100644 --- a/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs +++ b/src/Artemis.Core/VisualScripting/Extensions/NodeExtension.cs @@ -1,26 +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); + + 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); + + node.PinCollections.Where(x => x.Direction == PinDirection.Output).Sum(x => x.Count() + 1); - return TITLE_HEIGHT + (Math.Max(inputPinCount, outputPinCount) * PIN_HEIGHT); + return TITLE_HEIGHT + Math.Max(inputPinCount, outputPinCount) * PIN_HEIGHT; } - public static double EstimateWidth(this INode node) => 120; // DarthAffe 13.09.2022: For now just assume they are all the same size + /// + /// 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 index 92d583d99..da206c2f3 100644 --- a/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs +++ b/src/Artemis.Core/VisualScripting/Extensions/NodeScriptExtension.cs @@ -3,10 +3,17 @@ 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; @@ -22,20 +29,22 @@ public static class NodeScriptExtension 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(); + .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++; @@ -60,7 +69,7 @@ public static class NodeScriptExtension 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 levelHeight = nodes.Sum(x => x.EstimateHeight()) + (nodes.Count - 1) * SPACING_VERTICAL; double levelWidth = nodes.Max(x => x.EstimateWidth()); double positionY = -(levelHeight / 2.0); @@ -81,7 +90,7 @@ public static class NodeScriptExtension } if (unusedNodes != null) - LayoutLevel(unusedNodes, level0Width + (SPACING_HORIZONTAL / 2.0), unusedPosY); + LayoutLevel(unusedNodes, level0Width + SPACING_HORIZONTAL / 2.0, unusedPosY); } #endregion 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 3ccf5989b..aa862508f 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptWindowView.axaml @@ -16,6 +16,7 @@ + @@ -43,7 +44,7 @@ - +