Bläddra i källkod

NodeGraph wip

flabbet 1 år sedan
förälder
incheckning
7b7711dac5

+ 1 - 1
src/Directory.Build.props

@@ -1,7 +1,7 @@
 <Project>
     <PropertyGroup>
         <CodeAnalysisRuleSet>../Custom.ruleset</CodeAnalysisRuleSet>
-		    <AvaloniaVersion>11.0.10</AvaloniaVersion>
+		    <AvaloniaVersion>11.0.11</AvaloniaVersion>
     </PropertyGroup>
     <ItemGroup>
         <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" />

+ 9 - 0
src/Nodes/INodeGraph.cs

@@ -0,0 +1,9 @@
+namespace Nodes;
+
+public interface INodeGraph
+{
+    public IReadOnlyCollection<IReadOnlyNode> AllNodes { get; }
+    public IReadOnlyNode OutputNode { get; }
+    public void AddNode(IReadOnlyNode node);
+    public void RemoveNode(IReadOnlyNode node);
+}

+ 32 - 0
src/Nodes/INodeProperty.cs

@@ -0,0 +1,32 @@
+namespace Nodes;
+
+public interface INodeProperty
+{
+    public string Name { get; }
+    public object Value { get; set; }
+    public IReadOnlyNode Node { get; }
+}
+
+public interface INodeProperty<T> : INodeProperty
+{
+    public new T Value { get; set; }
+}
+
+public interface IInputProperty : INodeProperty
+{
+    public IOutputProperty? Connection { get; set; }
+}
+
+public interface IOutputProperty : INodeProperty
+{
+    public void ConnectTo(IInputProperty property);
+    public void DisconnectFrom(IInputProperty property);
+}
+
+public interface IInputProperty<T> : IInputProperty, INodeProperty<T>
+{
+}
+
+public interface IOutputProperty<T> : IOutputProperty, INodeProperty<T>
+{
+}

+ 12 - 0
src/Nodes/IReadOnlyNode.cs

@@ -0,0 +1,12 @@
+namespace Nodes;
+
+public interface IReadOnlyNode
+{
+    public string Name { get; }
+    public IReadOnlyCollection<IInputProperty> InputProperties { get; }
+    public IReadOnlyCollection<IOutputProperty> OutputProperties { get; }
+    public IReadOnlyCollection<IReadOnlyNode> ConnectedNodes { get; }
+
+    public void Execute(int frame);
+    public bool Validate();
+}

+ 35 - 0
src/Nodes/InputProperty.cs

@@ -0,0 +1,35 @@
+using Nodes.Nodes;
+
+namespace Nodes;
+
+public class InputProperty : IInputProperty
+{
+    public string Name { get; }
+    public object Value { get; set; }
+    public Node Node { get; }
+    IReadOnlyNode INodeProperty.Node => Node;
+    
+    public IOutputProperty Connection { get; set; }
+    
+    internal InputProperty(Node node, string name, object defaultValue)
+    {
+        Name = name;
+        Value = defaultValue;
+        Node = node;
+    }
+
+}
+
+
+public class InputProperty<T> : InputProperty, IInputProperty<T>
+{
+    public new T Value
+    {
+        get => (T)base.Value;
+        set => base.Value = value;
+    }
+    
+    internal InputProperty(Node node, string name, T defaultValue) : base(node, name, defaultValue)
+    {
+    }
+}

+ 87 - 0
src/Nodes/NodeGraph.cs

@@ -0,0 +1,87 @@
+using Nodes.Nodes;
+using SkiaSharp;
+
+namespace Nodes;
+
+public class NodeGraph : INodeGraph
+{
+    private readonly List<Node> _nodes = new();
+    public IReadOnlyCollection<Node> Nodes => _nodes;
+    public OutputNode? OutputNode => Nodes.OfType<OutputNode>().FirstOrDefault();
+    
+    IReadOnlyCollection<IReadOnlyNode> INodeGraph.AllNodes => Nodes;
+    IReadOnlyNode INodeGraph.OutputNode => OutputNode;
+
+    public void AddNode(Node node)
+    {
+        if (Nodes.Contains(node))
+        {
+            return;
+        }
+        
+        _nodes.Add(node);
+    }
+    
+    public void RemoveNode(Node node)
+    {
+        if (!Nodes.Contains(node))
+        {
+            return;
+        }
+        
+        _nodes.Remove(node);
+    }
+    
+    public SKSurface? Execute()
+    {
+        if(OutputNode == null) return null;
+        
+        var queue = CalculateExecutionQueue(OutputNode);
+        
+        while (queue.Count > 0)
+        {
+            var node = queue.Dequeue();
+            
+            node.Execute(0);
+        }
+        
+        return OutputNode.Input.Value;
+    }
+
+    private Queue<IReadOnlyNode> CalculateExecutionQueue(OutputNode outputNode)
+    {
+        // backwards breadth-first search
+        var visited = new HashSet<IReadOnlyNode>();
+        var queueNodes = new Queue<IReadOnlyNode>();
+        List<IReadOnlyNode> finalQueue = new();
+        queueNodes.Enqueue(outputNode);
+        
+        while (queueNodes.Count > 0)
+        {
+            var node = queueNodes.Dequeue();
+            if (!visited.Add(node))
+            {
+                continue;
+            }
+            
+            finalQueue.Add(node);
+            
+            foreach (var input in node.InputProperties)
+            {
+                if (input.Connection == null)
+                {
+                    continue;
+                }
+                
+                queueNodes.Enqueue(input.Connection.Node);
+            }
+        }
+        
+        finalQueue.Reverse();
+        return new Queue<IReadOnlyNode>(finalQueue);
+    }
+
+    void INodeGraph.AddNode(IReadOnlyNode node) => AddNode((Node)node);
+
+    void INodeGraph.RemoveNode(IReadOnlyNode node) => RemoveNode((Node)node);
+}

+ 23 - 0
src/Nodes/Nodes.csproj

@@ -0,0 +1,23 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <OutputType>Exe</OutputType>
+        <TargetFramework>net8.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <PackageReference Include="SkiaSharp" Version="2.88.8" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <None Update="test.png">
+        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      </None>
+      <None Update="test2.png">
+        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      </None>
+    </ItemGroup>
+
+</Project>

+ 25 - 0
src/Nodes/Nodes/FolderNode.cs

@@ -0,0 +1,25 @@
+using SkiaSharp;
+
+namespace Nodes.Nodes;
+
+public class FolderNode : Node
+{
+    public InputProperty<SKSurface> Input { get; }
+    public OutputProperty<SKSurface> Output { get; }
+    
+    public FolderNode(string name) : base(name)
+    {
+        Input = CreateInput<SKSurface>("Input", null);
+        Output = CreateOutput<SKSurface>("Output", null);
+    }    
+    
+    public override bool Validate()
+    {
+        return true;
+    }
+    
+    public override void OnExecute(int frame)
+    {
+        Output.Value = Input.Value;
+    }
+}

+ 35 - 0
src/Nodes/Nodes/LayerNode.cs

@@ -0,0 +1,35 @@
+using SkiaSharp;
+
+namespace Nodes.Nodes;
+
+public class LayerNode : Node
+{
+    public InputProperty<SKSurface?> Background { get; }
+    public OutputProperty<SKSurface> Output { get; }
+    public SKSurface LayerImage { get; set; }
+    
+    public LayerNode(string name, SKSizeI size) : base(name)
+    {
+        Background = CreateInput<SKSurface>("Background", null);
+        Output = CreateOutput<SKSurface>("Image", SKSurface.Create(new SKImageInfo(size.Width, size.Height)));
+        LayerImage = SKSurface.Create(new SKImageInfo(size.Width, size.Height));
+    }
+
+    public override bool Validate()
+    {
+        return true;
+    }
+
+    public override void OnExecute(int frame)
+    {
+        using SKPaint paint = new SKPaint();
+        if (Background.Value != null)
+        {
+            Output.Value.Draw(Background.Value.Canvas, 0, 0, paint);       
+        }
+        
+        Output.Value.Canvas.DrawSurface(LayerImage, 0, 0, paint);
+    }
+
+   
+}

+ 44 - 0
src/Nodes/Nodes/MergeNode.cs

@@ -0,0 +1,44 @@
+using SkiaSharp;
+
+namespace Nodes.Nodes;
+
+public class MergeNode : Node
+{
+    public InputProperty<SKSurface?> Top { get; }
+    public InputProperty<SKSurface?> Bottom { get; }
+    public OutputProperty<SKSurface> Output { get; }
+    
+    public MergeNode(string name) : base(name)
+    {
+        Top = CreateInput<SKSurface>("Top", null);
+        Bottom = CreateInput<SKSurface>("Bottom", null);
+        Output = CreateOutput<SKSurface>("Output", null);
+    }
+    
+    public override bool Validate()
+    {
+        return Top.Value != null || Bottom.Value != null;
+    }
+
+    public override void OnExecute(int frame)
+    {
+        SKImage topSnapshot = Top.Value?.Snapshot();
+        SKImage bottomSnapshot = Bottom.Value?.Snapshot();
+        using SKPaint paint = new SKPaint() { BlendMode = SKBlendMode.SrcOver };
+        
+        SKSizeI size = new SKSizeI(topSnapshot?.Width ?? bottomSnapshot.Width, topSnapshot?.Height ?? bottomSnapshot.Height);
+        
+        Output.Value = SKSurface.Create(new SKImageInfo(size.Width, size.Height));
+        using SKCanvas canvas = Output.Value.Canvas;
+        
+        if (bottomSnapshot != null)
+        {
+            canvas.DrawImage(bottomSnapshot, 0, 0, paint);
+        }
+        
+        if (topSnapshot != null)
+        {
+            canvas.DrawImage(topSnapshot, 0, 0, paint);
+        }
+    }
+}

+ 50 - 0
src/Nodes/Nodes/Node.cs

@@ -0,0 +1,50 @@
+namespace Nodes.Nodes;
+
+public abstract class Node(string name) : IReadOnlyNode
+{
+    private List<InputProperty> inputs = new();
+    private List<OutputProperty> outputs = new();
+    
+    private List<IReadOnlyNode> _connectedNodes = new();
+    
+    public string Name { get; } = name;
+    
+    public IReadOnlyCollection<InputProperty> InputProperties => inputs;
+    public IReadOnlyCollection<OutputProperty> OutputProperties => outputs;
+    public IReadOnlyCollection<IReadOnlyNode> ConnectedNodes => _connectedNodes;
+
+    IReadOnlyCollection<IInputProperty> IReadOnlyNode.InputProperties => inputs;
+    IReadOnlyCollection<IOutputProperty> IReadOnlyNode.OutputProperties => outputs;
+
+    public void Execute(int frame)
+    {
+        foreach (var output in outputs)
+        {
+            foreach (var connection in output.Connections)
+            {
+                connection.Value = output.Value;
+            }
+        }
+        
+        OnExecute(frame);
+    }
+
+    public abstract void OnExecute(int frame);
+    public abstract bool Validate();
+    
+    protected InputProperty<T> CreateInput<T>(string name, T defaultValue)
+    {
+        var property = new InputProperty<T>(this, name, defaultValue);
+        inputs.Add(property);
+        return property;
+    }
+    
+    protected OutputProperty<T> CreateOutput<T>(string name, T defaultValue)
+    {
+        var property = new OutputProperty<T>(this, name, defaultValue);
+        outputs.Add(property);
+        property.Connected += (input, _) => _connectedNodes.Add(input.Node);
+        property.Disconnected += (input, _) => _connectedNodes.Remove(input.Node);
+        return property;
+    }
+}

+ 22 - 0
src/Nodes/Nodes/OutputNode.cs

@@ -0,0 +1,22 @@
+using SkiaSharp;
+
+namespace Nodes.Nodes;
+
+public class OutputNode : Node
+{
+    public InputProperty<SKSurface?> Input { get; } 
+    public OutputNode(string name) : base(name)
+    {
+        Input = CreateInput<SKSurface>("Input", null);
+    }
+    
+    public override bool Validate()
+    {
+        return Input.Value != null;
+    }
+    
+    public override void OnExecute(int frame)
+    {
+        
+    }
+}

+ 75 - 0
src/Nodes/OutputProperty.cs

@@ -0,0 +1,75 @@
+using Nodes.Nodes;
+
+namespace Nodes;
+
+public delegate void InputConnectedEvent(IInputProperty input, IOutputProperty output);
+public class OutputProperty : IOutputProperty
+{
+    private List<IInputProperty> _connections = new();
+    private object _value;
+    public string Name { get; }
+    
+    public Node Node { get; }
+    IReadOnlyNode INodeProperty.Node => Node;
+
+    public object Value
+    {
+        get => _value;
+        set
+        {
+            _value = value;
+            foreach (var connection in Connections)
+            {
+                connection.Value = value;
+            }
+        }    
+    }
+
+    public IReadOnlyCollection<IInputProperty> Connections => _connections;
+    
+    public event InputConnectedEvent Connected;
+    public event InputConnectedEvent Disconnected;
+    
+    internal OutputProperty(Node node, string name, object defaultValue)
+    {
+        Name = name;
+        Value = defaultValue;
+        _connections = new List<IInputProperty>();
+        Node = node;
+    }
+    
+    public void ConnectTo(IInputProperty property)
+    {
+        if(Connections.Contains(property)) return;
+        
+        _connections.Add(property);
+        property.Connection = this;
+        Connected?.Invoke(property, this);
+    }
+    
+    public void DisconnectFrom(IInputProperty property)
+    {
+        if(!Connections.Contains(property)) return;
+        
+        _connections.Remove(property);
+        if(property.Connection == this)
+        {
+            property.Connection = null;
+        }
+        
+        Disconnected?.Invoke(property, this);
+    }
+}
+
+public class OutputProperty<T> : OutputProperty, INodeProperty<T>
+{
+    public new T Value
+    {
+        get => (T)base.Value;
+        set => base.Value = value;
+    }
+    
+    internal OutputProperty(Node node ,string name, T defaultValue) : base(node, name, defaultValue)
+    {
+    }
+}

+ 37 - 0
src/Nodes/Program.cs

@@ -0,0 +1,37 @@
+using Nodes;
+using Nodes.Nodes;
+using SkiaSharp;
+
+LayerNode layerNode = new LayerNode("Layer", new SKSizeI(100, 100));
+
+SKBitmap testBitmap = SKBitmap.Decode("test.png");
+using SKSurface surface = SKSurface.Create(new SKImageInfo(100, 100));
+using SKCanvas canvas = surface.Canvas;
+canvas.DrawBitmap(testBitmap, 0, 0);
+
+layerNode.LayerImage.Canvas.DrawSurface(surface, 0, 0);
+
+LayerNode layerNode2 = new LayerNode("Layer2", new SKSizeI(100, 100));
+
+SKBitmap testBitmap2 = SKBitmap.Decode("test2.png");
+using SKSurface surface2 = SKSurface.Create(new SKImageInfo(100, 100));
+using SKCanvas canvas2 = surface2.Canvas;
+canvas2.DrawBitmap(testBitmap2, 0, 0);
+
+layerNode2.LayerImage.Canvas.DrawSurface(surface2, 0, 0);
+
+MergeNode mergeNode = new MergeNode("Merge");
+OutputNode outputNode = new OutputNode("Output");
+
+layerNode.Output.ConnectTo(mergeNode.Top);
+layerNode2.Output.ConnectTo(mergeNode.Bottom);
+mergeNode.Output.ConnectTo(outputNode.Input);
+
+NodeGraph graph = new();
+graph.AddNode(layerNode);
+graph.AddNode(layerNode2);
+graph.AddNode(mergeNode);
+graph.AddNode(outputNode);
+
+using FileStream fileStream = new("output.png", FileMode.Create);
+graph.Execute().Snapshot().Encode().SaveTo(fileStream);

BIN
src/Nodes/test.png


BIN
src/Nodes/test2.png


+ 18 - 0
src/PixiEditor.AvaloniaUI.GraphView/PixiEditor.AvaloniaUI.GraphView.csproj

@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net8.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <PackageReference Include="Avalonia" Version="$(AvaloniaVersion)" />
+      <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\PixiEditor.Zoombox\PixiEditor.Zoombox.csproj" />
+    </ItemGroup>
+
+</Project>

+ 2 - 0
src/PixiEditor.AvaloniaUI/Styles/PixiEditor.Controls.axaml

@@ -13,6 +13,8 @@
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/KeyFrame.axaml"/>
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/TimelineSlider.axaml"/>
                 <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/TimelineGroupHeader.axaml"/>
+                <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/NodeGraphView.axaml"/>
+                <MergeResourceInclude Source="avares://PixiEditor.AvaloniaUI/Styles/Templates/NodeView.axaml"/>
             </ResourceDictionary.MergedDictionaries>
         </ResourceDictionary>
     </Styles.Resources>

+ 45 - 0
src/PixiEditor.AvaloniaUI/Styles/Templates/NodeGraphView.axaml

@@ -0,0 +1,45 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                    xmlns:nodes="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes"
+                    xmlns:nodes1="clr-namespace:PixiEditor.AvaloniaUI.ViewModels.Document.Nodes">
+    <ControlTheme TargetType="nodes:NodeGraphView" x:Key="{x:Type nodes:NodeGraphView}">
+        <Setter Property="ZoomMode" Value="Move" />
+        <Setter Property="Template">
+            <ControlTemplate>
+                <Grid Background="Transparent">
+                    <ItemsControl
+                        ItemsSource="{TemplateBinding Nodes}">
+                        <ItemsControl.ItemsPanel>
+                            <ItemsPanelTemplate>
+                                <Canvas RenderTransformOrigin="0, 0">
+                                    <Canvas.RenderTransform>
+                                        <TransformGroup>
+                                            <ScaleTransform
+                                                ScaleX="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                ScaleY="{Binding Scale, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                            <TranslateTransform
+                                                X="{Binding CanvasX, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
+                                                Y="{Binding CanvasY, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}" />
+                                        </TransformGroup>
+                                    </Canvas.RenderTransform>
+                                </Canvas>
+                            </ItemsPanelTemplate>
+                        </ItemsControl.ItemsPanel>
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate>
+                                <nodes:NodeView
+                                    DataContext="{Binding}" />
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                        <ItemsControl.ItemContainerTheme>
+                            <ControlTheme TargetType="ContentPresenter">
+                                <Setter Property="Canvas.Left" Value="{Binding X}" />
+                                <Setter Property="Canvas.Top" Value="{Binding Y}" />
+                            </ControlTheme>
+                        </ItemsControl.ItemContainerTheme>
+                    </ItemsControl>
+                </Grid>
+            </ControlTemplate>
+        </Setter>
+    </ControlTheme>
+</ResourceDictionary>

+ 44 - 0
src/PixiEditor.AvaloniaUI/Styles/Templates/NodeView.axaml

@@ -0,0 +1,44 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+                    xmlns:nodes="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes"
+                    xmlns:visuals="clr-namespace:PixiEditor.AvaloniaUI.Views.Visuals">
+    <ControlTheme TargetType="nodes:NodeView" x:Key="{x:Type nodes:NodeView}">
+        <Setter Property="Background" Value="{DynamicResource ThemeControlMidBrush}" />
+        <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderMidBrush}" />
+        <Setter Property="BorderThickness" Value="1" />
+        <Setter Property="CornerRadius" Value="5" />
+        <Setter Property="Margin" Value="5" />
+        <Setter Property="Padding" Value="5" />
+        <Setter Property="Template">
+            <ControlTemplate>
+                <Border Background="{TemplateBinding Background}"
+                        BorderBrush="{TemplateBinding BorderBrush}"
+                        BorderThickness="{TemplateBinding BorderThickness}"
+                        CornerRadius="{TemplateBinding CornerRadius}"
+                        Margin="{TemplateBinding Margin}"
+                        Padding="{TemplateBinding Padding}">
+                    <Grid>
+                        <Grid.RowDefinitions>
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
+                            <RowDefinition Height="Auto" />
+                        </Grid.RowDefinitions>
+                        <TextBlock Grid.ColumnSpan="3" Grid.Row="0" Text="{TemplateBinding DisplayName}"
+                                   FontWeight="Bold" />
+                        <Grid Grid.Row="1">
+
+                        </Grid>
+                        <Border CornerRadius="{DynamicResource ControlCornerRadius}" Grid.Row="2">
+                            <visuals:SurfaceControl Width="50" Height="50" RenderOptions.BitmapInterpolationMode="None">
+                                <visuals:SurfaceControl.Background>
+                                    <ImageBrush Source="/Images/CheckerTile.png"
+                                                TileMode="Tile" DestinationRect="0, 0, 25, 25" />
+                                </visuals:SurfaceControl.Background>
+                            </visuals:SurfaceControl>
+                        </Border>
+                    </Grid>
+                </Border>
+            </ControlTemplate>
+        </Setter>
+    </ControlTheme>
+</ResourceDictionary>

+ 6 - 2
src/PixiEditor.AvaloniaUI/ViewModels/Dock/LayoutManager.cs

@@ -38,6 +38,8 @@ internal class LayoutManager
         PaletteViewerDockViewModel paletteViewerDockViewModel =
             new(mainViewModel.ColorsSubViewModel, mainViewModel.DocumentManagerSubViewModel);
         TimelineDockViewModel timelineDockViewModel = new(mainViewModel.DocumentManagerSubViewModel);
+        
+        NodeGraphDockViewModel nodeGraphDockViewModel = new();
 
         RegisterDockable(layersDockViewModel);
         RegisterDockable(colorPickerDockViewModel);
@@ -46,7 +48,8 @@ internal class LayoutManager
         RegisterDockable(swatchesDockViewModel);
         RegisterDockable(paletteViewerDockViewModel);
         RegisterDockable(timelineDockViewModel);
-
+        RegisterDockable(nodeGraphDockViewModel);
+        
         DefaultLayout = new LayoutTree
         {
             Root = new DockableTree
@@ -55,7 +58,8 @@ internal class LayoutManager
                 {
                     First = new DockableArea()
                     {
-                        Id = "DocumentArea", FallbackContent = new CreateDocumentFallbackView()
+                        Id = "DocumentArea", FallbackContent = new CreateDocumentFallbackView(),
+                        Dockables = [ DockContext.CreateDockable(nodeGraphDockViewModel) ]
                     },
                     FirstSize = 0.75,
                     SplitDirection = DockingDirection.Bottom,

+ 13 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Dock/NodeGraphDockViewModel.cs

@@ -0,0 +1,13 @@
+using PixiEditor.Extensions.Common.Localization;
+
+namespace PixiEditor.AvaloniaUI.ViewModels.Dock;
+
+internal class NodeGraphDockViewModel : DockableViewModel
+{
+    public const string TabId = "NodeGraph";
+    
+    public override string Id { get; } = TabId;
+    public override string Title => new LocalizedString("NODE_GRAPH_TITLE");
+    public override bool CanFloat => true;
+    public override bool CanClose => true;
+}

+ 26 - 0
src/PixiEditor.AvaloniaUI/ViewModels/Document/Nodes/NodeViewModel.cs

@@ -0,0 +1,26 @@
+namespace PixiEditor.AvaloniaUI.ViewModels.Document.Nodes;
+
+public class NodeViewModel : ViewModelBase
+{
+    private string name;
+    private double x;
+    private double y;
+    
+    public string Name
+    {
+        get => name;
+        set => SetProperty(ref name, value);
+    }
+    
+    public double X
+    {
+        get => x;
+        set => SetProperty(ref x, value);
+    }
+    
+    public double Y
+    {
+        get => y;
+        set => SetProperty(ref y, value);
+    }
+}

+ 9 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/NodeGraphDockView.axaml

@@ -0,0 +1,9 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:nodes="clr-namespace:PixiEditor.AvaloniaUI.Views.Nodes"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="PixiEditor.AvaloniaUI.Views.Dock.NodeGraphDockView">
+    <nodes:NodeGraphView/>
+</UserControl>

+ 14 - 0
src/PixiEditor.AvaloniaUI/Views/Dock/NodeGraphDockView.axaml.cs

@@ -0,0 +1,14 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PixiEditor.AvaloniaUI.Views.Dock;
+
+public partial class NodeGraphDockView : UserControl
+{
+    public NodeGraphDockView()
+    {
+        InitializeComponent();
+    }
+}
+

+ 25 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/NodeGraphView.cs

@@ -0,0 +1,25 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using PixiEditor.AvaloniaUI.ViewModels.Document.Nodes;
+
+namespace PixiEditor.AvaloniaUI.Views.Nodes;
+
+public class NodeGraphView : Zoombox.Zoombox
+{
+    public static readonly StyledProperty<ObservableCollection<NodeViewModel>> NodesProperty = AvaloniaProperty.Register<NodeGraphView, ObservableCollection<NodeViewModel>>(
+        nameof(Nodes), new ObservableCollection<NodeViewModel>()
+        {
+            new NodeViewModel() { Name = "Node 1", X = 100, Y = 100 },
+            new NodeViewModel() { Name = "Node 2", X = 200, Y = 200 },
+            new NodeViewModel() { Name = "Node 3", X = 300, Y = 300 }
+        });
+
+    public ObservableCollection<NodeViewModel> Nodes
+    {
+        get => GetValue(NodesProperty);
+        set => SetValue(NodesProperty, value);
+    }
+    
+    protected override Type StyleKeyOverride => typeof(NodeGraphView);
+}
+

+ 16 - 0
src/PixiEditor.AvaloniaUI/Views/Nodes/NodeView.cs

@@ -0,0 +1,16 @@
+using Avalonia;
+using Avalonia.Controls.Primitives;
+
+namespace PixiEditor.AvaloniaUI.Views.Nodes;
+
+public class NodeView : TemplatedControl
+{
+    public static readonly StyledProperty<string> DisplayNameProperty = AvaloniaProperty.Register<NodeView, string>(
+        nameof(DisplayName), "Node");
+
+    public string DisplayName
+    {
+        get => GetValue(DisplayNameProperty);
+        set => SetValue(DisplayNameProperty, value);
+    }
+}

+ 13 - 5
src/PixiEditor.AvaloniaUI/Views/Visuals/SurfaceControl.cs

@@ -72,6 +72,10 @@ internal class SurfaceControl : Control
         {
             result = Stretch.CalculateSize(availableSize, new Size(source.Size.X, source.Size.Y));
         }
+        else
+        {
+            result = Stretch.CalculateSize(availableSize, new Size(Width, Height));
+        }
 
         return result;
     }
@@ -87,21 +91,25 @@ internal class SurfaceControl : Control
             var result = Stretch.CalculateSize(finalSize, new Size(sourceSize.X, sourceSize.Y));
             return result;
         }
+        else
+        {
+            return Stretch.CalculateSize(finalSize, new Size(Width, Height));
+        }
 
         return new Size();
     }
 
     public override void Render(DrawingContext context)
     {
-        if (Surface == null || Surface.IsDisposed)
-        {
-            return;
-        }
-
         if (Background != null)
         {
             context.FillRectangle(Background, new Rect(0, 0, Bounds.Width, Bounds.Height));
         }
+        
+        if (Surface == null || Surface.IsDisposed)
+        {
+            return;
+        }
 
         var bounds = new Rect(Bounds.Size);
         var operation = new DrawSurfaceOperation(bounds, Surface, Stretch, Opacity);

+ 31 - 0
src/PixiEditor.sln

@@ -102,6 +102,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.AnimationRendere
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AnimationRendering", "AnimationRendering", "{2BA72059-FFD7-4887-AE88-269017198933}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nodes", "Nodes\Nodes.csproj", "{9D50FB5B-F835-411C-B735-45124CB65865}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|x64 = Debug|x64
@@ -1500,6 +1502,34 @@ Global
 		{CD863C88-72E3-40F4-9AAE-5696BBB4460C}.Steam|x64.Build.0 = Debug|Any CPU
 		{CD863C88-72E3-40F4-9AAE-5696BBB4460C}.Steam|ARM64.ActiveCfg = Debug|Any CPU
 		{CD863C88-72E3-40F4-9AAE-5696BBB4460C}.Steam|ARM64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Debug|x64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Debug|ARM64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevRelease|x64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevRelease|x64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevRelease|ARM64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevRelease|ARM64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevSteam|x64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevSteam|x64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevSteam|ARM64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.DevSteam|ARM64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX Debug|ARM64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX Debug|ARM64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX|x64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX|ARM64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.MSIX|ARM64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Release|x64.ActiveCfg = Release|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Release|x64.Build.0 = Release|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Release|ARM64.ActiveCfg = Release|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Release|ARM64.Build.0 = Release|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Steam|x64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Steam|x64.Build.0 = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Steam|ARM64.ActiveCfg = Debug|Any CPU
+		{9D50FB5B-F835-411C-B735-45124CB65865}.Steam|ARM64.Build.0 = Debug|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -1546,6 +1576,7 @@ Global
 		{2BA72059-FFD7-4887-AE88-269017198933} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
 		{9B552A44-9587-4410-8673-254B31E2E4F7} = {2BA72059-FFD7-4887-AE88-269017198933}
 		{CD863C88-72E3-40F4-9AAE-5696BBB4460C} = {2BA72059-FFD7-4887-AE88-269017198933}
+		{9D50FB5B-F835-411C-B735-45124CB65865} = {5AFBF881-C054-4CE4-8159-8D4017FFD27A}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {D04B4AB0-CA33-42FD-A909-79966F9255C5}