Browse Source

Made node pairs generic and simple test

flabbet 1 year ago
parent
commit
a3e1a80884
36 changed files with 363 additions and 127 deletions
  1. 6 2
      src/PixiEditor.AvaloniaUI/ViewModels/Document/NodeGraphViewModel.cs
  2. 2 5
      src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/CreateNode_ChangeInfo.cs
  3. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Document.cs
  4. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/IReadOnlyNode.cs
  5. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CircleNode.cs
  6. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineChannelsNode.cs
  7. 8 6
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineColorNode.cs
  8. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineVecD.cs
  9. 2 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineVecI.cs
  10. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateChannelsNode.cs
  11. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateColorNode.cs
  12. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateVecDNode.cs
  13. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateVecINode.cs
  14. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/DebugBlendModeNode.cs
  15. 2 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/EmptyImageNode.cs
  16. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ImageSpaceNode.cs
  17. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/KernelNode.cs
  18. 3 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MathNode.cs
  19. 2 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MatrixTransformNode.cs
  20. 3 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MergeNode.cs
  21. 5 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageLeftNode.cs
  22. 4 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageRightNode.cs
  23. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Node.cs
  24. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/NoiseNode.cs
  25. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/OutputNode.cs
  26. 6 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/StructureNode.cs
  27. 9 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/PairNodeAttribute.cs
  28. 0 61
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/CreateModifyImageNodePair_Change.cs
  29. 78 0
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/CreateNodePair_Change.cs
  30. 2 22
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/CreateNode_Change.cs
  31. 40 6
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/NodeOperations.cs
  32. 4 17
      src/PixiEditor.ChangeableDocument/Rendering/RenderingContext.cs
  33. 74 0
      tests/PixiEditor.Backend.Tests/MockDocument.cs
  34. 51 0
      tests/PixiEditor.Backend.Tests/NodeSystemTests.cs
  35. 28 0
      tests/PixiEditor.Backend.Tests/PixiEditor.Backend.Tests.csproj
  36. 14 0
      tests/PixiEditorTests.sln

+ 6 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Document/NodeGraphViewModel.cs

@@ -1,9 +1,12 @@
 using System.Collections.ObjectModel;
+using System.Reflection;
 using PixiEditor.AvaloniaUI.Models.DocumentModels;
 using PixiEditor.AvaloniaUI.Models.Handlers;
 using PixiEditor.AvaloniaUI.ViewModels.Nodes;
 using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.Numerics;
@@ -189,9 +192,10 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler
     {
         IAction change;
         
-        if (nodeType == typeof(ModifyImageLeftNode) || nodeType == typeof(ModifyImageRightNode))
+        PairNodeAttribute? pairAttribute = nodeType.GetCustomAttribute<PairNodeAttribute>(true);
+        if (pairAttribute != null)
         {
-            change = new CreateModifyImageNodePair_Action(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid());
+            change = new CreateNodePair_Action(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), nodeType);
         }
         else
         {

+ 2 - 5
src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/CreateNode_ChangeInfo.cs

@@ -20,13 +20,10 @@ public record CreateNode_ChangeInfo(
         return properties.Select(p => new NodePropertyInfo(p.InternalPropertyName, p.DisplayName, p.ValueType, isInput, GetNonOverridenValue(p), guid))
             .ToImmutableArray();
     }
-
-    public static CreateNode_ChangeInfo CreateFromNode(IReadOnlyNode node) =>
-        CreateFromNode(node, node.GetType().Name.Replace("Node", ""));
     
-    public static CreateNode_ChangeInfo CreateFromNode(IReadOnlyNode node, string name)
+    public static CreateNode_ChangeInfo CreateFromNode(IReadOnlyNode node)
     {
-        return new CreateNode_ChangeInfo(node.InternalName, name, node.Position,
+        return new CreateNode_ChangeInfo(node.InternalName, node.DisplayName, node.Position,
             node.Id,
             CreatePropertyInfos(node.InputProperties, true, node.Id), CreatePropertyInfos(node.OutputProperties, false, node.Id));
     }

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Document.cs

@@ -12,7 +12,7 @@ using PixiEditor.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changeables;
 
-internal class Document : IChangeable, IReadOnlyDocument, IDisposable
+internal class Document : IChangeable, IReadOnlyDocument
 {
     IReadOnlyNodeGraph IReadOnlyDocument.NodeGraph => NodeGraph;
     IReadOnlySelection IReadOnlyDocument.Selection => Selection;

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/IReadOnlyNode.cs

@@ -14,6 +14,7 @@ public interface IReadOnlyNode
     public Surface? CachedResult { get; }
     
     public string InternalName { get; }
+    string DisplayName { get; }
 
     public Surface? Execute(RenderingContext context);
     public bool Validate();

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CircleNode.cs

@@ -51,6 +51,8 @@ public class CircleNode : Node
         return Output.Value;
     }
 
+    public override string DisplayName { get; set; } = "CIRCLE_NODE";
+
     public override bool Validate()
     {
         return Radius.Value is { X: > 0, Y: > 0 } && StrokeWidth.Value > 0;

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineChannelsNode.cs

@@ -112,6 +112,7 @@ public class CombineChannelsNode : Node
         return final.Size;
     }
 
+    public override string DisplayName { get; set; } = "COMBINE_CHANNELS_NODE";
     public override bool Validate() => true;
 
     public override Node CreateCopy() => new CombineChannelsNode();

+ 8 - 6
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineColorNode.cs

@@ -7,19 +7,21 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.CombineSeparate;
 public class CombineColorNode : Node
 {
     public FieldOutputProperty<Color> Color { get; }
-    
+
     public FieldInputProperty<double> R { get; }
-    
+
     public FieldInputProperty<double> G { get; }
-    
+
     public FieldInputProperty<double> B { get; }
-    
+
     public FieldInputProperty<double> A { get; }
 
+    public override string DisplayName { get; set; } = "COMBINE_COLOR_NODE";
+
     public CombineColorNode()
     {
         Color = CreateFieldOutput(nameof(Color), "COLOR", GetColor);
-        
+
         R = CreateFieldInput("R", "R", 0d);
         G = CreateFieldInput("G", "G", 0d);
         B = CreateFieldInput("B", "B", 0d);
@@ -36,7 +38,7 @@ public class CombineColorNode : Node
         return new Color((byte)r, (byte)g, (byte)b, (byte)a);
     }
 
-    protected override string NodeUniqueName => "CombineColor"; 
+    protected override string NodeUniqueName => "CombineColor";
 
     protected override Surface? OnExecute(RenderingContext context)
     {

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineVecD.cs

@@ -14,6 +14,7 @@ public class CombineVecD : Node
     public FieldInputProperty<double> Y { get; }
     
     
+    public override string DisplayName { get; set; } = "COMBINE_VECD_NODE";
 
     public CombineVecD()
     {

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/CombineVecI.cs

@@ -11,8 +11,8 @@ public class CombineVecI : Node
     public FieldInputProperty<int> X { get; }
     
     public FieldInputProperty<int> Y { get; }
-    
-    
+
+    public override string DisplayName { get; set; } = "COMBINE_VECI_NODE";
 
     public CombineVecI()
     {

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateChannelsNode.cs

@@ -43,6 +43,8 @@ public class SeparateChannelsNode : Node
 
     protected override string NodeUniqueName => "SeparateChannels";
 
+    public override string DisplayName { get; set; } = "SEPARATE_CHANNELS_NODE";
+    
     protected override Surface? OnExecute(RenderingContext context)
     {
         var image = Image.Value;

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateColorNode.cs

@@ -14,6 +14,8 @@ public class SeparateColorNode : Node
     public FieldOutputProperty<double> B { get; }
     
     public FieldOutputProperty<double> A { get; }
+    
+    public override string DisplayName { get; set; } = "SEPARATE_COLOR_NODE";
 
     public SeparateColorNode()
     {

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateVecDNode.cs

@@ -10,6 +10,8 @@ public class SeparateVecDNode : Node
     public FieldOutputProperty<double> X { get; }
     
     public FieldOutputProperty<double> Y { get; }
+    
+    public override string DisplayName { get; set; } = "SEPARATE_VECD_NODE";
 
     public SeparateVecDNode()
     {

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateVecINode.cs

@@ -10,6 +10,8 @@ public class SeparateVecINode : Node
     public FieldOutputProperty<int> X { get; }
     
     public FieldOutputProperty<int> Y { get; }
+    
+    public override string DisplayName { get; set; } = "SEPARATE_VECI_NODE";
 
     public SeparateVecINode()
     {

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/DebugBlendModeNode.cs

@@ -20,6 +20,7 @@ public class DebugBlendModeNode : Node
 
     public OutputProperty<Surface> Result { get; }
 
+    public override string DisplayName { get; set; } = "Debug Blend Mode";
     public DebugBlendModeNode()
     {
         Dst = CreateInput<Surface?>(nameof(Dst), "Dst", null);

+ 2 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/EmptyImageNode.cs

@@ -37,7 +37,8 @@ public class CreateImageNode : Node
 
         return Output.Value;
     }
-
+ 
+    public override string DisplayName { get; set; } = "CREATE_IMAGE_NODE";
     public override bool Validate() => Size.Value is { X: > 0, Y: > 0 };
 
     public override Node CreateCopy() => new CreateImageNode();

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ImageSpaceNode.cs

@@ -12,6 +12,7 @@ public class ImageSpaceNode : Node
     
     public FieldOutputProperty<VecI> Size { get; }
 
+    public override string DisplayName { get; set; } = "IMAGE_SPACE_NODE";
     public ImageSpaceNode()
     {
         SpacePosition = CreateFieldOutput(nameof(SpacePosition), "PIXEL_COORDINATE", ctx => ctx.Position);

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/KernelNode.cs

@@ -24,6 +24,7 @@ public class KernelFilterNode : Node
 
     public InputProperty<bool> OnAlpha { get; }
 
+    public override string DisplayName { get; set; } = "KERNEL_FILTER_NODE";
     public KernelFilterNode()
     {
         Transformed = CreateOutput<Surface>(nameof(Transformed), "TRANSFORMED", null);

+ 3 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MathNode.cs

@@ -19,6 +19,9 @@ public class MathNode : Node
     
     public FieldInputProperty<double> Y { get; }
     
+    
+    public override string DisplayName { get; set; } = "MATH_NODE";
+    
     public MathNode()
     {
         Result = CreateFieldOutput(nameof(Result), "RESULT", Calculate);

+ 2 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MatrixTransformNode.cs

@@ -20,6 +20,8 @@ public class MatrixTransformNode : Node
     
     public InputProperty<ColorMatrix> Matrix { get; }
 
+    public override string DisplayName { get; set; } = "MATRIX_TRANSFORM_NODE";
+    
     public MatrixTransformNode()
     {
         Transformed = CreateOutput<Surface>(nameof(Transformed), "TRANSFORMED", null);

+ 3 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MergeNode.cs

@@ -18,7 +18,9 @@ public class MergeNode : Node, IBackgroundInput
         Bottom = CreateInput<Surface?>("Bottom", "BOTTOM", null);
         Output = CreateOutput<Surface?>("Output", "OUTPUT", null);
     }
-    
+
+    public override string DisplayName { get; set; } = "MERGE_NODE";
+
     public override bool Validate()
     {
         return Top.Connection != null || Bottom.Connection != null;

+ 5 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageLeftNode.cs

@@ -9,16 +9,19 @@ using PixiEditor.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 
+[PairNode(typeof(ModifyImageRightNode), "ModifyImageZone", true)]
 public class ModifyImageLeftNode : Node
 {
     private Pixmap? pixmap;
-    
+
     public InputProperty<Surface?> Image { get; }
     
     public FieldOutputProperty<VecD> Coordinate { get; }
     
     public FieldOutputProperty<Color> Color { get; }
-    
+
+    public override string DisplayName { get; set; } = "MODIFY_IMAGE_LEFT_NODE";
+
     public ModifyImageLeftNode()
     {
         Image = CreateInput<Surface>(nameof(Surface), "IMAGE", null);

+ 4 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ModifyImageRightNode.cs

@@ -10,6 +10,7 @@ using PixiEditor.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 
+[PairNode(typeof(ModifyImageLeftNode), "ModifyImageZone")]
 public class ModifyImageRightNode : Node
 {
     private ModifyImageLeftNode startNode;
@@ -19,7 +20,9 @@ public class ModifyImageRightNode : Node
     public FieldInputProperty<Color> Color { get; }
     
     public OutputProperty<Surface> Output { get; }
-    
+
+    public override string DisplayName { get; set; } = "MODIFY_IMAGE_RIGHT_NODE";
+
     public ModifyImageRightNode(ModifyImageLeftNode startNode)
     {
         this.startNode = startNode;

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Node.cs

@@ -50,6 +50,7 @@ public abstract class Node : IReadOnlyNode, IDisposable
     IReadOnlyCollection<IInputProperty> IReadOnlyNode.InputProperties => inputs;
     IReadOnlyCollection<IOutputProperty> IReadOnlyNode.OutputProperties => outputs;
     public VecD Position { get; set; }
+    public abstract string DisplayName { get; set; }
 
     private KeyFrameTime _lastFrameTime = new KeyFrameTime(-1);
     private ChunkResolution? _lastResolution;

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/NoiseNode.cs

@@ -51,6 +51,7 @@ public class NoiseNode : Node
         return Noise.Value;
     }
 
+    public override string DisplayName { get; set; } = "NOISE_NODE";
     public override bool Validate() => Size.Value is { X: > 0, Y: > 0 };
 
     public override Node CreateCopy() => new NoiseNode();

+ 1 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/OutputNode.cs

@@ -7,6 +7,7 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 
 public class OutputNode : Node, IBackgroundInput
 {
+    public override string DisplayName { get; set; } = "OUTPUT_NODE";
     public InputProperty<Surface?> Input { get; } 
     public OutputNode()
     {

+ 6 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/StructureNode.cs

@@ -22,6 +22,12 @@ public abstract class StructureNode : Node, IReadOnlyStructureNode, IBackgroundI
     public OutputProperty<Surface?> Output { get; }
 
     public string MemberName { get; set; } = string.Empty;
+    
+    public override string DisplayName
+    {
+        get => MemberName;
+        set => MemberName = value;
+    }
 
     protected Dictionary<ChunkResolution, Surface> workingSurfaces = new Dictionary<ChunkResolution, Surface>();
     private Paint maskPaint = new Paint() { BlendMode = DrawingApi.Core.Surface.BlendMode.DstIn };

+ 9 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/PairNodeAttribute.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.ChangeableDocument.Changeables.Graph;
+
+[AttributeUsage(validOn: AttributeTargets.Class)]
+public class PairNodeAttribute(Type otherType, string zoneUniqueName, bool isStartingType = false) : Attribute
+{
+    public Type OtherType { get; set; } = otherType;
+    public bool IsStartingType { get; set; } = isStartingType;
+    public string ZoneUniqueName { get; set; } = zoneUniqueName;
+}

+ 0 - 61
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/CreateModifyImageNodePair_Change.cs

@@ -1,61 +0,0 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
-using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
-using PixiEditor.Numerics;
-
-namespace PixiEditor.ChangeableDocument.Changes.NodeGraph;
-
-internal class CreateModifyImageNodePair_Change : Change
-{
-    private Guid startId;
-    private Guid endId;
-    private Guid zoneId;
-    
-    [GenerateMakeChangeAction]
-    public CreateModifyImageNodePair_Change(Guid startId, Guid endId, Guid zoneId)
-    {
-        this.startId = startId;
-        this.endId = endId;
-        this.zoneId = zoneId;
-    }
-
-    public override bool InitializeAndValidate(Document target) => true;
-
-    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
-    {
-        var start = new ModifyImageLeftNode();
-        var end = new ModifyImageRightNode(start);
-
-        start.Id = startId;
-        end.Id = endId;
-        end.Position = new VecD(100, 0);
-        
-        target.NodeGraph.AddNode(start);
-        target.NodeGraph.AddNode(end);
-        
-        ignoreInUndo = false;
-
-        return new List<IChangeInfo>
-        {
-            CreateNode_ChangeInfo.CreateFromNode(start, "Modify Image Start"),
-            CreateNode_ChangeInfo.CreateFromNode(end, "Modify Image End"),
-            new CreateNodeZone_ChangeInfo(zoneId, "PixiEditor.ModifyImageZone", startId, endId)
-        };
-    }
-
-    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
-    {
-        var startChange = RemoveNode(target, startId);
-        var endChange = RemoveNode(target, endId);
-        var zoneChange = new DeleteNodeFrame_ChangeInfo(zoneId);
-
-        return new List<IChangeInfo> { startChange, endChange, zoneChange };
-    }
-
-    private static DeleteNode_ChangeInfo RemoveNode(Document target, Guid id)
-    {
-        Node node = target.FindNodeOrThrow<Node>(id);
-        target.NodeGraph.RemoveNode(node);
-
-        return new DeleteNode_ChangeInfo(id);
-    }
-}

+ 78 - 0
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/CreateNodePair_Change.cs

@@ -0,0 +1,78 @@
+using System.Reflection;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changes.NodeGraph;
+
+internal class CreateNodePair_Change : Change
+{
+    private Guid startId;
+    private Guid endId;
+    private Guid zoneId;
+    private Type nodeType;
+    
+    [GenerateMakeChangeAction]
+    public CreateNodePair_Change(Guid startId, Guid endId, Guid zoneId, Type nodeType)
+    {
+        this.startId = startId;
+        this.endId = endId;
+        this.zoneId = zoneId;
+        this.nodeType = nodeType;
+    }
+
+    public override bool InitializeAndValidate(Document target)
+    {
+        return nodeType.GetCustomAttribute<PairNodeAttribute>() != null && nodeType is { IsAbstract: false, IsInterface: false };
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        if (startId == Guid.Empty)
+            startId = Guid.NewGuid();
+        if (endId == Guid.Empty)
+            endId = Guid.NewGuid();
+        
+        PairNodeAttribute attribute = nodeType.GetCustomAttribute<PairNodeAttribute>();
+        Type startingType = attribute.IsStartingType ? nodeType : attribute.OtherType;
+        Type endingType = attribute.IsStartingType ? attribute.OtherType : nodeType;
+        
+        var start = NodeOperations.CreateNode(startingType, target);
+        var end = NodeOperations.CreateNode(endingType, target, start);
+
+        start.Id = startId;
+        end.Id = endId;
+        end.Position = new VecD(100, 0);
+        
+        target.NodeGraph.AddNode(start);
+        target.NodeGraph.AddNode(end);
+        
+        ignoreInUndo = false;
+
+        return new List<IChangeInfo>
+        {
+            CreateNode_ChangeInfo.CreateFromNode(start),
+            CreateNode_ChangeInfo.CreateFromNode(end),
+            new CreateNodeZone_ChangeInfo(zoneId, $"PixiEditor.{attribute.ZoneUniqueName}", startId, endId)
+        };
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        var startChange = RemoveNode(target, startId);
+        var endChange = RemoveNode(target, endId);
+        var zoneChange = new DeleteNodeFrame_ChangeInfo(zoneId);
+
+        return new List<IChangeInfo> { startChange, endChange, zoneChange };
+    }
+
+    private static DeleteNode_ChangeInfo RemoveNode(Document target, Guid id)
+    {
+        Node node = target.FindNodeOrThrow<Node>(id);
+        target.NodeGraph.RemoveNode(node);
+
+        return new DeleteNode_ChangeInfo(id);
+    }
+}

+ 2 - 22
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/CreateNode_Change.cs

@@ -13,29 +13,17 @@ internal class CreateNode_Change : Change
 {
     private Type nodeType;
     private Guid id;
-    private static Dictionary<Type, INodeFactory> allFactories;
     
     [GenerateMakeChangeAction]
     public CreateNode_Change(Type nodeType, Guid id)
     {
         this.id = id;
         this.nodeType = nodeType;
-
-        if (allFactories == null)
-        {
-            allFactories = new Dictionary<Type, INodeFactory>();
-            var factoryTypes = Assembly.GetExecutingAssembly().GetTypes().Where(x => x.IsSubclassOf(typeof(INodeFactory)) && !x.IsAbstract && !x.IsInterface).ToImmutableArray();
-            foreach (var factoryType in factoryTypes)
-            {
-                INodeFactory factory = (INodeFactory)Activator.CreateInstance(factoryType);
-                allFactories.Add(factory.NodeType, factory);
-            }
-        }
     }
     
     public override bool InitializeAndValidate(Document target)
     {
-        return nodeType.IsSubclassOf(typeof(Node));
+        return nodeType.IsSubclassOf(typeof(Node)) && nodeType is { IsAbstract: false, IsInterface: false };
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
@@ -43,15 +31,7 @@ internal class CreateNode_Change : Change
         if(id == Guid.Empty)
             id = Guid.NewGuid();
 
-        Node node = null;
-        if (allFactories.TryGetValue(nodeType, out INodeFactory factory))
-        {
-            node = factory.CreateNode(target);
-        }
-        else
-        {
-            node = (Node)Activator.CreateInstance(nodeType);
-        }
+        Node node = NodeOperations.CreateNode(nodeType, target);
         
         node.Position = new VecD(0, 0);
         node.Id = id;

+ 40 - 6
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/NodeOperations.cs

@@ -1,6 +1,9 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph;
+using System.Collections.Immutable;
+using System.Reflection;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 using PixiEditor.ChangeableDocument.Changes.Structure;
 using PixiEditor.DrawingApi.Core.Surface.ImageData;
@@ -9,6 +12,35 @@ namespace PixiEditor.ChangeableDocument.Changes.NodeGraph;
 
 public static class NodeOperations
 {
+    private static Dictionary<Type, INodeFactory> allFactories;
+
+    static NodeOperations()
+    {
+        allFactories = new Dictionary<Type, INodeFactory>();
+        var factoryTypes = typeof(Node).Assembly.GetTypes().Where(x =>
+            x.IsAssignableTo(typeof(INodeFactory)) && x is { IsAbstract: false, IsInterface: false }).ToImmutableArray();
+        foreach (var factoryType in factoryTypes)
+        {
+            INodeFactory factory = (INodeFactory)Activator.CreateInstance(factoryType);
+            allFactories.Add(factory.NodeType, factory);
+        }
+    }
+
+    public static Node CreateNode(Type nodeType, IReadOnlyDocument target, params object[] optionalParameters)
+    {
+        Node node = null;
+        if (allFactories.TryGetValue(nodeType, out INodeFactory factory))
+        {
+            node = factory.CreateNode(target);
+        }
+        else
+        {
+            node = (Node)Activator.CreateInstance(nodeType, optionalParameters);
+        }
+        
+        return node;
+    }
+
     public static List<ConnectProperty_ChangeInfo> AppendMember(InputProperty<Surface?> parentInput,
         OutputProperty<Surface> toAddOutput,
         InputProperty<Surface> toAddInput, Guid memberId)
@@ -53,7 +85,7 @@ public static class NodeOperations
                 changes.Add(new ConnectProperty_ChangeInfo(output.Node.Id, input.Node.Id,
                     output.InternalPropertyName, input.InternalPropertyName));
             }
-            
+
             structureNode.Background.Connection.DisconnectFrom(structureNode.Background);
             changes.Add(new ConnectProperty_ChangeInfo(null, structureNode.Id, null,
                 structureNode.Background.InternalPropertyName));
@@ -72,7 +104,8 @@ public static class NodeOperations
 
     public static List<IChangeInfo> ConnectStructureNodeProperties(
         List<PropertyConnection> originalOutputConnections,
-        List<(PropertyConnection, PropertyConnection?)> originalInputConnections, StructureNode node, IReadOnlyNodeGraph graph)
+        List<(PropertyConnection, PropertyConnection?)> originalInputConnections, StructureNode node,
+        IReadOnlyNodeGraph graph)
     {
         List<IChangeInfo> changes = new();
         foreach (var connection in originalOutputConnections)
@@ -87,15 +120,15 @@ public static class NodeOperations
         foreach (var connection in originalInputConnections)
         {
             var outputNode = graph.AllNodes.FirstOrDefault(x => x.Id == connection.Item2?.NodeId);
-            
+
             if (outputNode is null)
                 continue;
 
             IOutputProperty output = outputNode.GetOutputProperty(connection.Item2.PropertyName);
-            
+
             if (output is null)
                 continue;
-            
+
             IInputProperty? input =
                 node.GetInputProperty(connection.Item1.PropertyName);
 
@@ -111,4 +144,5 @@ public static class NodeOperations
         return changes;
     }
 }
+
 public record PropertyConnection(Guid? NodeId, string? PropertyName);

+ 4 - 17
src/PixiEditor.ChangeableDocument/Rendering/RenderingContext.cs

@@ -9,37 +9,24 @@ using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
 using DrawingApiBlendMode = PixiEditor.DrawingApi.Core.Surface.BlendMode;
 
 namespace PixiEditor.ChangeableDocument.Rendering;
+
 public class RenderingContext : IDisposable
 {
-    public Paint BlendModePaint = new () { BlendMode = DrawingApiBlendMode.SrcOver };
-    public Paint BlendModeOpacityPaint = new () { BlendMode = DrawingApiBlendMode.SrcOver };
-    public Paint ReplacingPaintWithOpacity = new () { BlendMode = DrawingApiBlendMode.Src };
+    public Paint BlendModePaint = new() { BlendMode = DrawingApiBlendMode.SrcOver };
+    public Paint BlendModeOpacityPaint = new() { BlendMode = DrawingApiBlendMode.SrcOver };
+    public Paint ReplacingPaintWithOpacity = new() { BlendMode = DrawingApiBlendMode.Src };
 
     public KeyFrameTime FrameTime { get; }
     public VecI ChunkToUpdate { get; }
     public ChunkResolution ChunkResolution { get; }
     public VecI DocumentSize { get; set; }
 
-    /// <summary>
-    ///     This surface is unique to each rendering context and is used to draw on to avoid leaking
-    /// internal node surfaces and cloning them. It is disposed after rendering.
-    /// </summary>
-    //public Surface WorkingSurface { get; }
-
-    public RenderingContext(KeyFrameTime frameTime, VecI docSize)
-    {
-        FrameTime = frameTime;
-        DocumentSize = docSize;
-        //WorkingSurface = new Surface(docSize);
-    }
-    
     public RenderingContext(KeyFrameTime frameTime, VecI chunkToUpdate, ChunkResolution chunkResolution, VecI docSize)
     {
         FrameTime = frameTime;
         ChunkToUpdate = chunkToUpdate;
         ChunkResolution = chunkResolution;
         DocumentSize = docSize;
-        //WorkingSurface = new Surface(docSize);
     }
 
     public static DrawingApiBlendMode GetDrawingBlendMode(BlendMode blendMode)

+ 74 - 0
tests/PixiEditor.Backend.Tests/MockDocument.cs

@@ -0,0 +1,74 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.Backend.Tests;
+
+public class MockDocument : IReadOnlyDocument
+{
+    public void Dispose()
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyNodeGraph NodeGraph { get; }
+    public IReadOnlySelection Selection { get; }
+    public IReadOnlyAnimationData AnimationData { get; }
+    public VecI Size { get; } = new VecI(16, 16);
+    public bool HorizontalSymmetryAxisEnabled { get; }
+    public bool VerticalSymmetryAxisEnabled { get; }
+    public double HorizontalSymmetryAxisY { get; }
+    public double VerticalSymmetryAxisX { get; }
+    public void ForEveryReadonlyMember(Action<IReadOnlyStructureNode> action)
+    {
+        throw new NotImplementedException();
+    }
+
+    public Image? GetLayerRasterizedImage(Guid layerGuid, int frame)
+    {
+        throw new NotImplementedException();
+    }
+
+    public RectI? GetChunkAlignedLayerBounds(Guid layerGuid, int frame)
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyNode FindNode(Guid guid)
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyStructureNode? FindMember(Guid guid)
+    {
+        throw new NotImplementedException();
+    }
+
+    public bool TryFindMember<T>(Guid guid, out T? member) where T : IReadOnlyStructureNode
+    {
+        throw new NotImplementedException();
+    }
+
+    public bool TryFindMember(Guid guid, out IReadOnlyStructureNode? member)
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyStructureNode FindMemberOrThrow(Guid guid)
+    {
+        throw new NotImplementedException();
+    }
+
+    public (IReadOnlyStructureNode, IReadOnlyFolderNode) FindChildAndParentOrThrow(Guid childGuid)
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyList<IReadOnlyStructureNode> FindMemberPath(Guid guid)
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyReferenceLayer? ReferenceLayer { get; }
+}

+ 51 - 0
tests/PixiEditor.Backend.Tests/NodeSystemTests.cs

@@ -0,0 +1,51 @@
+using System.Collections.Immutable;
+using System.Reflection;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.Changes.NodeGraph;
+using PixiEditor.ChangeableDocument.Rendering;
+using PixiEditor.DrawingApi.Core.Bridge;
+using PixiEditor.DrawingApi.Skia;
+using PixiEditor.Numerics;
+
+namespace PixiEditor.Backend.Tests;
+
+public class NodeSystemTests
+{
+    public NodeSystemTests()
+    {
+        DrawingBackendApi.SetupBackend(new SkiaDrawingBackend());
+    }
+
+    [Fact]
+    public void TestThatNodeGraphExecutesEmptyOutputNode()
+    {
+        NodeGraph graph = new NodeGraph();
+        OutputNode outputNode = new OutputNode();
+
+        graph.AddNode(outputNode);
+        using RenderingContext context = new RenderingContext(0, VecI.Zero, ChunkResolution.Full, new VecI(1, 1));
+        graph.Execute(context);
+
+        Assert.Null(outputNode.CachedResult);
+    }
+
+    [Fact]
+    public void TestThatCreateSimpleNodeDoesntThrow()
+    {
+        var allNodeTypes = typeof(Node).Assembly.GetTypes()
+            .Where(x => x.IsAssignableTo(typeof(Node)) && x is { IsAbstract: false, IsInterface: false }).ToList();
+
+        IReadOnlyDocument target = new MockDocument();
+
+        foreach (var type in allNodeTypes)
+        {
+            if(type.GetCustomAttribute<PairNodeAttribute>() != null) continue;
+            var node = NodeOperations.CreateNode(type, target);
+            Assert.NotNull(node);
+        }
+    }
+}

+ 28 - 0
tests/PixiEditor.Backend.Tests/PixiEditor.Backend.Tests.csproj

@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net8.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+
+        <IsPackable>false</IsPackable>
+        <IsTestProject>true</IsTestProject>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="coverlet.collector" Version="6.0.0"/>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/>
+        <PackageReference Include="xunit" Version="2.5.3"/>
+        <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <Using Include="Xunit"/>
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\..\src\PixiEditor.ChangeableDocument\PixiEditor.ChangeableDocument.csproj" />
+      <ProjectReference Include="..\..\src\PixiEditor.DrawingApi.Skia\PixiEditor.DrawingApi.Skia.csproj" />
+    </ItemGroup>
+
+</Project>

+ 14 - 0
tests/PixiEditorTests.sln

@@ -41,6 +41,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Extensions.MSPac
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Extensions.CommonApi", "..\src\PixiEditor.Extensions.CommonApi\PixiEditor.Extensions.CommonApi.csproj", "{AE174C40-F7CB-4A17-A87A-F1CDCE218B78}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Backend.Tests", "PixiEditor.Backend.Tests\PixiEditor.Backend.Tests.csproj", "{744D7ACF-9C09-4A29-A950-910934D90BFD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.ChangeableDocument", "..\src\PixiEditor.ChangeableDocument\PixiEditor.ChangeableDocument.csproj", "{26B5BF58-A3B7-4966-A34C-9E7AF2994165}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -114,6 +118,14 @@ Global
 		{AE174C40-F7CB-4A17-A87A-F1CDCE218B78}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{AE174C40-F7CB-4A17-A87A-F1CDCE218B78}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{AE174C40-F7CB-4A17-A87A-F1CDCE218B78}.Release|Any CPU.Build.0 = Release|Any CPU
+		{744D7ACF-9C09-4A29-A950-910934D90BFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{744D7ACF-9C09-4A29-A950-910934D90BFD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{744D7ACF-9C09-4A29-A950-910934D90BFD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{744D7ACF-9C09-4A29-A950-910934D90BFD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{26B5BF58-A3B7-4966-A34C-9E7AF2994165}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{26B5BF58-A3B7-4966-A34C-9E7AF2994165}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{26B5BF58-A3B7-4966-A34C-9E7AF2994165}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{26B5BF58-A3B7-4966-A34C-9E7AF2994165}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{7F2DBBFC-FBDB-4772-806F-3B0829032DC0} = {E118E6FE-67E7-4472-A8D7-E7F470E66131}
@@ -131,5 +143,7 @@ Global
 		{5851EC52-2875-4AC0-8C69-EA0B5E0D2B8A} = {D914C08C-5F1A-4E13-AAA6-F25E8C9748E2}
 		{DA31D2E8-2AC2-41D1-921B-7571881EE85F} = {D914C08C-5F1A-4E13-AAA6-F25E8C9748E2}
 		{AE174C40-F7CB-4A17-A87A-F1CDCE218B78} = {E118E6FE-67E7-4472-A8D7-E7F470E66131}
+		{744D7ACF-9C09-4A29-A950-910934D90BFD} = {D914C08C-5F1A-4E13-AAA6-F25E8C9748E2}
+		{26B5BF58-A3B7-4966-A34C-9E7AF2994165} = {E118E6FE-67E7-4472-A8D7-E7F470E66131}
 	EndGlobalSection
 EndGlobal