From 041ed8e0a0e1650a44165b0f4e8465f89d6ec4bb Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 13 Aug 2022 11:17:47 +0200 Subject: [PATCH] Visual Scripting - Fixed storage issues where connections would be lost Visual Scripting - Added node duplication --- .../VisualScripting/NodeScript.cs | 16 ++- src/Artemis.Core/VisualScripting/Pin.cs | 1 + .../Converters/SKColorToColor2Converter.cs | 29 ---- .../NodeEditor/Commands/DuplicateNode.cs | 126 ++++++++++++++++++ .../NodeEditor/NodeConnectionStore.cs | 14 +- .../VisualScripting/NodeScriptView.axaml | 17 ++- .../VisualScripting/NodeScriptViewModel.cs | 54 +++++++- .../Nodes/Operators/EnumEqualsNode.cs | 4 +- .../Screens/EnumEqualsNodeCustomView.axaml | 16 ++- .../Screens/EnumEqualsNodeCustomViewModel.cs | 35 +++-- .../StaticSKColorValueNodeCustomView.axaml | 4 +- 11 files changed, 256 insertions(+), 60 deletions(-) delete mode 100644 src/Artemis.UI.Shared/Converters/SKColorToColor2Converter.cs create mode 100644 src/Artemis.UI.Shared/Services/NodeEditor/Commands/DuplicateNode.cs diff --git a/src/Artemis.Core/VisualScripting/NodeScript.cs b/src/Artemis.Core/VisualScripting/NodeScript.cs index cf0d6b5e8..aed492d85 100644 --- a/src/Artemis.Core/VisualScripting/NodeScript.cs +++ b/src/Artemis.Core/VisualScripting/NodeScript.cs @@ -264,11 +264,23 @@ namespace Artemis.Core } // Only connect the nodes if they aren't already connected (LoadConnections may be called twice or more) - if (!targetPin.ConnectedTo.Contains(sourcePin) && targetPin.IsTypeCompatible(sourcePin.Type)) + // Type checking is done later when all connections are in place + if (!targetPin.ConnectedTo.Contains(sourcePin)) targetPin.ConnectTo(sourcePin); - if (!sourcePin.ConnectedTo.Contains(targetPin) && sourcePin.IsTypeCompatible(targetPin.Type)) + if (!sourcePin.ConnectedTo.Contains(targetPin)) sourcePin.ConnectTo(targetPin); } + + // With all connections restored, ensure types match (connecting pins may affect types so the check is done afterwards) + foreach (INode node in nodes) + { + foreach (IPin nodePin in node.Pins.Concat(node.PinCollections.SelectMany(p => p))) + { + List toDisconnect = nodePin.ConnectedTo.Where(c => !c.IsTypeCompatible(nodePin.Type)).ToList(); + foreach (IPin pin in toDisconnect) + pin.DisconnectFrom(nodePin); + } + } } /// diff --git a/src/Artemis.Core/VisualScripting/Pin.cs b/src/Artemis.Core/VisualScripting/Pin.cs index b9626f87e..d0df8bfbd 100644 --- a/src/Artemis.Core/VisualScripting/Pin.cs +++ b/src/Artemis.Core/VisualScripting/Pin.cs @@ -129,6 +129,7 @@ namespace Artemis.Core /// public bool IsTypeCompatible(Type type) => Type == type || Type == typeof(Enum) && type.IsEnum + || Type.IsEnum && type == typeof(Enum) || Direction == PinDirection.Input && Type == typeof(object) || Direction == PinDirection.Output && type == typeof(object); diff --git a/src/Artemis.UI.Shared/Converters/SKColorToColor2Converter.cs b/src/Artemis.UI.Shared/Converters/SKColorToColor2Converter.cs deleted file mode 100644 index 4b86659c4..000000000 --- a/src/Artemis.UI.Shared/Converters/SKColorToColor2Converter.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Globalization; -using Avalonia.Data.Converters; -using FluentAvalonia.UI.Media; -using SkiaSharp; - -namespace Artemis.UI.Shared.Converters; - -/// -/// Converts into . -/// -public class SKColorToColor2Converter : IValueConverter -{ - /// - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is SKColor skColor) - return new Color2(skColor.Red, skColor.Green, skColor.Blue, skColor.Alpha); - return new Color2(); - } - - /// - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is Color2 color2) - return new SKColor(color2.R, color2.G, color2.B, color2.A); - return SKColor.Empty; - } -} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/NodeEditor/Commands/DuplicateNode.cs b/src/Artemis.UI.Shared/Services/NodeEditor/Commands/DuplicateNode.cs new file mode 100644 index 000000000..ca6d4a3f6 --- /dev/null +++ b/src/Artemis.UI.Shared/Services/NodeEditor/Commands/DuplicateNode.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Artemis.Core; +using Artemis.Core.Services; +using FluentAvalonia.Core; + +namespace Artemis.UI.Shared.Services.NodeEditor.Commands; + +/// +/// Represents a node editor command that can be used to duplicate a node. +/// +public class DuplicateNode : INodeEditorCommand, IDisposable +{ + private readonly INode _node; + private readonly bool _copyIncomingConnections; + private readonly INodeService _nodeService; + private readonly INodeScript _nodeScript; + private INode? _copy; + private NodeConnectionStore? _connections; + + private bool _executed; + + /// + /// Creates a new instance of the class. + /// + /// The node script the node belongs to. + /// The node to delete. + /// A boolean indicating whether incoming connections should be copied. + /// The service to use to duplicate the node. + public DuplicateNode(INodeScript nodeScript, INode node, bool copyIncomingConnections, INodeService nodeService) + { + _nodeScript = nodeScript; + _node = node; + _copyIncomingConnections = copyIncomingConnections; + _nodeService = nodeService; + } + + /// + public void Dispose() + { + if (!_executed && _copy is IDisposable disposableNode) + disposableNode.Dispose(); + } + + /// + public string DisplayName => $"Duplicate '{_node.Name}' node"; + + /// + public void Execute() + { + if (_copy == null) + { + _copy = CreateCopy(); + _nodeScript.AddNode(_copy); + } + else if (_connections != null) + { + _nodeScript.AddNode(_copy); + _connections.Restore(); + } + + _executed = true; + } + + /// + public void Undo() + { + if (_copy != null && _connections != null) + { + _connections.Store(); + _nodeScript.RemoveNode(_copy); + } + + _executed = false; + } + + private INode CreateCopy() + { + NodeData? nodeData = _nodeService.AvailableNodes.FirstOrDefault(d => d.Type == _node.GetType()); + if (nodeData == null) + throw new ArtemisSharedUIException($"Can't create a copy of node of type {_node.GetType()} because there is no matching node data."); + + // Create the node + INode node = nodeData.CreateNode(_nodeScript, null); + // Move it slightly + node.X = _node.X + 20; + node.Y = _node.Y + 20; + + // Add pins to collections + for (int i = 0; i < _node.PinCollections.Count; i++) + { + IPinCollection sourceCollection = _node.PinCollections.ElementAt(i); + IPinCollection? targetCollection = node.PinCollections.ElementAtOrDefault(i); + + if (targetCollection == null) + continue; + while (targetCollection.Count() < sourceCollection.Count()) + targetCollection.CreatePin(); + } + + // Copy the storage + if (_node is Node sourceNode && node is Node targetNode) + targetNode.DeserializeStorage(sourceNode.SerializeStorage()); + + // Connect input pins + if (_copyIncomingConnections) + { + List sourceInputPins = _node.Pins.Concat(_node.PinCollections.SelectMany(c => c)).Where(p => p.Direction == PinDirection.Input).ToList(); + List targetInputPins = node.Pins.Concat(node.PinCollections.SelectMany(c => c)).Where(p => p.Direction == PinDirection.Input).ToList(); + for (int i = 0; i < sourceInputPins.Count; i++) + { + IPin source = sourceInputPins.ElementAt(i); + IPin? target = targetInputPins.ElementAtOrDefault(i); + + if (target == null) + continue; + foreach (IPin pin in source.ConnectedTo) + target.ConnectTo(pin); + } + } + + _connections = new NodeConnectionStore(node); + return node; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/NodeEditor/NodeConnectionStore.cs b/src/Artemis.UI.Shared/Services/NodeEditor/NodeConnectionStore.cs index 1d8582a11..3598d41fa 100644 --- a/src/Artemis.UI.Shared/Services/NodeEditor/NodeConnectionStore.cs +++ b/src/Artemis.UI.Shared/Services/NodeEditor/NodeConnectionStore.cs @@ -53,18 +53,20 @@ public class NodeConnectionStore { foreach (IPin nodePin in Node.Pins) { - if (_pinConnections.TryGetValue(nodePin, out List? connections)) - foreach (IPin connection in connections) - nodePin.ConnectTo(connection); + if (!_pinConnections.TryGetValue(nodePin, out List? connections)) + continue; + foreach (IPin connection in connections) + nodePin.ConnectTo(connection); } foreach (IPinCollection nodePinCollection in Node.PinCollections) { foreach (IPin nodePin in nodePinCollection) { - if (_pinConnections.TryGetValue(nodePin, out List? connections)) - foreach (IPin connection in connections) - nodePin.ConnectTo(connection); + if (!_pinConnections.TryGetValue(nodePin, out List? connections)) + continue; + foreach (IPin connection in connections) + nodePin.ConnectTo(connection); } } diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml b/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml index ad68c8569..b442e5e6f 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptView.axaml @@ -26,6 +26,13 @@ + + + + + + + + InputElement="{Binding #ZoomBorder}" + SelectionUpdated="SelectionRectangle_OnSelectionUpdated" + SelectionFinished="SelectionRectangle_OnSelectionFinished" + BorderBrush="{DynamicResource SystemAccentColor}" + BorderRadius="8"> diff --git a/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs b/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs index e73c87cb6..83b11faa7 100644 --- a/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs +++ b/src/Artemis.UI/Screens/VisualScripting/NodeScriptViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reactive; using System.Reactive.Linq; using System.Reactive.Subjects; using Artemis.Core; @@ -79,6 +80,12 @@ public class NodeScriptViewModel : ActivatableViewModelBase .Bind(out ReadOnlyObservableCollection cableViewModels) .Subscribe(); CableViewModels = cableViewModels; + + ClearSelection = ReactiveCommand.Create(ExecuteClearSelection); + DeleteSelected = ReactiveCommand.Create(ExecuteDeleteSelected); + DuplicateSelected = ReactiveCommand.Create(ExecuteDuplicateSelected); + CopySelected = ReactiveCommand.Create(ExecuteCopySelected); + PasteSelected = ReactiveCommand.Create(ExecutePasteSelected); } public NodeScript NodeScript { get; } @@ -91,12 +98,18 @@ public class NodeScriptViewModel : ActivatableViewModelBase public bool IsPreview { get; } + public ReactiveCommand ClearSelection { get; } + public ReactiveCommand DeleteSelected { get; } + public ReactiveCommand DuplicateSelected { get; } + public ReactiveCommand CopySelected { get; } + public ReactiveCommand PasteSelected { get; } + public DragCableViewModel? DragViewModel { get => _dragViewModel; set => RaiseAndSetIfChanged(ref _dragViewModel, value); } - + public Matrix PanMatrix { get => _panMatrix; @@ -219,6 +232,43 @@ public class NodeScriptViewModel : ActivatableViewModelBase } } + private void ExecuteClearSelection() + { + ClearNodeSelection(); + } + + private void ExecuteDeleteSelected() + { + List nodes = NodeViewModels.Where(vm => vm.IsSelected).Select(vm => vm.Node).ToList(); + using NodeEditorCommandScope scope = _nodeEditorService.CreateCommandScope(NodeScript, "Delete nodes"); + foreach (INode node in nodes.Where(n => !n.IsDefaultNode && !n.IsExitNode)) + _nodeEditorService.ExecuteCommand(NodeScript, new DeleteNode(NodeScript, node)); + } + + private void ExecuteDuplicateSelected() + { + int nodeCount = NodeViewModels.Count; + List nodes = NodeViewModels.Where(vm => vm.IsSelected).Select(vm => vm.Node).ToList(); + using NodeEditorCommandScope scope = _nodeEditorService.CreateCommandScope(NodeScript, "Duplicate nodes"); + foreach (INode node in nodes.Where(n => !n.IsDefaultNode && !n.IsExitNode)) + _nodeEditorService.ExecuteCommand(NodeScript, new DuplicateNode(NodeScript, node, false, _nodeService)); + + // Select only the new nodes + for (int index = 0; index < NodeViewModels.Count; index++) + { + NodeViewModel nodeViewModel = NodeViewModels[index]; + nodeViewModel.IsSelected = index >= nodeCount; + } + } + + private void ExecuteCopySelected() + { + } + + private void ExecutePasteSelected() + { + } + private void HandleNodeAdded(SingleValueEventArgs eventArgs) { _nodeViewModels.Add(_nodeVmFactory.NodeViewModel(this, eventArgs.Value)); @@ -235,6 +285,6 @@ public class NodeScriptViewModel : ActivatableViewModelBase { AutoFitRequested?.Invoke(this, EventArgs.Empty); } - + public event EventHandler? AutoFitRequested; } \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/Operators/EnumEqualsNode.cs b/src/Artemis.VisualScripting/Nodes/Operators/EnumEqualsNode.cs index 798ae1a47..6d409602d 100644 --- a/src/Artemis.VisualScripting/Nodes/Operators/EnumEqualsNode.cs +++ b/src/Artemis.VisualScripting/Nodes/Operators/EnumEqualsNode.cs @@ -4,7 +4,7 @@ using Artemis.VisualScripting.Nodes.Operators.Screens; namespace Artemis.VisualScripting.Nodes.Operators; [Node("Enum Equals", "Determines the equality between an input and a selected enum value", "Operators", InputType = typeof(Enum), OutputType = typeof(bool))] -public class EnumEqualsNode : Node +public class EnumEqualsNode : Node { public EnumEqualsNode() : base("Enum Equals", "Determines the equality between an input and a selected enum value") { @@ -21,6 +21,6 @@ public class EnumEqualsNode : Node if (InputPin.Value == null) OutputPin.Value = false; else - OutputPin.Value = Convert.ToInt32(InputPin.Value) == Storage; + OutputPin.Value = Convert.ToInt64(InputPin.Value) == Storage; } } \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomView.axaml b/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomView.axaml index cb4208523..d0b607bb5 100644 --- a/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomView.axaml +++ b/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomView.axaml @@ -8,8 +8,20 @@ x:DataType="screens:EnumEqualsNodeCustomViewModel"> + VerticalAlignment="Center"> + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomViewModel.cs b/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomViewModel.cs index 16fad79e2..db476a538 100644 --- a/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomViewModel.cs +++ b/src/Artemis.VisualScripting/Nodes/Operators/Screens/EnumEqualsNodeCustomViewModel.cs @@ -6,7 +6,10 @@ using Artemis.UI.Shared.Services.NodeEditor; using Artemis.UI.Shared.Services.NodeEditor.Commands; using Artemis.UI.Shared.VisualScripting; using Avalonia.Controls.Mixins; +using Avalonia.Threading; using DynamicData; +using FluentAvalonia.Core; +using Humanizer; using ReactiveUI; namespace Artemis.VisualScripting.Nodes.Operators.Screens; @@ -28,13 +31,11 @@ public class EnumEqualsNodeCustomViewModel : CustomNodeViewModel { EnumValues.Clear(); if (_node.InputPin.ConnectedTo.First().Type.IsEnum) - EnumValues.AddRange(Enum.GetValues(_node.InputPin.ConnectedTo.First().Type).Cast()); - - this.RaisePropertyChanged(nameof(CurrentValue)); + AddEnumValues(_node.InputPin.ConnectedTo.First().Type); } Observable.FromEventPattern>(x => _node.InputPin.PinConnected += x, x => _node.InputPin.PinConnected -= x) - .Subscribe(p => EnumValues.AddRange(Enum.GetValues(p.EventArgs.Value.Type).Cast())) + .Subscribe(p => AddEnumValues(p.EventArgs.Value.Type)) .DisposeWith(d); Observable.FromEventPattern>(x => _node.InputPin.PinDisconnected += x, x => _node.InputPin.PinDisconnected -= x) .Subscribe(_ => EnumValues.Clear()) @@ -42,15 +43,29 @@ public class EnumEqualsNodeCustomViewModel : CustomNodeViewModel }); } - public ObservableCollection EnumValues { get; } = new(); - - public int CurrentValue + private void AddEnumValues(Type type) { - get => _node.Storage; + Dispatcher.UIThread.Post(() => + { + List<(long, string)> values = Enum.GetValues(type).Cast().Select(e => (Convert.ToInt64(e), e.Humanize())).ToList(); + if (values.Count > 20) + EnumValues.AddRange(values.OrderBy(v => v.Item2)); + else + EnumValues.AddRange(Enum.GetValues(type).Cast().Select(e => (Convert.ToInt64(e), e.Humanize()))); + + this.RaisePropertyChanged(nameof(CurrentValue)); + }, DispatcherPriority.Background); + } + + public ObservableCollection<(long, string)> EnumValues { get; } = new(); + + public (long, string) CurrentValue + { + get => EnumValues.FirstOrDefault(v => v.Item1 == _node.Storage); set { - if (!Equals(_node.Storage, value)) - _nodeEditorService.ExecuteCommand(Script, new UpdateStorage(_node, value)); + if (!Equals(_node.Storage, value.Item1)) + _nodeEditorService.ExecuteCommand(Script, new UpdateStorage(_node, value.Item1)); } } } \ No newline at end of file diff --git a/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticSKColorValueNodeCustomView.axaml b/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticSKColorValueNodeCustomView.axaml index 84db3a246..fb109aff3 100644 --- a/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticSKColorValueNodeCustomView.axaml +++ b/src/Artemis.VisualScripting/Nodes/Static/Screens/StaticSKColorValueNodeCustomView.axaml @@ -11,7 +11,7 @@ x:DataType="screens:StaticSKColorValueNodeCustomViewModel"> - +