Browse Source

Merge branch 'master' into webp

CPK 5 months ago
parent
commit
3fe55db49d
100 changed files with 2963 additions and 360 deletions
  1. 39 0
      .env-dev/run-build-bin.nix
  2. 35 0
      .env-dev/run-rider.nix
  3. 34 0
      .env-dev/shell.nix
  4. 1 0
      .gitignore
  5. 12 3
      src/ChunkyImageLib/Operations/ImageOperation.cs
  6. 1 1
      src/Directory.Build.props
  7. 1 1
      src/Drawie
  8. 1 1
      src/PixiDocks
  9. 12 0
      src/PixiEditor.AnimationRenderer.FFmpeg/PixiEditor.AnimationRenderer.FFmpeg.csproj
  10. BIN
      src/PixiEditor.AnimationRenderer.FFmpeg/ThirdParty/Linux/ffmpeg/ffmpeg
  11. 4 1
      src/PixiEditor.ChangeableDocument.Gen/Helpers.cs
  12. 3 1
      src/PixiEditor.ChangeableDocument.Gen/MethodInfo.cs
  13. 6 46
      src/PixiEditor.ChangeableDocument.Gen/PixiEditor.ChangeableDocument.Gen.csproj
  14. 3 1
      src/PixiEditor.ChangeableDocument.Gen/UpdateableChangeActionGenerator.cs
  15. 22 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/NodeInputsChanged_ChangeInfo.cs
  16. 4 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/PropertyValueUpdated_ChangeInfo.cs
  17. 15 0
      src/PixiEditor.ChangeableDocument/Changeables/Animations/KeyFrameData.cs
  18. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Document.cs
  19. 8 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/ColorSpaces/ColorSpaceType.cs
  20. 6 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/FuncInputProperty.cs
  21. 103 19
      src/PixiEditor.ChangeableDocument/Changeables/Graph/InputProperty.cs
  22. 4 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/INodeProperty.cs
  23. 2 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/IReadOnlyNode.cs
  24. 2 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/IReadOnlyNodeGraph.cs
  25. 13 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/Shapes/IReadOnlyTextData.cs
  26. 11 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/NodeGraph.cs
  27. 10 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CacheTriggerFlags.cs
  28. 3 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CombineSeparate/SeparateChannelsNode.cs
  29. 5 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs
  30. 28 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/DocumentInfoNode.cs
  31. 134 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Effects/OutlineNode.cs
  32. 26 8
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ApplyFilterNode.cs
  33. 198 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ColorAdjustmentsFilterNode.cs
  34. 2 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ColorMatrixFilterNode.cs
  35. 7 5
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/FilterNode.cs
  36. 11 10
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/GrayscaleNode.cs
  37. 37 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/InvertFilterNode.cs
  38. 3 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/KernelFilterNode.cs
  39. 49 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/SepiaFilterNode.cs
  40. 30 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ShadowNode.cs
  41. 51 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Image/MaskNode.cs
  42. 41 9
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ImageLayerNode.cs
  43. 37 11
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/LayerNode.cs
  44. 51 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/Matrix3X3BaseNode.cs
  45. 6 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/MatrixNode.cs
  46. 26 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/OffsetNode.cs
  47. 43 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/RotateNode.cs
  48. 28 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/ScaleNode.cs
  49. 26 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/SkewNode.cs
  50. 50 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/TransformNode.cs
  51. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/MergeNode.cs
  52. 57 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Node.cs
  53. 5 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Painter.cs
  54. 31 3
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs
  55. 329 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs
  56. 2 7
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/EllipseVectorData.cs
  57. 5 7
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/LineVectorData.cs
  58. 16 7
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/PathVectorData.cs
  59. 6 9
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/PointsVectorData.cs
  60. 2 7
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/RectangleVectorData.cs
  61. 37 9
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/ShapeVectorData.cs
  62. 235 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs
  63. 6 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RasterizeShapeNode.cs
  64. 2 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RemoveClosePointsNode.cs
  65. 71 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextNode.cs
  66. 51 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextOnPathNode.cs
  67. 28 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/StructureNode.cs
  68. 83 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/TileNode.cs
  69. 13 2
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs
  70. 11 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/OutputProperty.cs
  71. 74 11
      src/PixiEditor.ChangeableDocument/Changeables/Graph/PropertyValidator.cs
  72. 4 2
      src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyKeyFrameData.cs
  73. 81 7
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/UpdateProperty_Change.cs
  74. 46 0
      src/PixiEditor.ChangeableDocument/Changes/Structure/SetLowDpiRendering_Change.cs
  75. 6 0
      src/PixiEditor.ChangeableDocument/Changes/Vectors/ConvertToCurve_Change.cs
  76. 11 10
      src/PixiEditor.ChangeableDocument/Changes/Vectors/SetShapeGeometry_UpdateableChange.cs
  77. 14 8
      src/PixiEditor.ChangeableDocument/DocumentChangeTracker.cs
  78. 0 45
      src/PixiEditor.Desktop/Info.plist
  79. BIN
      src/PixiEditor.Desktop/PixiEditor.icns
  80. 3 1
      src/PixiEditor.Desktop/Program.cs
  81. 1 1
      src/PixiEditor.Extensions.CommonApi/PixiEditor.Extensions.CommonApi.csproj
  82. BIN
      src/PixiEditor.Extensions.Sdk/build/PixiEditor.Api.CGlueMSBuild.dll
  83. BIN
      src/PixiEditor.Extensions.Sdk/build/PixiEditor.Extensions.MSPackageBuilder.dll
  84. 1 0
      src/PixiEditor.Extensions/UI/Overlays/OverlayPointerArgs.cs
  85. 1 1
      src/PixiEditor.Gen/PixiEditor.Gen.csproj
  86. 84 0
      src/PixiEditor.Linux/LinuxInputKeys.cs
  87. 30 6
      src/PixiEditor.Linux/LinuxOperatingSystem.cs
  88. 56 0
      src/PixiEditor.Linux/LinuxProcessUtility.cs
  89. 4 1
      src/PixiEditor.SVG/Elements/SvgGroup.cs
  90. 6 1
      src/PixiEditor.SVG/Elements/SvgPrimitive.cs
  91. 51 0
      src/PixiEditor.SVG/Elements/SvgText.cs
  92. 8 0
      src/PixiEditor.SVG/Enums/SvgFontStyle.cs
  93. 9 0
      src/PixiEditor.SVG/Enums/SvgFontWeight.cs
  94. 1 0
      src/PixiEditor.SVG/Features/IFillable.cs
  95. 8 0
      src/PixiEditor.SVG/Features/IOpacity.cs
  96. 154 32
      src/PixiEditor.SVG/StyleContext.cs
  97. 16 13
      src/PixiEditor.SVG/SvgDocument.cs
  98. 24 23
      src/PixiEditor.SVG/SvgElement.cs
  99. 14 12
      src/PixiEditor.SVG/SvgParser.cs
  100. 20 3
      src/PixiEditor.SVG/SvgProperty.cs

+ 39 - 0
.env-dev/run-build-bin.nix

@@ -0,0 +1,39 @@
+{ pkgs ? import <nixpkgs> {} }:
+
+
+(pkgs.buildFHSEnv {
+  name = "pixieditor-env";
+  targetPkgs = pkgs: (with pkgs; [
+    dotnet-sdk
+    avalonia
+    fontconfig
+    alsa-lib
+    glew
+    udev
+    gnumake 
+    vulkan-headers
+    vulkan-loader
+    vulkan-validation-layers
+    vulkan-tools
+    vulkan-tools-lunarg
+    powershell
+  ]) ++ (with pkgs.xorg; [
+   libX11
+    libICE
+    libSM
+    libXi
+    libXcursor
+    libXext
+    libXrandr  ]);
+
+  multiPkgs = pkgs: (with pkgs; [
+   udev
+   alsa-lib
+  ]);
+
+  runScript = "nohup ./PixiEditor &";
+}).env
+
+
+
+

+ 35 - 0
.env-dev/run-rider.nix

@@ -0,0 +1,35 @@
+{ pkgs ? import <nixpkgs> {} }:
+
+
+(pkgs.buildFHSEnv {
+  name = "rider-env";
+  targetPkgs = pkgs: (with pkgs; [
+    dotnet-sdk
+    avalonia
+    fontconfig
+    alsa-lib
+    glew
+    udev
+    gnumake 
+    vulkan-headers
+    vulkan-loader
+    vulkan-validation-layers
+    vulkan-tools
+    vulkan-tools-lunarg
+    powershell
+  ]) ++ (with pkgs.xorg; [
+   libX11
+    libICE
+    libSM
+    libXi
+    libXcursor
+    libXext
+    libXrandr  ]);
+
+  multiPkgs = pkgs: (with pkgs; [
+   udev
+   alsa-lib
+  ]);
+
+  runScript = "nohup rider &";
+}).env

+ 34 - 0
.env-dev/shell.nix

@@ -0,0 +1,34 @@
+{ pkgs ? import <nixpkgs> { } }:
+
+with pkgs;
+let
+
+dotnet = dotnet-sdk; 
+
+in mkShell {
+  name = "avalonia-env";
+  packages = (with pkgs; [
+    dotnet
+    avalonia
+    fontconfig
+    alsa-lib
+    glew
+    udev
+    gnumake 
+    vulkan-headers
+    vulkan-loader
+    vulkan-validation-layers
+    vulkan-tools
+    vulkan-tools-lunarg
+    powershell
+  ]) ++ (with pkgs.xorg; [
+   libX11
+    libICE
+    libSM
+    libXi
+    libXcursor
+    libXext
+    libXrandr  ]);
+
+    DOTNET_ROOT = "${dotnet}";
+}

+ 1 - 0
.gitignore

@@ -340,3 +340,4 @@ GitIgnore
 
 Cache/
 .DS_Store
+nohup.out

+ 12 - 3
src/ChunkyImageLib/Operations/ImageOperation.cs

@@ -87,15 +87,20 @@ internal class ImageOperation : IMirroredDrawOperation
         var scaleTrans = Matrix3X3.CreateScaleTranslation(scaleMult, scaleMult, (float)trans.X * scaleMult, (float)trans.Y * scaleMult);
         var finalMatrix = Matrix3X3.Concat(scaleTrans, transformMatrix);
 
+        using var snapshot = toPaint.DrawingSurface.Snapshot();
+        ShapeCorners chunkCorners = new ShapeCorners(new RectD(VecD.Zero, targetChunk.PixelSize));
+        RectD rect = chunkCorners.WithMatrix(finalMatrix.Invert()).AABBBounds;
+
         targetChunk.Surface.DrawingSurface.Canvas.Save();
         targetChunk.Surface.DrawingSurface.Canvas.SetMatrix(finalMatrix);
-        targetChunk.Surface.DrawingSurface.Canvas.DrawSurface(toPaint.DrawingSurface, 0, 0, customPaint);
+        targetChunk.Surface.DrawingSurface.Canvas.DrawImage(snapshot, rect, rect, customPaint);
         targetChunk.Surface.DrawingSurface.Canvas.Restore();
     }
 
     public AffectedArea FindAffectedArea(VecI imageSize)
     {
-        return new AffectedArea(OperationHelper.FindChunksTouchingQuadrilateral(corners, ChunkPool.FullChunkSize), (RectI)corners.AABBBounds.RoundOutwards());
+        return new AffectedArea(OperationHelper.FindChunksTouchingQuadrilateral(corners, ChunkPool.FullChunkSize),
+            (RectI)corners.AABBBounds.RoundOutwards());
     }
 
     public void Dispose()
@@ -110,18 +115,22 @@ internal class ImageOperation : IMirroredDrawOperation
         if (verAxisX is not null && horAxisY is not null)
         {
             return new ImageOperation
-                (corners.AsMirroredAcrossVerAxis((double)verAxisX).AsMirroredAcrossHorAxis((double)horAxisY), toPaint, customPaint, imageWasCopied);
+            (corners.AsMirroredAcrossVerAxis((double)verAxisX).AsMirroredAcrossHorAxis((double)horAxisY), toPaint,
+                customPaint, imageWasCopied);
         }
+
         if (verAxisX is not null)
         {
             return new ImageOperation
                 (corners.AsMirroredAcrossVerAxis((double)verAxisX), toPaint, customPaint, imageWasCopied);
         }
+
         if (horAxisY is not null)
         {
             return new ImageOperation
                 (corners.AsMirroredAcrossHorAxis((double)horAxisY), toPaint, customPaint, imageWasCopied);
         }
+
         return new ImageOperation(corners, toPaint, customPaint, imageWasCopied);
     }
 }

+ 1 - 1
src/Directory.Build.props

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

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit 77762ab280a996c1536d2b22f1a4016e7a6e218f
+Subproject commit 135600d52af3806ab9bf654d783594683bc756e4

+ 1 - 1
src/PixiDocks

@@ -1 +1 @@
-Subproject commit 47107d7dc284e04ed92e4c470a6ed2f972e5d9cd
+Subproject commit 261d8e70227a68d6bc76883af4d1f76b2e6f467d

+ 12 - 0
src/PixiEditor.AnimationRenderer.FFmpeg/PixiEditor.AnimationRenderer.FFmpeg.csproj

@@ -42,5 +42,17 @@
       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
     </Content>
   </ItemGroup>
+  
+  <ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
+    <Content Include="ThirdParty/Linux/ffmpeg/**">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+  </ItemGroup>
+  
+  <ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-arm64'">
+    <Content Include="ThirdParty/Linux/ffmpeg/**">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+  </ItemGroup>
 
 </Project>

BIN
src/PixiEditor.AnimationRenderer.FFmpeg/ThirdParty/Linux/ffmpeg/ffmpeg


+ 4 - 1
src/PixiEditor.ChangeableDocument.Gen/Helpers.cs

@@ -1,4 +1,7 @@
-using System.Text;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
 using Microsoft.CodeAnalysis;
 using Microsoft.CodeAnalysis.CSharp.Syntax;
 

+ 3 - 1
src/PixiEditor.ChangeableDocument.Gen/MethodInfo.cs

@@ -1,4 +1,6 @@
-namespace PixiEditor.ChangeableDocument.Gen
+using System.Collections.Generic;
+
+namespace PixiEditor.ChangeableDocument.Gen
 {
     internal record struct MethodInfo(string Name, List<TypeWithName> Arguments, NamespacedType ContainingClass);
 }

+ 6 - 46
src/PixiEditor.ChangeableDocument.Gen/PixiEditor.ChangeableDocument.Gen.csproj

@@ -1,57 +1,17 @@
 <Project Sdk="Microsoft.NET.Sdk">
-
   <PropertyGroup>
     <TargetFramework>netstandard2.0</TargetFramework>
-    <IncludeBuildOutput>false</IncludeBuildOutput>
+    <LangVersion>latest</LangVersion>
     <Nullable>enable</Nullable>
-    <ImplicitUsings>true</ImplicitUsings>
-    <LangVersion>Latest</LangVersion>
-    <Configurations>Debug;Release;Steam;DevRelease</Configurations>
-    <Platforms>AnyCPU</Platforms>
-  </PropertyGroup>
-
-  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Steam|AnyCPU'">
-    <Optimize>True</Optimize>
-  </PropertyGroup>
-
-  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Steam|x64'">
-    <Optimize>True</Optimize>
-    <PlatformTarget>AnyCPU</PlatformTarget>
-  </PropertyGroup>
-
-  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Steam|x86'">
-    <Optimize>True</Optimize>
-    <PlatformTarget>AnyCPU</PlatformTarget>
-  </PropertyGroup>
-
-  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x64' ">
-    <PlatformTarget>AnyCPU</PlatformTarget>
-  </PropertyGroup>
-
-  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' ">
-    <PlatformTarget>AnyCPU</PlatformTarget>
-  </PropertyGroup>
-
-  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'DevRelease|x64' ">
-    <PlatformTarget>AnyCPU</PlatformTarget>
-  </PropertyGroup>
-
-  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'DevRelease|x86' ">
-    <PlatformTarget>AnyCPU</PlatformTarget>
-  </PropertyGroup>
-
-  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x64' ">
-    <PlatformTarget>AnyCPU</PlatformTarget>
-  </PropertyGroup>
-
-  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' ">
-    <PlatformTarget>AnyCPU</PlatformTarget>
+    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" PrivateAssets="all" />
+    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
   </ItemGroup>
+
   <ItemGroup>
     <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
   </ItemGroup>
-</Project>
+</Project>

+ 3 - 1
src/PixiEditor.ChangeableDocument.Gen/UpdateableChangeActionGenerator.cs

@@ -1,4 +1,6 @@
-using Microsoft.CodeAnalysis;
+using System.Linq;
+using System.Threading;
+using Microsoft.CodeAnalysis;
 using Microsoft.CodeAnalysis.CSharp.Syntax;
 using Microsoft.CodeAnalysis.Text;
 

+ 22 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/NodeInputsChanged_ChangeInfo.cs

@@ -0,0 +1,22 @@
+using System.Collections.Immutable;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
+
+public record NodeInputsChanged_ChangeInfo(Guid NodeId, ImmutableArray<NodePropertyInfo> Inputs) : IChangeInfo
+{
+    public static NodeInputsChanged_ChangeInfo FromNode(Node node)
+    {
+        var infos = CreateNode_ChangeInfo.CreatePropertyInfos(node.InputProperties, true, node.Id);
+        return new NodeInputsChanged_ChangeInfo(node.Id, infos);
+    }
+}
+
+public record NodeOutputsChanged_ChangeInfo(Guid NodeId, ImmutableArray<NodePropertyInfo> Outputs) : IChangeInfo
+{
+    public static NodeOutputsChanged_ChangeInfo FromNode(Node node)
+    {
+        var infos = CreateNode_ChangeInfo.CreatePropertyInfos(node.OutputProperties, false, node.Id);
+        return new NodeOutputsChanged_ChangeInfo(node.Id, infos);
+    }
+}

+ 4 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/NodeGraph/PropertyValueUpdated_ChangeInfo.cs

@@ -1,3 +1,6 @@
 namespace PixiEditor.ChangeableDocument.ChangeInfos.NodeGraph;
 
-public record PropertyValueUpdated_ChangeInfo(Guid NodeId, string Property, object Value) : IChangeInfo;
+public record PropertyValueUpdated_ChangeInfo(Guid NodeId, string Property, object Value) : IChangeInfo
+{
+    public string? Errors { get; set; }
+}

+ 15 - 0
src/PixiEditor.ChangeableDocument/Changeables/Animations/KeyFrameData.cs

@@ -72,4 +72,19 @@ public class KeyFrameData : IDisposable, IReadOnlyKeyFrameData
             IsVisible = IsVisible
         };
     }
+
+    public int GetCacheHash()
+    {
+        HashCode hash = new();
+        hash.Add(StartFrame);
+        hash.Add(Duration);
+        hash.Add(KeyFrameGuid);
+        hash.Add(AffectedElement);
+        if (Data != null)
+        {
+            hash.Add(Data is ICacheable cacheable ? cacheable.GetCacheHash() : Data.GetHashCode());
+        }
+
+        return hash.ToHashCode();
+    }
 }

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

@@ -364,7 +364,7 @@ internal class Document : IChangeable, IReadOnlyDocument
     /// <param name="guid">The <see cref="StructureNode.Id"/> of the member</param>
     public List<StructureNode> FindMemberPath(Guid guid)
     {
-        if (NodeGraph.OutputNode == null) return [];
+        //if (NodeGraph.OutputNode == null) return [];
 
         var list = new List<StructureNode>();
         var targetNode = FindNode(guid);

+ 8 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/ColorSpaces/ColorSpaceType.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.ColorSpaces;
+
+public enum ColorSpaceType
+{
+    Inherit,
+    Srgb,
+    LinearSrgb,
+}

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

@@ -20,6 +20,11 @@ public class FuncInputProperty<T> : InputProperty<Func<FuncContext, T>>, IFuncIn
         NonOverridenValue = _ => constantNonOverrideValue;
     }
 
+    protected override void NonOverridenValueSet(Func<FuncContext, T> value)
+    {
+        constantNonOverrideValue = value(FuncContext.NoContext);
+    }
+
     protected internal override object FuncFactory(object toReturn)
     {
         Func<FuncContext, T> func = _ =>
@@ -133,6 +138,7 @@ public class FuncInputProperty<T> : InputProperty<Func<FuncContext, T>>, IFuncIn
         if (constantNonOverrideValue is ShaderExpressionVariable shaderExpressionVariable)
         {
             shaderExpressionVariable.SetConstantValue(value, ConversionTable.Convert);
+            NonOverridenValue = _ => constantNonOverrideValue;
             return;
         }
 

+ 103 - 19
src/PixiEditor.ChangeableDocument/Changeables/Graph/InputProperty.cs

@@ -16,6 +16,7 @@ public class InputProperty : IInputProperty
     private IOutputProperty? connection;
 
     public event Action ConnectionChanged;
+    public event Action<object> NonOverridenValueChanged;
 
     public string InternalPropertyName { get; }
     public string DisplayName { get; }
@@ -31,6 +32,11 @@ public class InputProperty : IInputProperty
 
             var connectionValue = Connection.Value;
 
+            if (connectionValue is null)
+            {
+                return null;
+            }
+
             if (!ValueType.IsAssignableTo(typeof(Delegate)) && connectionValue is Delegate connectionField)
             {
                 return connectionField.DynamicInvoke(FuncContext.NoContext);
@@ -41,7 +47,28 @@ public class InputProperty : IInputProperty
                 return FuncFactory(connectionValue);
             }
 
-            return connectionValue;
+            if (connectionValue.GetType().IsAssignableTo(ValueType))
+            {
+                return connectionValue;
+            }
+
+            if (connectionValue is Delegate func && ValueType.IsAssignableTo(typeof(Delegate)))
+            {
+                return FuncFactoryDelegate(func);
+            }
+
+            object target = connectionValue;
+            if (target is ShaderExpressionVariable shaderExpression)
+            {
+                target = shaderExpression.GetConstant();
+            }
+
+            if (!ConversionTable.TryConvert(target, ValueType, out object result))
+            {
+                return null;
+            }
+
+            return Validator.GetClosestValidValue(result);
         }
     }
 
@@ -50,7 +77,25 @@ public class InputProperty : IInputProperty
         get => _internalValue;
         set
         {
-            _internalValue = value;
+            object evaluatedValue = value;
+            if (value != null)
+            {
+                if (!value.GetType().IsAssignableTo(ValueType))
+                {
+                    if (!ConversionTable.TryConvert(value, ValueType, out object result))
+                    {
+                        evaluatedValue = null;
+                    }
+                    else
+                    {
+                        evaluatedValue = result;
+                    }
+                }
+            }
+
+            _internalValue = evaluatedValue;
+            NonOverridenValueChanged?.Invoke(evaluatedValue);
+            NonOverridenValueSet(evaluatedValue);
         }
     }
 
@@ -60,7 +105,7 @@ public class InputProperty : IInputProperty
         {
             if (validator is null)
             {
-                validator = new PropertyValidator();
+                validator = new PropertyValidator(this);
             }
 
             return validator;
@@ -91,17 +136,17 @@ public class InputProperty : IInputProperty
     {
         get
         {
-            if(Connection == null && lastConnectionHash != -1)
+            if (Connection == null && lastConnectionHash != -1)
             {
                 return true;
             }
-            
-            if(Connection != null && lastConnectionHash != Connection.GetHashCode())
+
+            if (Connection != null && lastConnectionHash != Connection.GetHashCode())
             {
                 lastConnectionHash = Connection.GetHashCode();
                 return true;
             }
-            
+
             if (Value is ICacheable cacheable)
             {
                 return cacheable.GetCacheHash() != _lastExecuteHash;
@@ -121,6 +166,10 @@ public class InputProperty : IInputProperty
         }
     }
 
+    protected virtual void NonOverridenValueSet(object value)
+    {
+    }
+
     internal virtual void UpdateCache()
     {
         if (Value is null)
@@ -135,7 +184,7 @@ public class InputProperty : IInputProperty
         {
             _lastExecuteHash = Value.GetHashCode();
         }
-        
+
         lastConnectionHash = Connection?.GetHashCode() ?? -1;
     }
 
@@ -162,6 +211,24 @@ public class InputProperty : IInputProperty
         Node = node;
         ValueType = valueType;
     }
+
+    public int GetCacheHash()
+    {
+        HashCode hash = new();
+        hash.Add(InternalPropertyName);
+        hash.Add(ValueType);
+        if(Value is ICacheable cacheable)
+        {
+            hash.Add(cacheable.GetCacheHash());
+        }
+        else
+        {
+            hash.Add(Value?.GetHashCode() ?? 0);
+        }
+
+        hash.Add(Connection?.GetCacheHash() ?? 0);
+        return hash.ToHashCode();
+    }
 }
 
 public class InputProperty<T> : InputProperty, IInputProperty<T>
@@ -176,30 +243,41 @@ public class InputProperty<T> : InputProperty, IInputProperty<T>
             if (value is T tValue)
                 return tValue;
 
-            if (value is Delegate func && typeof(T).IsAssignableTo(typeof(Delegate)))
-            {
-                return (T)FuncFactoryDelegate(func);
-            }
-
-            object target = value;
             if (value is ShaderExpressionVariable shaderExpression)
             {
-                target = shaderExpression.GetConstant();
+                value = shaderExpression.GetConstant();
             }
 
-            if (!ConversionTable.TryConvert(target, typeof(T), out object result))
+            var validated = Validator.GetClosestValidValue(value);
+
+            if (!ConversionTable.TryConvert(validated, ValueType, out object result))
             {
-                return default;
+                return default(T);
             }
 
-            return (T)Validator.GetClosestValidValue(result);
+            return (T)result;
         }
     }
 
     public T NonOverridenValue
     {
         get => (T)(base.NonOverridenValue ?? default(T));
-        set => base.NonOverridenValue = value;
+        set
+        {
+            base.NonOverridenValue = value;
+        }
+    }
+
+    protected override void NonOverridenValueSet(object value)
+    {
+        if (value is T casted)
+        {
+            NonOverridenValueSet(casted);
+        }
+    }
+
+    protected virtual void NonOverridenValueSet(T value)
+    {
     }
 
     internal InputProperty(Node node, string internalName, string displayName, T defaultValue) : base(node,
@@ -212,4 +290,10 @@ public class InputProperty<T> : InputProperty, IInputProperty<T>
         rules(Validator);
         return this;
     }
+
+    public InputProperty<T> NonOverridenChanged(Action<T> callback)
+    {
+        NonOverridenValueChanged += value => callback((T)value);
+        return this;
+    }
 }

+ 4 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/INodeProperty.cs

@@ -1,6 +1,8 @@
-namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.Common;
 
-public interface INodeProperty
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+
+public interface INodeProperty : ICacheable
 {
     public string InternalPropertyName { get; }
     public string DisplayName { get; }

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

@@ -3,10 +3,11 @@ using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core;
 using Drawie.Numerics;
+using PixiEditor.Common;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 
-public interface IReadOnlyNode
+public interface IReadOnlyNode : ICacheable
 {
     public Guid Id { get; }
     public IReadOnlyList<IInputProperty> InputProperties { get; }

+ 2 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/IReadOnlyNodeGraph.cs

@@ -1,9 +1,10 @@
 using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core;
+using PixiEditor.Common;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 
-public interface IReadOnlyNodeGraph
+public interface IReadOnlyNodeGraph : ICacheable
 {
     public IReadOnlyCollection<IReadOnlyNode> AllNodes { get; }
     public IReadOnlyNode OutputNode { get; }

+ 13 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/Shapes/IReadOnlyTextData.cs

@@ -0,0 +1,13 @@
+using Drawie.Backend.Core.Text;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces.Shapes;
+
+public interface IReadOnlyTextData
+{
+    public string Text { get; }
+    public VecD Position { get; }
+    public Font ConstructFont();
+    public double Spacing { get; }
+    public double MaxWidth { get; }
+}

+ 11 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/NodeGraph.cs

@@ -116,4 +116,15 @@ public class NodeGraph : IReadOnlyNodeGraph, IDisposable
     {
         cachedExecutionList = null;
     }
+
+    public int GetCacheHash()
+    {
+        HashCode hash = new();
+        foreach (var node in Nodes)
+        {
+            hash.Add(node.GetCacheHash());
+        }
+
+        return hash.ToHashCode();
+    }
 }

+ 10 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CacheTriggerFlags.cs

@@ -0,0 +1,10 @@
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+[Flags]
+public enum CacheTriggerFlags
+{
+    None = 0,
+    Inputs = 1,
+    Timeline = 2,
+    All = Inputs | Timeline
+}

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

@@ -69,6 +69,9 @@ public class SeparateChannelsNode : Node, IRenderInput, IPreviewRenderable
 
     private void Paint(RenderContext context, DrawingSurface drawingSurface, ColorFilter colorFilter, ColorFilter grayscaleFilter)
     {
+        if(Image.Value == null)
+            return;
+        
         bool grayscale = Grayscale.Value;
         
         ColorFilter filter = grayscale ? grayscaleFilter : colorFilter; 

+ 5 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs

@@ -20,6 +20,8 @@ public class CreateImageNode : Node, IPreviewRenderable
 
     public RenderInputProperty Content { get; }
 
+    public InputProperty<VecD> ContentOffset { get; }
+
     public RenderOutputProperty RenderOutput { get; }
 
     private TextureCache textureCache = new();
@@ -30,6 +32,7 @@ public class CreateImageNode : Node, IPreviewRenderable
         Size = CreateInput(nameof(Size), "SIZE", new VecI(32, 32)).WithRules(v => v.Min(VecI.One));
         Fill = CreateInput(nameof(Fill), "FILL", Colors.Transparent);
         Content = CreateRenderInput(nameof(Content), "CONTENT");
+        ContentOffset = CreateInput(nameof(ContentOffset), "CONTENT_OFFSET", VecD.Zero);
         RenderOutput = CreateRenderOutput("RenderOutput", "RENDER_OUTPUT", () => new Painter(OnPaint));
     }
 
@@ -58,6 +61,8 @@ public class CreateImageNode : Node, IPreviewRenderable
         RenderContext ctx = new RenderContext(surface.DrawingSurface, context.FrameTime, context.ChunkResolution,
             context.DocumentSize, context.ProcessingColorSpace);
 
+        surface.DrawingSurface.Canvas.Translate((float)-ContentOffset.Value.X, (float)-ContentOffset.Value.Y);
+
         Content.Value?.Paint(ctx, surface.DrawingSurface);
 
         surface.DrawingSurface.Canvas.RestoreToCount(saved);

+ 28 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/DocumentInfoNode.cs

@@ -0,0 +1,28 @@
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+[NodeInfo("DocumentInfo")]
+public class DocumentInfoNode : Node
+{
+    public OutputProperty<VecI> Size { get; }
+    public OutputProperty<VecD> Center { get; }
+
+    public DocumentInfoNode()
+    {
+        Size = CreateOutput("Size", "SIZE", new VecI(0, 0));
+        Center = CreateOutput("Center", "CENTER", new VecD(0, 0));
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        Size.Value = context.DocumentSize;
+        Center.Value = new VecD(context.DocumentSize.X / 2.0, context.DocumentSize.Y / 2.0);
+    }
+
+    public override Node CreateCopy()
+    {
+        return new DocumentInfoNode();
+    }
+}

+ 134 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Effects/OutlineNode.cs

@@ -0,0 +1,134 @@
+using Drawie.Backend.Core;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Shaders;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Effects;
+
+[NodeInfo("Outline")]
+public class OutlineNode : RenderNode, IRenderInput
+{
+    public RenderInputProperty Background { get; }
+    public InputProperty<OutlineType> Type { get; }
+    public InputProperty<double> Thickness { get; }
+    public InputProperty<Color> Color { get; }
+
+    private Kernel simpleKernel = new Kernel(3, 3, [1, 1, 1, 1, 1, 1, 1, 1, 1]);
+    private Kernel pixelPerfectKernel = new Kernel(3, 3, [0, 1, 0, 1, -4, 1, 0, 1, 0]);
+    private Kernel gaussianKernel = new Kernel(5, 5, [
+        1, 4, 6, 4, 1,
+        4, 16, 24, 16, 4,
+        6, 24, 36, 24, 6,
+        4, 16, 24, 16, 4,
+        1, 4, 6, 4, 1
+    ]);
+
+    private Paint paint;
+    private ImageFilter filter;
+
+    private OutlineType? lastType = null;
+    private VecI lastDocumentSize;
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+
+    public OutlineNode()
+    {
+        Background = CreateRenderInput("Background", "BACKGROUND");
+        Type = CreateInput("Type", "TYPE", OutlineType.Simple);
+        Thickness = CreateInput("Thickness", "THICKNESS", 1.0)
+            .WithRules(validator => validator.Min(0.0));
+        Color = CreateInput("Color", "COLOR", Colors.Black);
+
+        paint = new Paint();
+
+        Output.FirstInChain = null;
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        base.OnExecute(context);
+        lastDocumentSize = context.DocumentSize;
+
+        Kernel finalKernel = Type.Value switch
+        {
+            OutlineType.Simple => simpleKernel,
+            OutlineType.Gaussian => gaussianKernel,
+            OutlineType.PixelPerfect => pixelPerfectKernel,
+            _ => simpleKernel
+        };
+
+        VecI offset = new VecI(finalKernel.RadiusX, finalKernel.RadiusY);
+        double gain = 1.0 / finalKernel.Sum;
+
+        filter?.Dispose();
+        filter = ImageFilter.CreateMatrixConvolution(finalKernel, (float)gain, 0, offset, TileMode.Clamp, true);
+
+        lastType = Type.Value;
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (Background.Value == null)
+        {
+            return;
+        }
+
+        if (Thickness.Value > 0)
+        {
+            paint.ImageFilter = filter;
+            paint.ColorFilter = ColorFilter.CreateBlendMode(Color.Value, BlendMode.SrcIn);
+
+            using Texture temp = Texture.ForProcessing(surface, context.ProcessingColorSpace);
+            int saved = temp.DrawingSurface.Canvas.SaveLayer(paint);
+
+            Background.Value.Paint(context, temp.DrawingSurface);
+
+            temp.DrawingSurface.Canvas.RestoreToCount(saved);
+
+            for (int i = 1; i < (int)Thickness.Value; i++)
+            {
+                saved = temp.DrawingSurface.Canvas.SaveLayer(paint);
+
+                temp.DrawingSurface.Canvas.DrawSurface(temp.DrawingSurface, 0, 0);
+
+                temp.DrawingSurface.Canvas.RestoreToCount(saved);
+            }
+
+            saved = surface.Canvas.Save();
+            surface.Canvas.SetMatrix(Matrix3X3.Identity);
+            surface.Canvas.DrawSurface(temp.DrawingSurface, 0, 0);
+
+            surface.Canvas.RestoreToCount(saved);
+        }
+
+        Background.Value.Paint(context, surface);
+    }
+
+    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    {
+        return new RectD(0, 0, lastDocumentSize.X, lastDocumentSize.Y);
+    }
+
+    public override bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
+    {
+        OnPaint(context, renderOn);
+        return true;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new OutlineNode();
+    }
+}
+
+public enum OutlineType
+{
+    Simple,
+    Gaussian,
+    PixelPerfect,
+}

+ 26 - 8
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ApplyFilterNode.cs

@@ -2,7 +2,9 @@
 using PixiEditor.ChangeableDocument.Helpers;
 using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core;
+using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
 
@@ -21,18 +23,37 @@ public class ApplyFilterNode : RenderNode, IRenderInput
         Background = CreateRenderInput("Input", "IMAGE");
         Filter = CreateInput<Filter>("Filter", "FILTER", null);
         Output.FirstInChain = null;
+        AllowHighDpiRendering = true;
     }
 
     protected override void OnPaint(RenderContext context, DrawingSurface surface)
     {
-        if (Background.Value == null)
+        if (_paint == null)
             return;
 
         _paint.SetFilters(Filter.Value);
-        int layer = surface.Canvas.SaveLayer(_paint);
-        Background.Value.Paint(context, surface);
 
-        surface.Canvas.RestoreToCount(layer);
+        if (!context.ProcessingColorSpace.IsSrgb)
+        {
+            var target = Texture.ForProcessing(surface, ColorSpace.CreateSrgb());
+
+            int saved = surface.Canvas.Save();
+            surface.Canvas.SetMatrix(Matrix3X3.Identity);
+
+            target.DrawingSurface.Canvas.SaveLayer(_paint);
+            Background.Value?.Paint(context, target.DrawingSurface);
+            target.DrawingSurface.Canvas.Restore();
+
+            surface.Canvas.DrawSurface(target.DrawingSurface, 0, 0);
+            surface.Canvas.RestoreToCount(saved);
+            target.Dispose();
+        }
+        else
+        {
+            int layer = surface.Canvas.SaveLayer(_paint);
+            Background.Value?.Paint(context, surface);
+            surface.Canvas.RestoreToCount(layer);
+        }
     }
 
     public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
@@ -43,11 +64,8 @@ public class ApplyFilterNode : RenderNode, IRenderInput
     public override bool RenderPreview(DrawingSurface renderOn, RenderContext context,
         string elementToRenderName)
     {
-        if (Background.Value == null)
-            return false;
-
         int layer = renderOn.Canvas.SaveLayer(_paint);
-        Background.Value.Paint(context, renderOn);
+        Background.Value?.Paint(context, renderOn);
         renderOn.Canvas.RestoreToCount(layer);
 
         return true;

+ 198 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ColorAdjustmentsFilterNode.cs

@@ -0,0 +1,198 @@
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
+
+[NodeInfo("ColorAdjustmentsFilter")]
+public class ColorAdjustmentsFilterNode : FilterNode
+{
+    public InputProperty<bool> AdjustBrightness { get; }
+    public InputProperty<double> BrightnessValue { get; }
+
+    public InputProperty<bool> AdjustContrast { get; }
+    public InputProperty<double> ContrastValue { get; }
+
+    public InputProperty<bool> AdjustTemperature { get; }
+    public InputProperty<double> TemperatureValue { get; }
+
+    public InputProperty<bool> AdjustTint { get; }
+    public InputProperty<double> TintValue { get; }
+
+    public InputProperty<bool> AdjustSaturation { get; }
+    public InputProperty<double> SaturationValue { get; }
+
+    public InputProperty<bool> AdjustHue { get; }
+    public InputProperty<double> HueValue { get; }
+
+    private List<ColorFilter> filters = new List<ColorFilter>();
+    private ColorFilter lastCombinedFilter;
+
+    public ColorAdjustmentsFilterNode()
+    {
+        AdjustBrightness = CreateInput("AdjustBrightness", "ADJUST_BRIGHTNESS", false);
+        BrightnessValue = CreateInput("BrightnessValue", "BRIGHTNESS_VALUE", 0.0)
+            .WithRules(rules => rules.Min(-1d).Max(1d));
+
+        AdjustContrast = CreateInput("AdjustContrast", "ADJUST_CONTRAST", false);
+        ContrastValue = CreateInput("ContrastValue", "CONTRAST_VALUE", 0.0)
+            .WithRules(rules => rules.Min(-1d).Max(1d));
+
+        AdjustTemperature = CreateInput("AdjustTemperature", "ADJUST_TEMPERATURE", false);
+        TemperatureValue = CreateInput("TemperatureValue", "TEMPERATURE_VALUE", 0.0)
+            .WithRules(rules => rules.Min(-1d).Max(1d));
+
+        AdjustTint = CreateInput("AdjustTint", "ADJUST_TINT", false);
+        TintValue = CreateInput("TintValue", "TINT_VALUE", 0.0)
+            .WithRules(rules => rules.Min(-1d).Max(1d));
+
+        AdjustSaturation = CreateInput("AdjustSaturation", "ADJUST_SATURATION", false);
+        SaturationValue = CreateInput("SaturationValue", "SATURATION_VALUE", 0.0)
+            .WithRules(rules => rules.Min(-1d).Max(1d));
+
+        AdjustHue = CreateInput("AdjustHue", "ADJUST_HUE", false);
+        HueValue = CreateInput("HueValue", "HUE_VALUE", 0.0)
+            .WithRules(rules => rules.Min(-180d).Max(180d));
+    }
+
+    protected override ColorFilter? GetColorFilter()
+    {
+        filters.ForEach(filter => filter.Dispose());
+        filters.Clear();
+
+        CreateBrightnessFilter();
+        CreateContrastFilter();
+        CreateTemperatureFilter();
+        CreateTintFilter();
+        CreateSaturationFilter();
+        CreateHueFilter();
+
+        lastCombinedFilter?.Dispose();
+        lastCombinedFilter = CombineFilters();
+        return lastCombinedFilter;
+    }
+
+    private void CreateBrightnessFilter()
+    {
+        if (AdjustBrightness.Value)
+        {
+            float brightnessValue = (float)BrightnessValue.Value;
+            ColorFilter brightnessFilter = ColorFilter.CreateColorMatrix(
+            [
+                1, 0, 0, 0, brightnessValue,
+                0, 1, 0, 0, brightnessValue,
+                0, 0, 1, 0, brightnessValue,
+                0, 0, 0, 1, 0
+            ]);
+            filters.Add(brightnessFilter);
+        }
+    }
+
+    private void CreateContrastFilter()
+    {
+        if (AdjustContrast.Value)
+        {
+            float contrastValue = (float)ContrastValue.Value;
+            ColorFilter contrastFilter =
+                ColorFilter.CreateHighContrast(false, ContrastInvertMode.InvertBrightness, contrastValue);
+            filters.Add(contrastFilter);
+        }
+    }
+
+    private void CreateTemperatureFilter()
+    {
+        if (AdjustTemperature.Value)
+        {
+            float temperatureValue = (float)TemperatureValue.Value;
+            ColorFilter temperatureFilter = ColorFilter.CreateColorMatrix(
+            [
+                1, 0, 0, 0, temperatureValue,
+                0, 1, 0, 0, 0,
+                0, 0, 1, 0, -temperatureValue,
+                0, 0, 0, 1, 0
+            ]);
+            filters.Add(temperatureFilter);
+        }
+    }
+
+    private void CreateTintFilter()
+    {
+        if (AdjustTint.Value)
+        {
+            float tintValue = (float)TintValue.Value;
+            ColorFilter tintFilter = ColorFilter.CreateColorMatrix(
+            [
+                1, 0, 0, 0, 0,
+                0, 1, 0, 0, tintValue,
+                0, 0, 1, 0, 0,
+                0, 0, 0, 1, 0
+            ]);
+            filters.Add(tintFilter);
+        }
+    }
+
+    private void CreateSaturationFilter()
+    {
+        if (AdjustSaturation.Value)
+        {
+            float saturationValue = (float)SaturationValue.Value + 1;
+            ColorFilter saturationFilter = ColorFilter.CreateColorMatrix(
+            [
+                0.213f + 0.787f * saturationValue, 0.715f - 0.715f * saturationValue, 0.072f - 0.072f * saturationValue,
+                0, 0,
+                0.213f - 0.213f * saturationValue, 0.715f + 0.285f * saturationValue, 0.072f - 0.072f * saturationValue,
+                0, 0,
+                0.213f - 0.213f * saturationValue, 0.715f - 0.715f * saturationValue, 0.072f + 0.928f * saturationValue,
+                0, 0,
+                0, 0, 0, 1, 0
+            ]);
+            filters.Add(saturationFilter);
+        }
+    }
+
+    private void CreateHueFilter()
+    {
+        if (AdjustHue.Value)
+        {
+            float value = (float)-HueValue.Value * (float)Math.PI / 180f;
+            var cosVal = (float)Math.Cos(value);
+            var sinVal = (float)Math.Sin(value);
+            float lumR = 0.213f;
+            float lumG = 0.715f;
+            float lumB = 0.072f;
+
+            ColorFilter hueFilter = ColorFilter.CreateColorMatrix(
+            [
+                lumR + cosVal * (1 - lumR) + sinVal * (-lumR), lumG + cosVal * (-lumG) + sinVal * (-lumG),
+                lumB + cosVal * (-lumB) + sinVal * (1 - lumB), 0, 0,
+                lumR + cosVal * (-lumR) + sinVal * (0.143f), lumG + cosVal * (1 - lumG) + sinVal * (0.140f),
+                lumB + cosVal * (-lumB) + sinVal * (-0.283f), 0, 0,
+                lumR + cosVal * (-lumR) + sinVal * (-(1 - lumR)), lumG + cosVal * (-lumG) + sinVal * (lumG),
+                lumB + cosVal * (1 - lumB) + sinVal * (lumB), 0, 0,
+                0, 0, 0, 1, 0,
+            ]);
+
+            filters.Add(hueFilter);
+        }
+    }
+
+    private ColorFilter? CombineFilters()
+    {
+        if (filters.Count == 0)
+        {
+            return null;
+        }
+
+        ColorFilter combinedFilter = filters[0];
+        for (int i = 1; i < filters.Count; i++)
+        {
+            combinedFilter = ColorFilter.CreateCompose(combinedFilter, filters[i]);
+        }
+
+        return combinedFilter;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new ColorAdjustmentsFilterNode();
+    }
+}

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

@@ -1,4 +1,5 @@
-using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Surfaces.ImageData;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;

+ 7 - 5
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/FilterNode.cs

@@ -1,5 +1,4 @@
 using PixiEditor.ChangeableDocument.Rendering;
-using Drawie.Backend.Core;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
@@ -7,15 +6,18 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
 public abstract class FilterNode : Node
 {
     public OutputProperty<Filter> Output { get; }
-    
+
     public InputProperty<Filter?> Input { get; }
-    
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+    protected override CacheTriggerFlags CacheTrigger => CacheTriggerFlags.Inputs;
+
     public FilterNode()
     {
         Output = CreateOutput<Filter>(nameof(Output), "FILTERS", null);
         Input = CreateInput<Filter>(nameof(Input), "PREVIOUS", null);
     }
-    
+
     protected override void OnExecute(RenderContext context)
     {
         var colorFilter = GetColorFilter();
@@ -33,6 +35,6 @@ public abstract class FilterNode : Node
     }
 
     protected virtual ColorFilter? GetColorFilter() => null;
-    
+
     protected virtual ImageFilter? GetImageFilter() => null;
 }

+ 11 - 10
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/GrayscaleNode.cs

@@ -1,4 +1,5 @@
-using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Surfaces.ImageData;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
@@ -15,26 +16,25 @@ public class GrayscaleNode : FilterNode
     
     public InputProperty<bool> Normalize { get; }
 
-    // TODO: Hide when Mode != Custom
     public InputProperty<Vec3D> CustomWeight { get; }
     
     private GrayscaleMode lastMode;
     private double lastFactor;
     private bool lastNormalize;
     private Vec3D lastCustomWeight;
-    
+
     private ColorFilter? filter;
     
     public GrayscaleNode()
     {
         Mode = CreateInput("Mode", "MODE", GrayscaleMode.Weighted);
-        // TODO: Clamp 0 - 1 in UI
-        Factor = CreateInput("Factor", "FACTOR", 1d);
+        Factor = CreateInput("Factor", "FACTOR", 1d)
+            .WithRules(rules => rules.Min(0d).Max(1d));
         Normalize = CreateInput("Normalize", "NORMALIZE", true);
         CustomWeight = CreateInput("CustomWeight", "WEIGHT_FACTOR", new Vec3D(1, 1, 1));
     }
 
-    protected override ColorFilter GetColorFilter()
+    protected override ColorFilter? GetColorFilter()
     {
         if (Mode.Value == lastMode 
             && Factor.Value == lastFactor 
@@ -48,17 +48,18 @@ public class GrayscaleNode : FilterNode
         lastFactor = Factor.Value;
         lastNormalize = Normalize.Value;
         lastCustomWeight = CustomWeight.Value;
-        
+
         filter?.Dispose();
         
-        filter = ColorFilter.CreateColorMatrix(Mode.Value switch
+        var matrix = Mode.Value switch
         {
             GrayscaleMode.Weighted => UseFactor(WeightedMatrix),
             GrayscaleMode.Average => UseFactor(AverageMatrix),
             GrayscaleMode.Custom => UseFactor(ColorMatrix.WeightedGrayscale(GetAdjustedCustomWeight()) +
                                               ColorMatrix.UseAlpha)
-        });
-        
+        };
+
+        filter = ColorFilter.CreateColorMatrix(matrix);
         return filter;
     }
 

+ 37 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/InvertFilterNode.cs

@@ -0,0 +1,37 @@
+using Drawie.Backend.Core.Surfaces.ImageData;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
+
+[NodeInfo("InvertFilter")]
+public class InvertFilterNode : FilterNode
+{
+    public InputProperty<double> Intensity { get; }
+    private ColorFilter? filter;
+    private ColorMatrix invertedMatrix;
+
+    public InvertFilterNode()
+    {
+        Intensity = CreateInput("Intensity", "INTENSITY", 1.0)
+            .WithRules(rules => rules.Min(0d).Max(1d));
+        invertedMatrix = new ColorMatrix(new float[] { -1, 0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1, 0, 1, 0, 0, 0, 1, 0 });
+
+        filter = ColorFilter.CreateColorMatrix(invertedMatrix);
+    }
+
+    protected override ColorFilter? GetColorFilter()
+    {
+        filter?.Dispose();
+
+        var lerped = ColorMatrix.Lerp(ColorMatrix.Identity, invertedMatrix, (float)Intensity.Value);
+        filter = ColorFilter.CreateColorMatrix(lerped);
+
+        return filter;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new InvertFilterNode();
+    }
+}

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

@@ -24,6 +24,7 @@ public class KernelFilterNode : FilterNode
     private TileMode lastTile;
     private double lastGain;
     private double lastBias;
+    private bool lastOnAlpha;
 
     private float[] lastKernelValues = new float[9];
 
@@ -40,13 +41,14 @@ public class KernelFilterNode : FilterNode
     {
         var kernel = Kernel.Value;
         
-        if (kernel.AsSpan().SequenceEqual(lastKernelValues) && Tile.Value == lastTile && Gain.Value == lastGain && Bias.Value == lastBias)
+        if (kernel.AsSpan().SequenceEqual(lastKernelValues) && Tile.Value == lastTile && Gain.Value == lastGain && Bias.Value == lastBias && OnAlpha.Value == lastOnAlpha)
             return filter;
         
         lastKernel = kernel;
         lastTile = Tile.Value;
         lastGain = Gain.Value;
         lastBias = Bias.Value;
+        lastOnAlpha = OnAlpha.Value;
         
         filter?.Dispose();
         

+ 49 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/SepiaFilterNode.cs

@@ -0,0 +1,49 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Surfaces.ImageData;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
+
+[NodeInfo("Sepia")]
+public class SepiaFilterNode : FilterNode
+{
+    public InputProperty<double> Intensity { get; }
+
+    private ColorMatrix sepiaMatrix;
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+    protected override CacheTriggerFlags CacheTrigger => CacheTriggerFlags.Inputs;
+
+    private ColorFilter lastFilter;
+
+    public SepiaFilterNode()
+    {
+        Intensity = CreateInput("Intensity", "INTENSITY", 1d)
+            .WithRules(rules => rules.Min(0d).Max(1d));
+
+        sepiaMatrix = new ColorMatrix(
+            [
+                0.393f, 0.769f, 0.189f, 0.0f, 0.0f,
+                0.349f, 0.686f, 0.168f, 0.0f, 0.0f,
+                0.272f, 0.534f, 0.131f, 0.0f, 0.0f,
+                0.0f, 0.0f, 0.0f, 1.0f, 0.0f
+            ]
+        );
+    }
+
+    protected override ColorFilter? GetColorFilter()
+    {
+        lastFilter?.Dispose();
+
+        var lerped = ColorMatrix.Lerp(ColorMatrix.Identity, sepiaMatrix, (float)Intensity.Value);
+        lastFilter = ColorFilter.CreateColorMatrix(lerped);
+
+        return lastFilter;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new SepiaFilterNode();
+    }
+}

+ 30 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ShadowNode.cs

@@ -0,0 +1,30 @@
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
+
+[NodeInfo("Shadow")]
+public class ShadowNode : FilterNode
+{
+    public InputProperty<VecD> Offset { get; }
+    public InputProperty<VecD> Sigma { get; }
+    public InputProperty<Color> Color { get; }
+
+    public ShadowNode()
+    {
+        Offset = CreateInput("Offset", "OFFSET", new VecD(5, 5));
+        Sigma = CreateInput("Radius", "RADIUS", new VecD(5, 5));
+        Color = CreateInput("Color", "COLOR", Colors.Black);
+    }
+
+    protected override ImageFilter? GetImageFilter()
+    {
+        return ImageFilter.CreateDropShadow((float)Offset.Value.X, (float)Offset.Value.Y, (float)Sigma.Value.X, (float)Sigma.Value.Y, Color.Value, null);
+    }
+
+    public override Node CreateCopy()
+    {
+        return new ShadowNode();
+    }
+}

+ 51 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Image/MaskNode.cs

@@ -0,0 +1,51 @@
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Image;
+
+[NodeInfo("Mask")]
+public class MaskNode : RenderNode, IRenderInput
+{
+    public RenderInputProperty Background { get; }
+    public RenderInputProperty Mask { get; }
+
+    protected Paint maskPaint = new Paint()
+    {
+        BlendMode = Drawie.Backend.Core.Surfaces.BlendMode.DstIn, ColorFilter = Nodes.Filters.MaskFilter
+    };
+
+    public MaskNode()
+    {
+        Background = CreateRenderInput("Background", "INPUT");
+        Mask = CreateRenderInput("Mask", "MASK");
+        AllowHighDpiRendering = true;
+        Output.FirstInChain = null;
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (Background.Value == null)
+        {
+            return;
+        }
+
+        Background.Value.Paint(context, surface);
+
+        if (Mask.Value == null)
+        {
+            return;
+        }
+
+        int layer = surface.Canvas.SaveLayer(maskPaint);
+        Mask.Value.Paint(context, surface);
+        surface.Canvas.RestoreToCount(layer);
+    }
+
+
+    public override Node CreateCopy()
+    {
+        return new MaskNode();
+    }
+}

+ 41 - 9
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ImageLayerNode.cs

@@ -34,7 +34,8 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
     {
         if (keyFrames.Count == 0)
         {
-            keyFrames.Add(new KeyFrameData(Guid.NewGuid(), 0, 0, ImageLayerKey) { Data = new ChunkyImage(size, colorSpace) });
+            keyFrames.Add(
+                new KeyFrameData(Guid.NewGuid(), 0, 0, ImageLayerKey) { Data = new ChunkyImage(size, colorSpace) });
         }
 
         this.startSize = size;
@@ -51,7 +52,8 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
         return (GetFrameWithImage(ctx.FrameTime).Data as ChunkyImage).LatestSize;
     }
 
-    protected internal override void DrawLayerInScene(SceneObjectRenderContext ctx, DrawingSurface workingSurface,
+    protected internal override void DrawLayerInScene(SceneObjectRenderContext ctx,
+        DrawingSurface workingSurface,
         bool useFilters = true)
     {
         int scaled = workingSurface.Canvas.Save();
@@ -62,7 +64,8 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
         workingSurface.Canvas.RestoreToCount(scaled);
     }
 
-    protected internal override void DrawLayerOnTexture(SceneObjectRenderContext ctx, DrawingSurface workingSurface,
+    protected internal override void DrawLayerOnTexture(SceneObjectRenderContext ctx,
+        DrawingSurface workingSurface,
         bool useFilters)
     {
         int scaled = workingSurface.Canvas.Save();
@@ -125,13 +128,39 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
 
             if (keyFrame != null)
             {
-                return (RectD?)GetLayerImageByKeyFrameGuid(keyFrame.KeyFrameGuid).FindTightCommittedBounds();
+                var kf = GetLayerImageByKeyFrameGuid(keyFrame.KeyFrameGuid);
+                if (kf == null)
+                {
+                    return null;
+                }
+
+                RectI? bounds = kf.FindChunkAlignedCommittedBounds(); // Don't use tight bounds, very expensive
+                if (bounds.HasValue)
+                {
+                    return new RectD(bounds.Value.X, bounds.Value.Y,
+                        Math.Min(bounds.Value.Width, kf.CommittedSize.X),
+                        Math.Min(bounds.Value.Height, kf.CommittedSize.Y));
+                }
             }
         }
 
         try
         {
-            return (RectD?)GetLayerImageAtFrame(frame).FindTightCommittedBounds();
+            var kf = GetLayerImageAtFrame(frame);
+            if (kf == null)
+            {
+                return null;
+            }
+
+            var bounds = kf.FindChunkAlignedCommittedBounds(); // Don't use tight bounds, very expensive
+            if (bounds.HasValue)
+            {
+                return new RectD(bounds.Value.X, bounds.Value.Y,
+                    Math.Min(bounds.Value.Width, kf.CommittedSize.X),
+                    Math.Min(bounds.Value.Height, kf.CommittedSize.Y));
+            }
+
+            return null;
         }
         catch (ObjectDisposedException)
         {
@@ -197,7 +226,7 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
         {
             return keyFrames[0];
         }
-        
+
         var imageFrame = keyFrames.OrderBy(x => x.StartFrame).LastOrDefault(x => x.IsInFrame(frame.Frame));
         if (imageFrame?.Data is not ChunkyImage)
         {
@@ -229,8 +258,10 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
     {
         var image = new ImageLayerNode(startSize, colorSpace)
         {
-            MemberName = this.MemberName, LockTransparency = this.LockTransparency,
-            ClipToPreviousMember = this.ClipToPreviousMember, EmbeddedMask = this.EmbeddedMask?.CloneFromCommitted()
+            MemberName = this.MemberName,
+            LockTransparency = this.LockTransparency,
+            ClipToPreviousMember = this.ClipToPreviousMember,
+            EmbeddedMask = this.EmbeddedMask?.CloneFromCommitted()
         };
 
         image.keyFrames.Clear();
@@ -258,7 +289,8 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
 
     void IReadOnlyImageNode.ForEveryFrame(Action<IReadOnlyChunkyImage> action) => ForEveryFrame(action);
 
-    public override void RenderChunk(VecI chunkPos, ChunkResolution resolution, KeyFrameTime frameTime, ColorSpace processColorSpace)
+    public override void RenderChunk(VecI chunkPos, ChunkResolution resolution, KeyFrameTime frameTime,
+        ColorSpace processColorSpace)
     {
         base.RenderChunk(chunkPos, resolution, frameTime, processColorSpace);
 

+ 37 - 11
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/LayerNode.cs

@@ -3,7 +3,9 @@ using PixiEditor.ChangeableDocument.Helpers;
 using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
 
@@ -22,7 +24,7 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
     {
         if (!IsVisible.Value || Opacity.Value <= 0 || IsEmptyMask())
         {
-            Output.Value = Background.Value; 
+            Output.Value = Background.Value;
             return;
         }
 
@@ -48,7 +50,8 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
             return;
         }
 
-        var outputWorkingSurface = TryInitWorkingSurface(size, context.ChunkResolution, 1);
+        var outputWorkingSurface =
+            TryInitWorkingSurface(size, context.ChunkResolution, context.ProcessingColorSpace, 1);
         outputWorkingSurface.DrawingSurface.Canvas.Clear();
 
         DrawLayerOnTexture(context, outputWorkingSurface.DrawingSurface, useFilters);
@@ -57,7 +60,7 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
 
         if (Background.Value != null)
         {
-            Texture tempSurface = TryInitWorkingSurface(size, context.ChunkResolution, 4);
+            Texture tempSurface = TryInitWorkingSurface(size, context.ChunkResolution, context.ProcessingColorSpace, 4);
             tempSurface.DrawingSurface.Canvas.Clear();
             if (Background.Connection is { Node: IClipSource clipSource } && ClipToPreviousMember)
             {
@@ -68,10 +71,11 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
         }
 
         blendPaint.BlendMode = RenderContext.GetDrawingBlendMode(BlendMode.Value);
-        DrawWithResolution(outputWorkingSurface.DrawingSurface, renderOnto, context.ChunkResolution, size);
+        DrawWithResolution(outputWorkingSurface.DrawingSurface, renderOnto, context.ChunkResolution);
     }
 
-    protected internal virtual void DrawLayerOnTexture(SceneObjectRenderContext ctx, DrawingSurface workingSurface,
+    protected internal virtual void DrawLayerOnTexture(SceneObjectRenderContext ctx,
+        DrawingSurface workingSurface,
         bool useFilters)
     {
         int scaled = workingSurface.Canvas.Save();
@@ -82,7 +86,7 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
         workingSurface.Canvas.RestoreToCount(scaled);
     }
 
-    private void DrawWithResolution(DrawingSurface source, DrawingSurface target, ChunkResolution resolution, VecI size)
+    private void DrawWithResolution(DrawingSurface source, DrawingSurface target, ChunkResolution resolution)
     {
         int scaled = target.Canvas.Save();
         float multiplier = (float)resolution.InvertedMultiplier();
@@ -95,20 +99,42 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
 
     protected abstract VecI GetTargetSize(RenderContext ctx);
 
-    protected internal virtual void DrawLayerInScene(SceneObjectRenderContext ctx, DrawingSurface workingSurface,
+    protected internal virtual void DrawLayerInScene(SceneObjectRenderContext ctx,
+        DrawingSurface workingSurface,
         bool useFilters = true)
     {
         DrawLayerOnto(ctx, workingSurface, useFilters);
     }
 
-    protected void DrawLayerOnto(SceneObjectRenderContext ctx, DrawingSurface workingSurface, bool useFilters)
+    protected void DrawLayerOnto(SceneObjectRenderContext ctx, DrawingSurface workingSurface,
+        bool useFilters)
     {
         blendPaint.Color = blendPaint.Color.WithAlpha((byte)Math.Round(Opacity.Value * ctx.Opacity * 255));
 
         if (useFilters && Filters.Value != null)
         {
             blendPaint.SetFilters(Filters.Value);
-            DrawWithFilters(ctx, workingSurface, blendPaint);
+
+            var targetSurface = workingSurface;
+            Texture? tex = null;
+            int saved = -1;
+            if (!ctx.ProcessingColorSpace.IsSrgb)
+            {
+                saved = workingSurface.Canvas.Save();
+
+                tex = Texture.ForProcessing(workingSurface, ColorSpace.CreateSrgb()); // filters are meant to be applied in sRGB
+                targetSurface = tex.DrawingSurface;
+                workingSurface.Canvas.SetMatrix(Matrix3X3.Identity);
+            }
+
+            DrawWithFilters(ctx, targetSurface, blendPaint);
+
+            if(targetSurface != workingSurface)
+            {
+                workingSurface.Canvas.DrawSurface(targetSurface, 0, 0);
+                tex.Dispose();
+                workingSurface.Canvas.RestoreToCount(saved);
+            }
         }
         else
         {
@@ -123,7 +149,7 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
     protected abstract void DrawWithFilters(SceneObjectRenderContext ctx, DrawingSurface workingSurface,
         Paint paint);
 
-    protected Texture TryInitWorkingSurface(VecI imageSize, ChunkResolution resolution, int id)
+    protected Texture TryInitWorkingSurface(VecI imageSize, ChunkResolution resolution, ColorSpace processingCs, int id)
     {
         ChunkResolution targetResolution = resolution;
         bool hasSurface = workingSurfaces.TryGetValue((targetResolution, id), out Texture workingSurface);
@@ -133,7 +159,7 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
 
         if (!hasSurface || workingSurface.Size != targetSize || workingSurface.IsDisposed)
         {
-            workingSurfaces[(targetResolution, id)] = new Texture(targetSize);
+            workingSurfaces[(targetResolution, id)] = Texture.ForProcessing(targetSize, processingCs);
             workingSurface = workingSurfaces[(targetResolution, id)];
         }
 

+ 51 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/Matrix3X3BaseNode.cs

@@ -0,0 +1,51 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+
+public abstract class Matrix3X3BaseNode : RenderNode, IRenderInput
+{
+    public RenderInputProperty Background { get; }
+    public InputProperty<Matrix3X3> Input { get; }
+    public OutputProperty<Matrix3X3> Matrix { get; }
+
+    private Paint? paint;
+
+    public Matrix3X3BaseNode()
+    {
+        Background = CreateRenderInput("Background", "IMAGE");
+        Input = CreateInput("Input", "INPUT_MATRIX", Matrix3X3.Identity);
+        Matrix = CreateOutput("Matrix", "OUTPUT_MATRIX", Matrix3X3.Identity);
+        Output.FirstInChain = null;
+        AllowHighDpiRendering = true;
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        Matrix.Value = CalculateMatrix(Input.Value);
+        if (Background.Value == null)
+            return;
+
+        paint ??= new();
+        base.OnExecute(context);
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (paint == null)
+            return;
+
+        int layer = surface.Canvas.Save();
+
+        surface.Canvas.SetMatrix(surface.Canvas.TotalMatrix.Concat(Matrix.Value));
+        Background.Value?.Paint(context, surface);
+
+        surface.Canvas.RestoreToCount(layer);
+    }
+
+    protected abstract Matrix3X3 CalculateMatrix(Matrix3X3 input);
+}

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

@@ -0,0 +1,6 @@
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+
+public class MatrixNode
+{
+
+}

+ 26 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/OffsetNode.cs

@@ -0,0 +1,26 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+
+[NodeInfo("Offset")]
+public class OffsetNode : Matrix3X3BaseNode
+{
+    public InputProperty<VecD> Translation { get; }
+
+    public OffsetNode()
+    {
+        Translation = CreateInput("Offset", "OFFSET", VecD.Zero);
+    }
+
+    protected override Matrix3X3 CalculateMatrix(Matrix3X3 input)
+    {
+        Matrix3X3 matrix = Matrix3X3.CreateTranslation((float)(Translation.Value.X), (float)(Translation.Value.Y));
+        return input.PostConcat(matrix);
+    }
+
+    public override Node CreateCopy()
+    {
+        return new OffsetNode();
+    }
+}

+ 43 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/RotateNode.cs

@@ -0,0 +1,43 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+
+[NodeInfo("Rotate")]
+public class RotateNode : Matrix3X3BaseNode
+{
+    public InputProperty<RotationType> RotationType { get; }
+    public InputProperty<double> Angle { get; }
+    public InputProperty<VecD> Center { get; }
+
+    public RotateNode()
+    {
+        RotationType = CreateInput("RotationType", "UNIT", Nodes.Matrix.RotationType.Degrees);
+        Angle = CreateInput("Angle", "ANGLE", 0.0);
+        Center = CreateInput("Center", "CENTER", new VecD(0, 0));
+    }
+
+    protected override Matrix3X3 CalculateMatrix(Matrix3X3 input)
+    {
+        VecD scaledCenter = new VecD(Center.Value.X, Center.Value.Y);
+        Matrix3X3 rotated = RotationType.Value switch
+        {
+            Nodes.Matrix.RotationType.Degrees => Matrix3X3.CreateRotationDegrees((float)Angle.Value, (float)scaledCenter.X, (float)scaledCenter.Y),
+            Nodes.Matrix.RotationType.Radians => Matrix3X3.CreateRotation((float)Angle.Value, (float)scaledCenter.X, (float)scaledCenter.Y),
+            _ => throw new ArgumentOutOfRangeException()
+        };
+
+        return input.PostConcat(rotated);
+    }
+
+    public override Node CreateCopy()
+    {
+        return new RotateNode();
+    }
+}
+
+public enum RotationType
+{
+    Degrees,
+    Radians
+}

+ 28 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/ScaleNode.cs

@@ -0,0 +1,28 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+
+[NodeInfo("Scale")]
+public class ScaleNode : Matrix3X3BaseNode
+{
+    public InputProperty<VecD> Scale { get; }
+    public InputProperty<VecD> Center { get; }
+
+    public ScaleNode()
+    {
+        Scale = CreateInput("Scale", "SCALE", new VecD(1, 1));
+        Center = CreateInput("Center", "CENTER", new VecD(0, 0));
+    }
+
+    protected override Matrix3X3 CalculateMatrix(Matrix3X3 input)
+    {
+        Matrix3X3 scaled = Matrix3X3.CreateScale((float)Scale.Value.X, (float)Scale.Value.Y, (float)Center.Value.X, (float)Center.Value.Y);
+        return input.PostConcat(scaled);
+    }
+
+    public override Node CreateCopy()
+    {
+        return new ScaleNode();
+    }
+}

+ 26 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/SkewNode.cs

@@ -0,0 +1,26 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Numerics;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+
+[NodeInfo("Skew")]
+public class SkewNode : Matrix3X3BaseNode
+{
+    public InputProperty<VecD> Skew { get; }
+
+    public SkewNode()
+    {
+        Skew = CreateInput("Skew", "SKEW", VecD.Zero);
+    }
+
+    protected override Matrix3X3 CalculateMatrix(Matrix3X3 input)
+    {
+        Matrix3X3 matrix = Matrix3X3.CreateSkew((float)Skew.Value.X, (float)Skew.Value.Y);
+        return input.PostConcat(matrix);
+    }
+
+    public override Node CreateCopy()
+    {
+        return new SkewNode();
+    }
+}

+ 50 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Matrix/TransformNode.cs

@@ -0,0 +1,50 @@
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+
+[NodeInfo("Transform")]
+public class TransformNode : RenderNode, IRenderInput
+{
+    public RenderInputProperty Background { get; }
+    public InputProperty<Matrix3X3> Matrix { get; }
+
+    public TransformNode()
+    {
+        Background = CreateRenderInput("Background", "IMAGE");
+        Matrix = CreateInput("Matrix", "INPUT_MATRIX", Matrix3X3.Identity);
+
+        Output.FirstInChain = null;
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (Background.Value == null)
+            return;
+
+        int layer = surface.Canvas.Save();
+
+        surface.Canvas.SetMatrix(surface.Canvas.TotalMatrix.PostConcat(Matrix.Value));
+        Background.Value?.Paint(context, surface);
+
+        surface.Canvas.RestoreToCount(layer);
+    }
+
+    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    {
+        return null;
+    }
+
+    public override bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
+    {
+        return false;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new TransformNode();
+    }
+}

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

@@ -55,6 +55,7 @@ public class MergeNode : RenderNode
         {
             int saved = target.Canvas.SaveLayer();
             Bottom.Value?.Paint(context, target);
+            target.Canvas.RestoreToCount(saved);
 
             paint.BlendMode = RenderContext.GetDrawingBlendMode(BlendMode.Value);
             target.Canvas.SaveLayer(paint);

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

@@ -41,10 +41,18 @@ public abstract class Node : IReadOnlyNode, IDisposable
     }
 
     protected virtual bool ExecuteOnlyOnCacheChange => false;
+    protected virtual CacheTriggerFlags CacheTrigger => CacheTriggerFlags.Inputs;
+
+    private KeyFrameTime lastFrameTime;
 
     protected internal bool IsDisposed => _isDisposed;
     private bool _isDisposed;
 
+    protected virtual int GetContentCacheHash()
+    {
+        return 0;
+    }
+
     public void Execute(RenderContext context)
     {
         ExecuteInternal(context);
@@ -71,7 +79,19 @@ public abstract class Node : IReadOnlyNode, IDisposable
 
     protected virtual bool CacheChanged(RenderContext context)
     {
-        return inputs.Any(x => x.CacheChanged);
+        bool changed = false;
+
+        if (CacheTrigger.HasFlag(CacheTriggerFlags.Inputs))
+        {
+            changed |= inputs.Any(x => x.CacheChanged);
+        }
+
+        if (CacheTrigger.HasFlag(CacheTriggerFlags.Timeline))
+        {
+            changed |= lastFrameTime.Frame != context.FrameTime.Frame || Math.Abs(lastFrameTime.NormalizedTime - context.FrameTime.NormalizedTime) > float.Epsilon;
+        }
+
+        return changed;
     }
 
     protected virtual void UpdateCache(RenderContext context)
@@ -80,6 +100,8 @@ public abstract class Node : IReadOnlyNode, IDisposable
         {
             input.UpdateCache();
         }
+
+        lastFrameTime = context.FrameTime;
     }
 
     public void TraverseBackwards(Func<IReadOnlyNode, IInputProperty, bool> action)
@@ -333,6 +355,14 @@ public abstract class Node : IReadOnlyNode, IDisposable
         return property;
     }
 
+    protected void RemoveInputProperty(InputProperty property)
+    {
+        if(inputs.Remove(property))
+        {
+            property.ConnectionChanged -= InvokeConnectionsChanged;
+        }
+    }
+
     protected void AddOutputProperty(OutputProperty property)
     {
         outputs.Add(property);
@@ -526,4 +556,30 @@ public abstract class Node : IReadOnlyNode, IDisposable
 
         return default;
     }
+
+    public int GetCacheHash()
+    {
+        HashCode hash = new();
+        hash.Add(GetType());
+        hash.Add(DisplayName);
+        hash.Add(Position);
+        foreach (var input in inputs)
+        {
+            hash.Add(input.GetCacheHash());
+        }
+
+        foreach (var output in outputs)
+        {
+            hash.Add(output.GetCacheHash());
+        }
+
+        foreach (var frame in keyFrames)
+        {
+            hash.Add(frame.GetCacheHash());
+        }
+
+        hash.Add(GetContentCacheHash());
+
+        return hash.ToHashCode();
+    }
 }

+ 5 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Painter.cs

@@ -6,4 +6,9 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 public class Painter(Action<RenderContext, DrawingSurface> paint)
 {
     public Action<RenderContext, DrawingSurface> Paint { get; } = paint;
+
+    public override int GetHashCode()
+    {
+        return Paint.GetHashCode();
+    }
 }

+ 31 - 3
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs

@@ -5,6 +5,7 @@ using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 
@@ -16,6 +17,8 @@ public abstract class RenderNode : Node, IPreviewRenderable, IHighDpiRenderNode
 
     private TextureCache textureCache = new();
 
+    private VecI lastDocumentSize = VecI.Zero;
+
     public RenderNode()
     {
         Painter painter = new Painter(Paint);
@@ -33,6 +36,8 @@ public abstract class RenderNode : Node, IPreviewRenderable, IHighDpiRenderNode
                 output.ChainToPainterValue();
             }
         }
+
+        lastDocumentSize = context.DocumentSize;
     }
 
     private void Paint(RenderContext context, DrawingSurface surface)
@@ -57,16 +62,39 @@ public abstract class RenderNode : Node, IPreviewRenderable, IHighDpiRenderNode
 
     protected abstract void OnPaint(RenderContext context, DrawingSurface surface);
 
-    public abstract RectD? GetPreviewBounds(int frame, string elementToRenderName = "");
+    public virtual RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    {
+        return new RectD(0, 0, lastDocumentSize.X, lastDocumentSize.Y);
+    }
 
-    public abstract bool RenderPreview(DrawingSurface renderOn, RenderContext context,
-        string elementToRenderName);
+    public virtual bool RenderPreview(DrawingSurface renderOn, RenderContext context,
+        string elementToRenderName)
+    {
+        OnPaint(context, renderOn);
+        return true;
+    }
 
     protected Texture RequestTexture(int id, VecI size, ColorSpace processingCs, bool clear = true)
     {
         return textureCache.RequestTexture(id, size, processingCs, clear);
     }
 
+    public override void SerializeAdditionalData(Dictionary<string, object> additionalData)
+    {
+        base.SerializeAdditionalData(additionalData);
+        additionalData["AllowHighDpiRendering"] = AllowHighDpiRendering;
+    }
+
+    internal override OneOf<None, IChangeInfo, List<IChangeInfo>> DeserializeAdditionalData(IReadOnlyDocument target, IReadOnlyDictionary<string, object> data)
+    {
+        base.DeserializeAdditionalData(target, data);
+
+        if(data.TryGetValue("AllowHighDpiRendering", out var value))
+            AllowHighDpiRendering = (bool)value;
+
+        return new None();
+    }
+
     public override void Dispose()
     {
         base.Dispose();

+ 329 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs

@@ -0,0 +1,329 @@
+using Drawie.Backend.Core;
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Shaders;
+using Drawie.Backend.Core.Shaders.Generation;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.ColorSpaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+[NodeInfo("Shader")]
+public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
+{
+    public RenderInputProperty Background { get; }
+    public InputProperty<ColorSpaceType> ColorSpace { get; }
+    public InputProperty<string> ShaderCode { get; }
+
+    private Shader? shader;
+    private Shader? lastImageShader;
+    private string lastShaderCode;
+    private Paint paint;
+
+    private VecI lastDocumentSize;
+    private List<Shader> lastCustomImageShaders = new();
+
+    private Dictionary<string, (InputProperty prop, UniformValueType valueType)> uniformInputs = new();
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+    protected override CacheTriggerFlags CacheTrigger => CacheTriggerFlags.All;
+
+    public ShaderNode()
+    {
+        Background = CreateRenderInput("Background", "BACKGROUND");
+        ColorSpace = CreateInput("ColorSpace", "COLOR_SPACE", ColorSpaceType.Inherit);
+        ShaderCode = CreateInput("ShaderCode", "SHADER_CODE", "")
+            .WithRules(validator => validator.Custom(ValidateShaderCode))
+            .NonOverridenChanged(RegenerateUniformInputs);
+
+        paint = new Paint();
+        Output.FirstInChain = null;
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        base.OnExecute(context);
+
+        lastDocumentSize = context.DocumentSize;
+
+        if (lastShaderCode != ShaderCode.Value)
+        {
+            GenerateShader(context);
+        }
+        else if (shader != null)
+        {
+            Uniforms uniforms = GenerateUniforms(context);
+            shader = shader.WithUpdatedUniforms(uniforms);
+        }
+
+        paint.Shader = shader;
+    }
+
+    private void GenerateShader(RenderContext context)
+    {
+        Uniforms uniforms = null;
+
+        uniforms = GenerateUniforms(context);
+
+        shader?.Dispose();
+
+        if (uniforms != null)
+        {
+            shader = Shader.Create(ShaderCode.Value, uniforms, out _);
+        }
+        else
+        {
+            shader = Shader.Create(ShaderCode.Value, out _);
+        }
+
+        lastShaderCode = ShaderCode.Value;
+    }
+
+    private Uniforms GenerateUniforms(RenderContext context)
+    {
+        Uniforms uniforms;
+        uniforms = new Uniforms();
+
+        uniforms.Add("iResolution", new Uniform("iResolution", context.DocumentSize));
+        uniforms.Add("iNormalizedTime", new Uniform("iNormalizedTime", (float)context.FrameTime.NormalizedTime));
+        uniforms.Add("iFrame", new Uniform("iFrame", context.FrameTime.Frame));
+
+        AddCustomUniforms(uniforms);
+
+        if (Background.Value == null)
+        {
+            lastImageShader?.Dispose();
+            lastImageShader = null;
+            return uniforms;
+        }
+
+        Texture texture = RequestTexture(50, context.DocumentSize, context.ProcessingColorSpace);
+        Background.Value.Paint(context, texture.DrawingSurface);
+
+        var snapshot = texture.DrawingSurface.Snapshot();
+        lastImageShader?.Dispose();
+        lastImageShader = snapshot.ToShader();
+
+        uniforms.Add("iImage", new Uniform("iImage", lastImageShader));
+
+        snapshot.Dispose();
+        return uniforms;
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (shader == null || paint == null)
+        {
+            surface.Canvas.DrawColor(Colors.Magenta, BlendMode.Src);
+            return;
+        }
+
+        DrawingSurface targetSurface = surface;
+
+        if (ColorSpace.Value != ColorSpaceType.Inherit)
+        {
+            if (ColorSpace.Value == ColorSpaceType.Srgb && !context.ProcessingColorSpace.IsSrgb)
+            {
+                targetSurface = RequestTexture(51, context.DocumentSize,
+                    Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgb()).DrawingSurface;
+            }
+            else if (ColorSpace.Value == ColorSpaceType.LinearSrgb && context.ProcessingColorSpace.IsSrgb)
+            {
+                targetSurface = RequestTexture(51, context.DocumentSize,
+                    Drawie.Backend.Core.Surfaces.ImageData.ColorSpace.CreateSrgbLinear()).DrawingSurface;
+            }
+        }
+
+        targetSurface.Canvas.DrawRect(0, 0, context.DocumentSize.X, context.DocumentSize.Y, paint);
+
+        if (targetSurface != surface)
+        {
+            surface.Canvas.DrawSurface(targetSurface, 0, 0);
+        }
+    }
+
+    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    {
+        return new RectD(0, 0, lastDocumentSize.X, lastDocumentSize.Y);
+    }
+
+    public override bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
+    {
+        OnPaint(context, renderOn);
+        return true;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new ShaderNode();
+    }
+
+    private void RegenerateUniformInputs(string newShaderCode)
+    {
+        UniformDeclaration[]? declarations = Shader.GetUniformDeclarations(newShaderCode);
+        if (declarations == null) return;
+
+        if (declarations.Length == 0)
+        {
+            foreach (var input in uniformInputs)
+            {
+                RemoveInputProperty(input.Value.prop);
+            }
+
+            uniformInputs.Clear();
+            return;
+        }
+
+        var uniforms = declarations;
+
+        var nonExistingUniforms = uniformInputs.Keys.Where(x => uniforms.All(y => y.Name != x)).ToList();
+        foreach (var nonExistingUniform in nonExistingUniforms)
+        {
+            RemoveInputProperty(uniformInputs[nonExistingUniform].prop);
+            uniformInputs.Remove(nonExistingUniform);
+        }
+
+        foreach (var uniform in uniforms)
+        {
+            if (IsBuiltInUniform(uniform.Name))
+            {
+                continue;
+            }
+
+            if (uniformInputs.ContainsKey(uniform.Name) && uniformInputs[uniform.Name].valueType != uniform.DataType)
+            {
+                RemoveInputProperty(uniformInputs[uniform.Name].prop);
+                uniformInputs.Remove(uniform.Name);
+            }
+
+            if (!uniformInputs.ContainsKey(uniform.Name))
+            {
+                InputProperty input;
+                if (uniform.DataType == UniformValueType.Float)
+                {
+                    input = CreateInput(uniform.Name, uniform.Name, 0d);
+                }
+                else if (uniform.DataType == UniformValueType.Shader)
+                {
+                    input = CreateInput<Texture>(uniform.Name, uniform.Name, null);
+                }
+                else if (uniform.DataType == UniformValueType.Color)
+                {
+                    input = CreateInput<Color>(uniform.Name, uniform.Name, Colors.Black);
+                }
+                else if (uniform.DataType == UniformValueType.Vector2)
+                {
+                    input = CreateInput<VecD>(uniform.Name, uniform.Name, new VecD(0, 0));
+                }
+                else if (uniform.DataType == UniformValueType.Vector3)
+                {
+                    input = CreateInput<Vec3D>(uniform.Name, uniform.Name, new Vec3D(0, 0, 0));
+                }
+                else
+                {
+                    continue;
+                }
+
+                uniformInputs.Add(uniform.Name, (input, uniform.DataType));
+            }
+        }
+    }
+
+    private void AddCustomUniforms(Uniforms uniforms)
+    {
+        foreach (var imgShader in lastCustomImageShaders)
+        {
+            imgShader.Dispose();
+        }
+
+        lastCustomImageShaders.Clear();
+
+        foreach (var input in uniformInputs)
+        {
+            object value = input.Value.prop.Value;
+            if (input.Value.prop.Value is ShaderExpressionVariable expressionVariable)
+            {
+                value = expressionVariable.GetConstant();
+            }
+
+            if (input.Value.valueType == UniformValueType.Float)
+            {
+                if (value is float floatValue)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, floatValue));
+                }
+                else if (value is double doubleValue)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, (float)doubleValue));
+                }
+                else if (value is int intValue)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, (float)intValue));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Vector2)
+            {
+                if (value is VecD vector)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, vector));
+                }
+                else if (value is VecI vecI)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, new VecD(vecI.X, vecI.Y)));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Vector3)
+            {
+                if (value is Vec3D vector)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, vector));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Vector4)
+            {
+                if (value is Vec4D vector)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, vector));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Color)
+            {
+                if (value is Color color)
+                {
+                    uniforms.Add(input.Key, new Uniform(input.Key, color));
+                }
+            }
+            else if (input.Value.valueType == UniformValueType.Shader)
+            {
+                if (value is Texture texture)
+                {
+                    var snapshot = texture.DrawingSurface.Snapshot();
+                    Shader snapshotShader = snapshot.ToShader();
+                    lastCustomImageShaders.Add(snapshotShader);
+                    uniforms.Add(input.Key, new Uniform(input.Key, snapshotShader));
+                    snapshot.Dispose();
+                }
+            }
+        }
+    }
+
+    private bool IsBuiltInUniform(string name)
+    {
+        return name is "iResolution" or "iNormalizedTime" or "iFrame" or "iImage";
+    }
+
+    private ValidatorResult ValidateShaderCode(object? value)
+    {
+        if (value is string code)
+        {
+            var result = Shader.Create(code, out string errors);
+            result?.Dispose();
+            return new(string.IsNullOrWhiteSpace(errors), errors);
+        }
+
+        return new(false, "Shader code must be a string");
+    }
+}

+ 2 - 7
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/EllipseVectorData.cs

@@ -78,14 +78,9 @@ public class EllipseVectorData : ShapeVectorData, IReadOnlyEllipseData
         return Radius is { X: > 0, Y: > 0 };
     }
 
-    public override int CalculateHash()
+    protected override int GetSpecificHash()
     {
-        return HashCode.Combine(Center, Radius, StrokeColor, FillColor, StrokeWidth, TransformationMatrix);
-    }
-
-    public override int GetCacheHash()
-    {
-        return CalculateHash();
+        return HashCode.Combine(Center, Radius);
     }
 
     protected override void AdjustCopy(ShapeVectorData copy)

+ 5 - 7
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/LineVectorData.cs

@@ -100,14 +100,12 @@ public class LineVectorData : ShapeVectorData, IReadOnlyLineData
         return Start != End;
     }
 
-    public override int GetCacheHash()
+    protected override int GetSpecificHash()
     {
-        return HashCode.Combine(Start, End, StrokeColor, StrokeWidth, TransformationMatrix);
-    }
-
-    public override int CalculateHash()
-    {
-        return GetCacheHash();
+        HashCode hash = new();
+        hash.Add(Start);
+        hash.Add(End);
+        return hash.ToHashCode();
     }
 
     public override VectorPath ToPath()

+ 16 - 7
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/PathVectorData.cs

@@ -11,7 +11,7 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 public class PathVectorData : ShapeVectorData, IReadOnlyPathData
 {
     public VectorPath Path { get; set; }
-    public override RectD GeometryAABB => Path.TightBounds;
+    public override RectD GeometryAABB => Path?.TightBounds ?? RectD.Empty;
     public override RectD VisualAABB => GeometryAABB.Inflate(StrokeWidth / 2);
 
     public override ShapeCorners TransformationCorners =>
@@ -24,6 +24,10 @@ public class PathVectorData : ShapeVectorData, IReadOnlyPathData
     public PathVectorData(VectorPath path)
     {
         Path = path;
+        if (path == null)
+        {
+            Path = new VectorPath();
+        }
     }
 
     public override void RasterizeGeometry(Canvas canvas)
@@ -38,6 +42,11 @@ public class PathVectorData : ShapeVectorData, IReadOnlyPathData
 
     private void Rasterize(Canvas canvas, bool applyTransform)
     {
+        if(Path == null)
+        {
+            return;
+        }
+
         int num = 0;
         if (applyTransform)
         {
@@ -78,14 +87,14 @@ public class PathVectorData : ShapeVectorData, IReadOnlyPathData
         return Path is { IsEmpty: false };
     }
 
-    public override int GetCacheHash()
+    protected override int GetSpecificHash()
     {
-        return Path.GetHashCode();
-    }
+        HashCode hash = new();
+        hash.Add(Path);
+        hash.Add(StrokeLineCap);
+        hash.Add(StrokeLineJoin);
 
-    public override int CalculateHash()
-    {
-        return Path.GetHashCode();
+        return hash.ToHashCode();
     }
 
     protected override void AdjustCopy(ShapeVectorData copy)

+ 6 - 9
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/PointsVectorData.cs

@@ -3,6 +3,7 @@ using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Backend.Core.Vector;
 using Drawie.Numerics;
+using Drawie.Numerics.Helpers;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 
@@ -47,8 +48,7 @@ public class PointsVectorData : ShapeVectorData
             canvas.SetMatrix(final);
         }
 
-        canvas.DrawPoints(PointMode.Points, Points.Select(p => new VecF((float)p.X, (float)p.Y)).ToArray(),
-            paint);
+        canvas.DrawPoints(PointMode.Points, Points.ToVecFArray(), paint);
 
         if (applyTransform)
         {
@@ -61,14 +61,11 @@ public class PointsVectorData : ShapeVectorData
         return Points.Count > 0;
     }
 
-    public override int GetCacheHash()
+    protected override int GetSpecificHash()
     {
-        return CalculateHash();
-    }
-
-    public override int CalculateHash()
-    {
-        return Points.GetHashCode();
+        HashCode hash = new();
+        hash.Add(Points);
+        return hash.ToHashCode();
     }
 
     protected override void AdjustCopy(ShapeVectorData copy)

+ 2 - 7
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/RectangleVectorData.cs

@@ -90,14 +90,9 @@ public class RectangleVectorData : ShapeVectorData, IReadOnlyRectangleData
         return Size is { X: > 0, Y: > 0 };
     }
 
-    public override int CalculateHash()
+    protected override int GetSpecificHash()
     {
-        return HashCode.Combine(Center, Size, StrokeColor, FillColor, StrokeWidth, TransformationMatrix);
-    }
-
-    public override int GetCacheHash()
-    {
-        return CalculateHash();
+        return HashCode.Combine(Center, Size);
     }
 
     public override VectorPath ToPath()

+ 37 - 9
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/ShapeVectorData.cs

@@ -11,18 +11,31 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
 
 public abstract class ShapeVectorData : ICacheable, ICloneable, IReadOnlyShapeVectorData
 {
-    public Matrix3X3 TransformationMatrix { get; set; } = Matrix3X3.Identity; 
-    
+    private float strokeWidth = 0;
+
+    public Matrix3X3 TransformationMatrix { get; set; } = Matrix3X3.Identity;
+
     public Color StrokeColor { get; set; } = Colors.White;
     public Color FillColor { get; set; } = Colors.White;
-    public float StrokeWidth { get; set; } = 1;
+
+    public float StrokeWidth
+    {
+        get => strokeWidth;
+        set
+        {
+            strokeWidth = value;
+            OnStrokeWidthChanged();
+        }
+    }
+    
     public bool Fill { get; set; } = true;
-    public abstract RectD GeometryAABB { get; } 
+
+    public abstract RectD GeometryAABB { get; }
     public abstract RectD VisualAABB { get; }
     public RectD TransformedAABB => new ShapeCorners(GeometryAABB).WithMatrix(TransformationMatrix).AABBBounds;
     public RectD TransformedVisualAABB => new ShapeCorners(VisualAABB).WithMatrix(TransformationMatrix).AABBBounds;
-    public abstract ShapeCorners TransformationCorners { get; } 
-    
+    public abstract ShapeCorners TransformationCorners { get; }
+
     protected void ApplyTransformTo(Canvas canvas)
     {
         Matrix3X3 canvasMatrix = canvas.TotalMatrix;
@@ -35,8 +48,21 @@ public abstract class ShapeVectorData : ICacheable, ICloneable, IReadOnlyShapeVe
     public abstract void RasterizeGeometry(Canvas canvas);
     public abstract void RasterizeTransformed(Canvas canvas);
     public abstract bool IsValid();
-    public abstract int GetCacheHash();
-    public abstract int CalculateHash();
+
+    public int GetCacheHash()
+    {
+        HashCode hash = new();
+        hash.Add(TransformationMatrix);
+        hash.Add(StrokeColor);
+        hash.Add(FillColor);
+        hash.Add(StrokeWidth);
+        hash.Add(Fill);
+        hash.Add(GetSpecificHash());
+
+        return hash.ToHashCode();
+    }
+
+    protected abstract int GetSpecificHash();
 
     public object Clone()
     {
@@ -46,10 +72,12 @@ public abstract class ShapeVectorData : ICacheable, ICloneable, IReadOnlyShapeVe
     }
 
     protected virtual void AdjustCopy(ShapeVectorData copy) { }
+    
+    protected virtual void OnStrokeWidthChanged() { }
 
     public override int GetHashCode()
     {
-        return CalculateHash();
+        return GetCacheHash();
     }
 
     public abstract VectorPath ToPath();

+ 235 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/Data/TextVectorData.cs

@@ -0,0 +1,235 @@
+using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces.Shapes;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+
+public class TextVectorData : ShapeVectorData, IReadOnlyTextData
+{
+    private string text;
+    private Font font = Font.CreateDefault();
+    private double? spacing = null;
+    private double strokeWidth = 1;
+    private VectorPath? path;
+
+    public string Text
+    {
+        get => text;
+        set
+        {
+            text = value;
+            richText = new RichText(value) { Spacing = Spacing, MaxWidth = MaxWidth, StrokeWidth = StrokeWidth };
+
+            lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+
+    public VecD Position { get; set; }
+
+
+    public double MaxWidth { get; set; } = double.MaxValue;
+
+    public Font Font
+    {
+        get => font;
+        set
+        {
+            if (value != null)
+            {
+                value.Changed -= FontChanged;
+            }
+
+            font = value;
+            if (value != null)
+            {
+                value.Changed += FontChanged;
+            }
+
+            lastBounds = richText.MeasureBounds(value);
+        }
+    }
+
+    private void FontChanged()
+    {
+        if (richText == null)
+        {
+            return;
+        }
+
+        lastBounds = richText.MeasureBounds(Font);
+    }
+
+    public Font ConstructFont()
+    {
+        Font newFont = Font.FromFontFamily(Font.Family);
+        newFont.Size = Font.Size;
+        newFont.Edging = Font.Edging;
+        newFont.SubPixel = Font.SubPixel;
+        newFont.Bold = Font.Bold;
+        newFont.Italic = Font.Italic;
+
+        return newFont;
+    }
+
+    double IReadOnlyTextData.Spacing => Spacing ?? Font.Size;
+
+    public double? Spacing
+    {
+        get => spacing;
+        set
+        {
+            spacing = value;
+            richText.Spacing = value;
+            lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+    
+    public bool AntiAlias { get; set; } = true;
+
+    protected override void OnStrokeWidthChanged()
+    {
+        if(richText == null)
+        {
+            return;
+        }
+
+        richText.StrokeWidth = StrokeWidth;
+        lastBounds = richText.MeasureBounds(Font);
+    }
+
+    public override RectD GeometryAABB
+    {
+        get
+        {
+            return lastBounds.Offset(Position);
+        }
+    }
+
+    public override ShapeCorners TransformationCorners =>
+        new ShapeCorners(GeometryAABB).WithMatrix(TransformationMatrix);
+
+    public override RectD VisualAABB => GeometryAABB;
+
+    public VectorPath? Path
+    {
+        get => path;
+        set
+        {
+            path = value;
+            // TODO: properly calculate bounds
+            //lastBounds = richText.MeasureBounds(Font);
+        }
+    }
+
+    public FontFamilyName? MissingFontFamily { get; set; }
+    public string MissingFontText { get; set; }
+
+    private RichText richText;
+    private RectD lastBounds;
+    private double _spacing;
+
+    public TextVectorData()
+    {
+
+    }
+
+    public TextVectorData(string text)
+    {
+        Text = text;
+    }
+
+
+    public override VectorPath ToPath()
+    {
+        var path = richText.ToPath(Font);
+        path.Offset(Position);
+
+        return path;
+    }
+
+    public override void RasterizeGeometry(Canvas canvas)
+    {
+        Rasterize(canvas, false);
+    }
+
+    public override void RasterizeTransformed(Canvas canvas)
+    {
+        Rasterize(canvas, true);
+    }
+
+    private void Rasterize(Canvas canvas, bool applyTransform)
+    {
+        int num = 0;
+        if (applyTransform)
+        {
+            num = canvas.Save();
+            ApplyTransformTo(canvas);
+        }
+
+        using Paint paint = new Paint() { IsAntiAliased = AntiAlias };
+
+        richText.Fill = Fill;
+        richText.FillColor = FillColor;
+        richText.StrokeColor = StrokeColor;
+        richText.StrokeWidth = StrokeWidth;
+        richText.Spacing = Spacing;
+
+        if (MissingFontFamily != null)
+        {
+            paint.Color = Fill ? FillColor : StrokeColor;
+            canvas.DrawText($"{MissingFontText}: " + MissingFontFamily.Value.Name, Position, Font, paint);
+        }
+        else
+        {
+            PaintText(canvas, paint);
+        }
+
+        if (applyTransform)
+        {
+            canvas.RestoreToCount(num);
+        }
+    }
+
+    private void PaintText(Canvas canvas, Paint paint)
+    {
+        richText.Paint(canvas, Position, Font, paint, Path);
+    }
+
+    public override bool IsValid()
+    {
+        return !string.IsNullOrEmpty(Text);
+    }
+
+    protected override void AdjustCopy(ShapeVectorData copy)
+    {
+        if (copy is TextVectorData textData)
+        {
+            textData.Font = Font.FromFontFamily(Font.Family);
+            textData.Font.Size = Font.Size;
+            textData.Font.Edging = Font.Edging;
+            textData.Font.SubPixel = Font.SubPixel;
+            textData.Font.Bold = Font.Bold;
+            textData.Font.Italic = Font.Italic;
+
+            textData.lastBounds = lastBounds;
+        }
+    }
+
+    protected override int GetSpecificHash()
+    {
+        HashCode hash = new();
+        hash.Add(Text);
+        hash.Add(Position);
+        hash.Add(Font);
+        hash.Add(Spacing);
+        hash.Add(AntiAlias);
+        hash.Add(MissingFontFamily);
+        hash.Add(MissingFontText);
+
+        return hash.ToHashCode();
+    }
+}

+ 6 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RasterizeShapeNode.cs

@@ -12,11 +12,12 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
 public class RasterizeShapeNode : RenderNode
 {
     public InputProperty<ShapeVectorData> Data { get; }
-
+    public InputProperty<bool> HighDpiRendering { get; }
 
     public RasterizeShapeNode()
     {
         Data = CreateInput<ShapeVectorData>("Points", "SHAPE", null);
+        HighDpiRendering = CreateInput<bool>("High DPI Rendering", "HIGH_DPI_RENDERING", true);
     }
 
     protected override void OnPaint(RenderContext context, DrawingSurface surface)
@@ -25,11 +26,14 @@ public class RasterizeShapeNode : RenderNode
 
         if (shape == null || !shape.IsValid())
             return;
-        
+
+        AllowHighDpiRendering = HighDpiRendering.Value;
+
         shape.RasterizeTransformed(surface.Canvas);
     }
 
     public override Node CreateCopy() => new RasterizeShapeNode();
+
     public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
     {
         return Data?.Value?.TransformedAABB;

+ 2 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/RemoveClosePointsNode.cs

@@ -26,6 +26,7 @@ public class RemoveClosePointsNode : ShapeNode<PointsVectorData>
         var data = Input.Value;
 
         var distance = MinDistance.Value;
+        var minDistanceSquared = distance * distance;
 
         if (distance == 0 || data == null || data.Points == null)
         {
@@ -34,9 +35,6 @@ public class RemoveClosePointsNode : ShapeNode<PointsVectorData>
 
         var availablePoints = data.Points.Distinct().ToList();
         List<VecD> newPoints = new List<VecD>();
-        
-        var minDistance = MinDistance.Value;
-        var documentSize = context.DocumentSize;
 
         var random = new Random(Seed.Value);
         while (availablePoints.Count > 1)
@@ -55,7 +53,7 @@ public class RemoveClosePointsNode : ShapeNode<PointsVectorData>
             continue;
 
             bool InRange(VecD other) =>
-                (other - point).Length <= minDistance;
+                (other - point).LengthSquared <= minDistanceSquared;
         }
 
         if (availablePoints.Count == 1)

+ 71 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextNode.cs

@@ -0,0 +1,71 @@
+using Drawie.Backend.Core.Text;
+using Drawie.Backend.Core.Vector;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("Text")]
+public class TextNode : ShapeNode<TextVectorData>
+{
+    public InputProperty<string> Text { get; }
+    public InputProperty<VecD> TextPosition { get; }
+    public InputProperty<FontFamilyName> FontFamily { get; }
+    public InputProperty<double> FontSize { get; }
+
+    private string lastText = "";
+    private VecD lastPosition = new VecD();
+    private FontFamilyName lastFontFamily = new FontFamilyName();
+    private double lastFontSize = 12d;
+    private VectorPath? lastPath;
+
+    private TextVectorData? cachedData;
+    public TextNode()
+    {
+        Text = CreateInput("Text", "TEXT_LABEL", "");
+        TextPosition = CreateInput("Position", "POSITION", new VecD());
+        FontFamily = CreateInput("FontFamily", "FONT_LABEL", new FontFamilyName());
+        FontSize = CreateInput("FontSize", "FONT_SIZE_LABEL", 12d);
+    }
+    
+    protected override TextVectorData? GetShapeData(RenderContext context)
+    {
+        string text = Text.Value;
+        VecD position = TextPosition.Value;
+        FontFamilyName fontFamily = FontFamily.Value;
+        double fontSize = FontSize.Value;
+
+        if (text == lastText && position == lastPosition && fontFamily.Equals(lastFontFamily) && fontSize == lastFontSize)
+        {
+            return cachedData;
+        }
+        
+        lastText = text;
+        lastPosition = position;
+        lastFontFamily = fontFamily;
+        lastFontSize = fontSize;
+
+        Font font = Font.FromFontFamily(fontFamily);
+        if(font == null)
+        {
+            font = Font.CreateDefault();
+        }
+        
+        font.Size = fontSize;
+        
+        cachedData = new TextVectorData()
+        {
+            Text = text,
+            Position = position,
+            Font = font,
+        };
+        
+        return cachedData;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new TextNode();
+    }
+}

+ 51 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Shapes/TextOnPathNode.cs

@@ -0,0 +1,51 @@
+using Drawie.Backend.Core.Vector;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes.Data;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Shapes;
+
+[NodeInfo("TextOnPath")]
+public class TextOnPathNode : Node
+{
+    public InputProperty<TextVectorData> TextData { get; }
+    public InputProperty<ShapeVectorData> PathData { get; }
+
+    public OutputProperty<TextVectorData> Output { get; }
+
+    private VectorPath lastPath;
+
+    public TextOnPathNode()
+    {
+        TextData = CreateInput<TextVectorData>("Text", "TEXT_LABEL", null);
+        PathData = CreateInput<ShapeVectorData>("Path", "SHAPE_LABEL", null);
+
+        Output = CreateOutput<TextVectorData>("Output", "TEXT_LABEL", null);
+    }
+
+    protected override void OnExecute(RenderContext context)
+    {
+        var textData = TextData.Value;
+        var pathData = PathData.Value;
+
+        if (textData == null || pathData == null || !textData.IsValid() || !pathData.IsValid())
+        {
+            Output.Value = null;
+            return;
+        }
+
+        var cloned = (TextVectorData)textData.Clone();
+
+        lastPath?.Dispose();
+        lastPath = pathData.ToPath();
+        lastPath.Transform(pathData.TransformationMatrix);
+
+        cloned.Path = lastPath;
+
+        Output.Value = cloned;
+    }
+
+    public override Node CreateCopy()
+    {
+        return new TextOnPathNode();
+    }
+}

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

@@ -39,6 +39,10 @@ public abstract class StructureNode : RenderNode, IReadOnlyStructureNode, IRende
     public RenderOutputProperty FilterlessOutput { get; }
     public RenderOutputProperty RawOutput { get; }
 
+    public OutputProperty<VecD> TightSize { get; }
+    public OutputProperty<VecD> CanvasPosition { get; }
+    public OutputProperty<VecD> CenterPosition { get; }
+
     public ChunkyImage? EmbeddedMask { get; set; }
 
     protected Texture renderedMask;
@@ -94,9 +98,33 @@ public abstract class StructureNode : RenderNode, IReadOnlyStructureNode, IRende
 
         RawOutput = CreateRenderOutput(RawOutputPropertyName, "RAW_LAYER_OUTPUT", () => rawPainter);
 
+        CanvasPosition = CreateOutput<VecD>("CanvasPosition", "CANVAS_POSITION", VecD.Zero);
+        CenterPosition = CreateOutput<VecD>("CenterPosition", "CENTER_POSITION", VecD.Zero);
+        TightSize = CreateOutput<VecD>("Size", "SIZE", VecD.Zero);
+
         MemberName = DefaultMemberName;
     }
 
+    protected override void OnExecute(RenderContext context)
+    {
+        base.OnExecute(context);
+
+        if (TightSize.Connections.Count > 0)
+        {
+            TightSize.Value = GetTightBounds(context.FrameTime)?.Size ?? VecD.Zero;
+        }
+
+        if (CanvasPosition.Connections.Count > 0)
+        {
+            CanvasPosition.Value = GetTightBounds(context.FrameTime)?.TopLeft ?? VecD.Zero;
+        }
+
+        if (CenterPosition.Connections.Count > 0)
+        {
+            CenterPosition.Value = GetTightBounds(context.FrameTime)?.Center ?? VecD.Zero;
+        }
+    }
+
     protected override void OnPaint(RenderContext context, DrawingSurface renderTarget)
     {
         if (Output.Connections.Count > 0)

+ 83 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/TileNode.cs

@@ -0,0 +1,83 @@
+using Drawie.Backend.Core;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Shaders;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.ImageData;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ChangeableDocument.Helpers;
+using PixiEditor.ChangeableDocument.Rendering;
+
+namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+[NodeInfo("Tile")]
+public class TileNode : RenderNode
+{
+    public InputProperty<Texture> Image { get; }
+    public InputProperty<ShaderTileMode> TileModeX { get; }
+    public InputProperty<ShaderTileMode> TileModeY { get; }
+    public InputProperty<Matrix3X3> Matrix { get; }
+
+    private Drawie.Backend.Core.Surfaces.ImageData.Image lastImage;
+    private Shader tileShader;
+    private Paint paint;
+
+    public TileNode()
+    {
+        Image = CreateInput<Texture>("Image", "IMAGE", null);
+        TileModeX = CreateInput<ShaderTileMode>("TileModeX", "TILE_MODE_X", ShaderTileMode.Repeat);
+        TileModeY = CreateInput<ShaderTileMode>("TileModeY", "TILE_MODE_Y", ShaderTileMode.Repeat);
+        Matrix = CreateInput<Matrix3X3>("Matrix", "MATRIX", Matrix3X3.Identity);
+
+        Output.FirstInChain = null;
+    }
+
+    protected override bool ExecuteOnlyOnCacheChange => true;
+    protected override CacheTriggerFlags CacheTrigger => CacheTriggerFlags.Inputs;
+
+    protected override void OnExecute(RenderContext context)
+    {
+        base.OnExecute(context);
+
+        lastImage?.Dispose();
+        tileShader?.Dispose();
+        if (paint != null)
+        {
+            paint.Shader = null;
+        }
+
+        if (Image.Value == null)
+            return;
+
+        lastImage = Image.Value.DrawingSurface.Snapshot();
+        tileShader = Shader.CreateImage(lastImage, TileModeX.Value, TileModeY.Value, Matrix.Value);
+
+        paint ??= new();
+        paint.Shader = tileShader;
+    }
+
+    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    {
+        if (paint == null)
+            return;
+
+        surface.Canvas.DrawRect(0, 0, context.DocumentSize.X, context.DocumentSize.Y, paint);
+    }
+
+    public override Node CreateCopy()
+    {
+        return new TileNode();
+    }
+
+    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    {
+        return null;
+    }
+
+    public override bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
+    {
+        return false;
+    }
+
+}

+ 13 - 2
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs

@@ -17,6 +17,7 @@ namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 [NodeInfo("VectorLayer")]
 public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorNode, IRasterizable
 {
+    public OutputProperty<ShapeVectorData> Shape { get; }
     public Matrix3X3 TransformationMatrix
     {
         get => ShapeData?.TransformationMatrix ?? Matrix3X3.Identity;
@@ -31,7 +32,11 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
         }
     }
 
-    public ShapeVectorData? ShapeData { get; set; }
+    public ShapeVectorData? ShapeData
+    {
+        get => Shape.Value;
+        set => Shape.Value = value;
+    }
     IReadOnlyShapeVectorData IReadOnlyVectorNode.ShapeData => ShapeData;
 
 
@@ -43,6 +48,7 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
     public VectorLayerNode()
     {
         AllowHighDpiRendering = true;
+        Shape = CreateOutput<ShapeVectorData>("Shape", "SHAPE", null);
     }
     
     protected override VecI GetTargetSize(RenderContext ctx)
@@ -167,6 +173,11 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
 
     public override Node CreateCopy()
     {
-        return new VectorLayerNode() { ShapeData = (ShapeVectorData?)ShapeData?.Clone(), ClipToPreviousMember = this.ClipToPreviousMember };
+        return new VectorLayerNode()
+        {
+            ShapeData = (ShapeVectorData?)ShapeData?.Clone(),
+            ClipToPreviousMember = this.ClipToPreviousMember,
+            AllowHighDpiRendering = this.AllowHighDpiRendering
+        };
     }
 }

+ 11 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/OutputProperty.cs

@@ -1,5 +1,6 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.Common;
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph;
 
@@ -65,6 +66,16 @@ public class OutputProperty : IOutputProperty
 
         Disconnected?.Invoke(property, this);
     }
+
+    public int GetCacheHash()
+    {
+        if (Value is ICacheable cacheable)
+        {
+            return cacheable.GetCacheHash();
+        }
+
+        return 0;
+    }
 }
 
 public class OutputProperty<T> : OutputProperty, INodeProperty<T>

+ 74 - 11
src/PixiEditor.ChangeableDocument/Changeables/Graph/PropertyValidator.cs

@@ -2,38 +2,80 @@
 
 namespace PixiEditor.ChangeableDocument.Changeables.Graph;
 
-public delegate (bool validationResult, object? closestValidValue) ValidateProperty(object? value);
+public delegate ValidatorResult ValidateProperty(object? value);
 
 public class PropertyValidator
 {
+    public InputProperty ForProperty { get; }
     public List<ValidateProperty> Rules { get; } = new();
 
+    public PropertyValidator(InputProperty forProperty)
+    {
+        ForProperty = forProperty;
+    }
+
     public PropertyValidator Min(VecI min)
     {
-       return Min(min, v => new VecI(Math.Max(v.X, min.X), Math.Max(v.Y, min.Y))); 
+        return Min(min, v => new VecI(Math.Max(v.X, min.X), Math.Max(v.Y, min.Y)));
     }
-    
+
     public PropertyValidator Min(VecD min)
     {
-        return Min(min, v => new VecD(Math.Max(v.X, min.X), Math.Max(v.Y, min.Y))); 
+        return Min(min, v => new VecD(Math.Max(v.X, min.X), Math.Max(v.Y, min.Y)));
     }
 
     public PropertyValidator Min<T>(T min, Func<T, T>? adjust = null) where T : IComparable<T>
     {
+        if (!typeof(T).IsAssignableTo(ForProperty.ValueType))
+        {
+            throw new ArgumentException($"Type mismatch. Expected {ForProperty.ValueType}, got {typeof(T)}");
+        }
+
         Rules.Add((value) =>
         {
             if (value is T val)
             {
                 bool isValid = val.CompareTo(min) >= 0;
-                return (isValid, isValid ? val : GetReturnValue(val, min, adjust));
+                return new(isValid, isValid ? val : GetReturnValue(val, min, adjust));
             }
 
-            return (false, GetReturnValue(min, min, adjust));
+            return new(false, GetReturnValue(min, min, adjust));
         });
 
         return this;
     }
 
+
+    public void Max(VecI max)
+    {
+        Max(max, v => new VecI(Math.Min(v.X, max.X), Math.Min(v.Y, max.Y)));
+    }
+
+    public void Max(VecD max)
+    {
+        Max(max, v => new VecD(Math.Min(v.X, max.X), Math.Min(v.Y, max.Y)));
+    }
+
+    public void Max<T>(T max, Func<T, T>? adjust = null) where T : IComparable<T>
+    {
+        Rules.Add((value) =>
+        {
+            if (value is T val)
+            {
+                bool isValid = val.CompareTo(max) <= 0;
+                return new(isValid, isValid ? val : GetReturnValue(val, max, adjust));
+            }
+
+            return new(false, GetReturnValue(max, max, adjust));
+        });
+    }
+
+    public PropertyValidator Custom(ValidateProperty rule)
+    {
+        Rules.Add(rule);
+        return this;
+    }
+
     private object? GetReturnValue<T>(T original, T min, Func<T, T>? fallback) where T : IComparable<T>
     {
         if (fallback != null)
@@ -44,25 +86,46 @@ public class PropertyValidator
         return min;
     }
 
-    public bool Validate(object? value)
+    public bool Validate(object? value, out string? errors)
     {
         object lastValue = value;
 
         foreach (var rule in Rules)
         {
-            var (isValid, toPass) = rule(lastValue);
-            lastValue = toPass;
-            if (!isValid)
+            var result = rule(lastValue);
+            lastValue = result.ClosestValidValue;
+            if (!result.IsValid)
             {
+                errors = result.ErrorMessage;
                 return false;
             }
         }
 
+        errors = null;
         return true;
     }
 
     public object? GetClosestValidValue(object? o)
     {
-        return Rules.Aggregate(o, (current, rule) => rule(current).closestValidValue);
+        return Rules.Aggregate(o, (current, rule) => rule(current).ClosestValidValue);
+    }
+}
+
+public record ValidatorResult
+{
+    public bool IsValid { get; }
+    public object? ClosestValidValue { get; }
+    public string? ErrorMessage { get; }
+
+    public ValidatorResult(bool isValid, string? errorMessage)
+    {
+        IsValid = isValid;
+        ErrorMessage = errorMessage;
+    }
+
+    public ValidatorResult(bool isValid, object? closestValidValue)
+    {
+        IsValid = isValid;
+        ClosestValidValue = closestValidValue;
     }
 }

+ 4 - 2
src/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyKeyFrameData.cs

@@ -1,6 +1,8 @@
-namespace PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.Common;
 
-public interface IReadOnlyKeyFrameData
+namespace PixiEditor.ChangeableDocument.Changeables.Interfaces;
+
+public interface IReadOnlyKeyFrameData : ICacheable
 {
     int StartFrame { get; }
     int Duration { get; }

+ 81 - 7
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/UpdateProperty_Change.cs

@@ -35,15 +35,23 @@ internal class UpdatePropertyValue_Change : Change
         var node = target.NodeGraph.Nodes.First(x => x.Id == _nodeId);
         var property = node.GetInputProperty(_propertyName);
 
+        int inputsHash = CalculateInputsHash(node);
+        int outputsHash = CalculateOutputsHash(node);
+
         previousValue = GetValue(property);
-        if (!property.Validator.Validate(_value))
+        string errors = string.Empty;
+        if (!property.Validator.Validate(_value, out errors))
         {
-            _value = property.Validator.GetClosestValidValue(_value);
-            if (_value == previousValue)
+            if (string.IsNullOrEmpty(errors))
             {
-                ignoreInUndo = true;
+                _value = property.Validator.GetClosestValidValue(_value);
+                if (_value == previousValue)
+                {
+                    ignoreInUndo = true;
+                }
             }
-            
+
+            _value = SetValue(property, _value);
             ignoreInUndo = false;
         }
         else
@@ -52,16 +60,53 @@ internal class UpdatePropertyValue_Change : Change
             ignoreInUndo = false;
         }
 
-        return new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, _value);
+        List<IChangeInfo> changes = new();
+        changes.Add(new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, _value) { Errors = errors });
+
+        int newInputsHash = CalculateInputsHash(node);
+        int newOutputsHash = CalculateOutputsHash(node);
+
+        if (inputsHash != newInputsHash)
+        {
+            changes.Add(NodeInputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        if (outputsHash != newOutputsHash)
+        {
+            changes.Add(NodeOutputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        return changes;
     }
 
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         var node = target.NodeGraph.Nodes.First(x => x.Id == _nodeId);
         var property = node.GetInputProperty(_propertyName);
+
+        int inputsHash = CalculateInputsHash(node);
+        int outputsHash = CalculateOutputsHash(node);
+
         SetValue(property, previousValue);
 
-        return new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, previousValue);
+        List<IChangeInfo> changes = new();
+
+        changes.Add(new PropertyValueUpdated_ChangeInfo(_nodeId, _propertyName, previousValue));
+
+        int newInputsHash = CalculateInputsHash(node);
+        int newOutputsHash = CalculateOutputsHash(node);
+
+        if (inputsHash != newInputsHash)
+        {
+            changes.Add(NodeInputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        if (outputsHash != newOutputsHash)
+        {
+            changes.Add(NodeOutputsChanged_ChangeInfo.FromNode(node));
+        }
+
+        return changes;
     }
 
     private static object SetValue(InputProperty property, object? value)
@@ -93,4 +138,33 @@ internal class UpdatePropertyValue_Change : Change
 
         return property.NonOverridenValue;
     }
+
+    private static int CalculateInputsHash(Node node)
+    {
+        HashCode hash = new();
+        foreach (var input in node.InputProperties)
+        {
+            hash.Add(input.InternalPropertyName);
+            hash.Add(input.ValueType);
+        }
+
+        return hash.ToHashCode();
+    }
+
+    private static int CalculateOutputsHash(Node node)
+    {
+        HashCode hash = new();
+        foreach (var output in node.OutputProperties)
+        {
+            hash.Add(output.InternalPropertyName);
+            hash.Add(output.ValueType);
+        }
+
+        return hash.ToHashCode();
+    }
+
+    public override bool IsMergeableWith(Change other)
+    {
+        return other is UpdatePropertyValue_Change change && change._nodeId == _nodeId && change._propertyName == _propertyName;
+    }
 }

+ 46 - 0
src/PixiEditor.ChangeableDocument/Changes/Structure/SetLowDpiRendering_Change.cs

@@ -0,0 +1,46 @@
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+
+namespace PixiEditor.ChangeableDocument.Changes.Structure;
+
+internal class SetLowDpiRendering_Change : Change
+{
+    public readonly Guid memberId;
+    public bool value;
+    
+    private bool originalValue;
+    
+    [GenerateMakeChangeAction]
+    public SetLowDpiRendering_Change(Guid memberId, bool value)
+    {
+        this.memberId = memberId;
+        this.value = value;
+    }
+    
+    public override bool InitializeAndValidate(Document target)
+    {
+        return target.TryFindNode(memberId, out RenderNode node);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        RenderNode node = target.FindNodeOrThrow<RenderNode>(memberId);
+        
+        bool toSet = !value;
+        
+        originalValue = node.AllowHighDpiRendering;
+        node.AllowHighDpiRendering = toSet;
+        
+        ignoreInUndo = originalValue == toSet;
+
+        return new None();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        RenderNode node = target.FindNodeOrThrow<RenderNode>(memberId);
+        
+        node.AllowHighDpiRendering = originalValue;
+
+        return new None();
+    }
+}

+ 6 - 0
src/PixiEditor.ChangeableDocument/Changes/Vectors/ConvertToCurve_Change.cs

@@ -12,6 +12,7 @@ internal class ConvertToCurve_Change : Change
     public readonly Guid memberId;
 
     private ShapeVectorData originalData;
+    private bool originalHighDpiRendering;
 
     [GenerateMakeChangeAction]
     public ConvertToCurve_Change(Guid memberId)
@@ -44,6 +45,9 @@ internal class ConvertToCurve_Change : Change
             TransformationMatrix = originalData.TransformationMatrix
         };
 
+        originalHighDpiRendering = node.AllowHighDpiRendering;
+        node.AllowHighDpiRendering = true;
+
         ignoreInUndo = false;
 
         var aabb = node.ShapeData.TransformedVisualAABB;
@@ -58,6 +62,8 @@ internal class ConvertToCurve_Change : Change
         VectorLayerNode node = target.FindNodeOrThrow<VectorLayerNode>(memberId);
         node.ShapeData = originalData;
 
+        node.AllowHighDpiRendering = originalHighDpiRendering;
+
         var aabb = node.ShapeData.TransformedVisualAABB;
         var affected = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
             (RectI)aabb, ChunkyImage.FullChunkSize));

+ 11 - 10
src/PixiEditor.ChangeableDocument/Changes/Vectors/SetShapeGeometry_UpdateableChange.cs

@@ -12,7 +12,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     public ShapeVectorData Data { get; set; }
 
     private ShapeVectorData? originalData;
-    
+
     private AffectedArea lastAffectedArea;
 
     [GenerateUpdateableChangeActions]
@@ -42,8 +42,9 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
         var node = target.FindNode<VectorLayerNode>(TargetId);
-        node.ShapeData = Data;
 
+        node.ShapeData = Data;
+        
         RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
         aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
 
@@ -51,14 +52,14 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
             (RectI)aabb, ChunkyImage.FullChunkSize));
 
         var tmp = new AffectedArea(affected);
-        
+
         if (lastAffectedArea.Chunks != null)
         {
             affected.UnionWith(lastAffectedArea);
         }
-        
+
         lastAffectedArea = tmp;
-        
+
         return new VectorShape_ChangeInfo(node.Id, affected);
     }
 
@@ -68,7 +69,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
         ignoreInUndo = false;
         var node = target.FindNode<VectorLayerNode>(TargetId);
         node.ShapeData = Data;
-        
+
         RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
         aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
 
@@ -84,12 +85,12 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
         node.ShapeData = originalData;
 
         AffectedArea affected = new AffectedArea();
-        
+
         if (node.ShapeData != null)
-        { 
+        {
             RectD aabb = node.ShapeData.TransformedAABB.RoundOutwards();
             aabb = aabb with { Size = new VecD(Math.Max(1, aabb.Size.X), Math.Max(1, aabb.Size.Y)) };
-         
+
             affected = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(
                 (RectI)aabb, ChunkyImage.FullChunkSize));
         }
@@ -101,7 +102,7 @@ internal class SetShapeGeometry_UpdateableChange : InterruptableUpdateableChange
     {
         if (other is SetShapeGeometry_UpdateableChange change)
         {
-            return change.TargetId == TargetId;
+            return change.TargetId == TargetId && change.Data is not TextVectorData; // text should not be merged into one change
         }
 
         return false;

+ 14 - 8
src/PixiEditor.ChangeableDocument/DocumentChangeTracker.cs

@@ -208,7 +208,7 @@ public class DocumentChangeTracker : IDisposable
             Trace.WriteLine($"Attempted to execute make change action {act} while {activeUpdateableChange} is active");
             return new None();
         }
-        
+
         bool ignoreInUndo = false;
         List<IChangeInfo> changeInfos = new();
 
@@ -219,12 +219,12 @@ public class DocumentChangeTracker : IDisposable
                 AddToUndo(interruptable, source);
             else
                 interruptable.Dispose();
-            
+
             applyInfo.Switch(
                 static (None _) => { },
                 (IChangeInfo info) => changeInfos.Add(info),
                 (List<IChangeInfo> infos) => changeInfos.AddRange(infos));
-            
+
             activeUpdateableChange = null;
         }
 
@@ -238,12 +238,12 @@ public class DocumentChangeTracker : IDisposable
         }
 
         var info = change.Apply(document, true, out ignoreInUndo);
-        
+
         info.Switch(
             static (None _) => { },
             (IChangeInfo changeInfo) => changeInfos.Add(changeInfo),
             (List<IChangeInfo> infos) => changeInfos.AddRange(infos));
-        
+
         if (!ignoreInUndo)
             AddToUndo(change, source);
         else
@@ -406,9 +406,15 @@ public class DocumentChangeTracker : IDisposable
         if (running)
             throw new InvalidOperationException("Already currently processing");
         running = true;
-        var result = ProcessActionList(actions);
-        running = false;
-        return result;
+        try
+        {
+            var result = ProcessActionList(actions);
+            return result;
+        }
+        finally
+        {
+            running = false;
+        }
     }
 }
 

+ 0 - 45
src/PixiEditor.Desktop/Info.plist

@@ -1,45 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0">
-    <dict>
-        <key>CFBundleName</key>
-        <string>PixiEditor</string>
-        <key>CFBundleDisplayName</key>
-        <string>PixiEditor</string>
-        <key>CFBundleIdentifier</key>
-        <string>com.pixieditor.macos</string>
-        <key>CFBundleVersion</key>
-        <string>{version-string}</string>
-        <key>CFBundlePackageType</key>
-        <string>APPL</string>
-        <key>CFBundleExecutable</key>
-        <string>PixiEditor</string>
-        <key>CFBundleIconFile</key>
-        <string>PixiEditor.icns</string>
-        <key>CFBundleShortVersionString</key>
-        <string>{version-string}</string>
-        <key>CFBundleSignature</key>
-        <string>????</string>
-        <key>NSPrincipalClass</key>
-        <string>NSApplication</string>
-        <key>NSHighResolutionCapable</key>
-        <key>CFBundleDocumentTypes</key>
-        <array>
-            <dict>
-                <key>CFBundleTypeName</key>
-                <string>Pixi Document</string>
-                <key>CFBundleTypeExtensions</key>
-                <array>
-                    <string>pixi</string>
-                </array>
-                <key>CFBundleTypeIconFile</key>
-                <string>PixiEditor.icns</string>
-                <key>CFBundleTypeRole</key>
-                <string>Editor</string>
-                <key>LSHandlerRank</key>
-                <string>Owner</string>
-            </dict>
-        </array>
-        <true/>
-    </dict>
-</plist>

BIN
src/PixiEditor.Desktop/PixiEditor.icns


+ 3 - 1
src/PixiEditor.Desktop/Program.cs

@@ -1,5 +1,6 @@
 using System;
 using Avalonia;
+using Avalonia.Logging;
 using Drawie.Interop.VulkanAvalonia;
 
 namespace PixiEditor.Desktop;
@@ -25,8 +26,9 @@ public class Program
             .With(new X11PlatformOptions()
             {
                 RenderingMode = new X11RenderingMode[] { X11RenderingMode.Vulkan, X11RenderingMode.Glx },
-                OverlayPopups = true
+                OverlayPopups = true,
             })
             .WithDrawie()
+            .LogToTrace(LogEventLevel.Verbose, "Vulkan")
             .LogToTrace();
 }

+ 1 - 1
src/PixiEditor.Extensions.CommonApi/PixiEditor.Extensions.CommonApi.csproj

@@ -45,7 +45,7 @@
   <Target Name="GenerateProtoContracts" BeforeTargets="BeforeCompile"
           Inputs="$(MSBuildProjectDirectory)\DataContracts\*.proto"
           Outputs="$(MSBuildProjectDirectory)\ProtoAutogen\*.cs">
-    <Exec Command="dotnet tool run --allow-roll-forward protogen --csharp_out=ProtoAutogen --proto_path=DataContracts +listset=yes *.proto"/>
+    <Exec Command="dotnet tool run protogen --csharp_out=ProtoAutogen --proto_path=DataContracts +listset=yes *.proto"/>
 
     <ItemGroup>
       <Compile Include="ProtoAutogen\*.cs" KeepDuplicates="false"/>

BIN
src/PixiEditor.Extensions.Sdk/build/PixiEditor.Api.CGlueMSBuild.dll


BIN
src/PixiEditor.Extensions.Sdk/build/PixiEditor.Extensions.MSPackageBuilder.dll


+ 1 - 0
src/PixiEditor.Extensions/UI/Overlays/OverlayPointerArgs.cs

@@ -11,4 +11,5 @@ public class OverlayPointerArgs
     public MouseButton InitialPressMouseButton { get; set; }
     public IOverlayPointer Pointer { get; set; }
     public bool Handled { get; set; }
+    public int ClickCount { get; set; }
 }

+ 1 - 1
src/PixiEditor.Gen/PixiEditor.Gen.csproj

@@ -4,7 +4,7 @@
     <TargetFramework>netstandard2.0</TargetFramework>
     <IncludeBuildOutput>true</IncludeBuildOutput>
     <Nullable>enable</Nullable>
-    <ImplicitUsings>enable</ImplicitUsings>
+    <ImplicitUsings>true</ImplicitUsings>
     <LangVersion>latest</LangVersion>
     <RootNamespace>PixiEditorGen</RootNamespace>
   </PropertyGroup>

+ 84 - 0
src/PixiEditor.Linux/LinuxInputKeys.cs

@@ -0,0 +1,84 @@
+using Avalonia.Input;
+using PixiEditor.OperatingSystem;
+
+namespace PixiEditor.Linux;
+
+internal class LinuxInputKeys : IInputKeys
+{
+    public string GetKeyboardKey(Key key, bool forceInvariant = false)
+    {
+        return MapKey(key);
+    }
+
+    public bool ModifierUsesSymbol(KeyModifiers modifier) => false;
+
+    private string MapKey(Key key)
+    {
+        // at the moment only latin keys are supported
+
+        return key switch
+        {
+            Key.Back => "Backspace",
+            Key.Tab => "Tab",
+            Key.Return => "↵",
+            Key.CapsLock => "Caps Lock",
+            Key.Escape => "Esc",
+            Key.Space => "Space",
+            Key.PageUp => "Page Up",
+            Key.PageDown => "Page Down",
+            Key.D0 => "0",
+            Key.D1 => "1",
+            Key.D2 => "2",
+            Key.D3 => "3",
+            Key.D4 => "4",
+            Key.D5 => "5",
+            Key.D6 => "6",
+            Key.D7 => "7",
+            Key.D8 => "8",
+            Key.D9 => "9",
+            Key.LWin => "Super",
+            Key.RWin => "Super",
+            Key.NumPad0 => "0",
+            Key.NumPad1 => "1",
+            Key.NumPad2 => "2",
+            Key.NumPad3 => "3",
+            Key.NumPad4 => "4",
+            Key.NumPad5 => "5",
+            Key.NumPad6 => "6",
+            Key.NumPad7 => "7",
+            Key.NumPad8 => "8",
+            Key.NumPad9 => "9",
+            Key.Multiply => "*",
+            Key.Add => "+",
+            Key.Separator => ",",
+            Key.Subtract => "-",
+            Key.Decimal => ".",
+            Key.Divide => "/",
+            Key.NumLock => "Num Lock",
+            Key.LeftShift => "Shift",
+            Key.RightShift => "Shift",
+            Key.LeftCtrl => "Ctrl",
+            Key.RightCtrl => "Ctrl",
+            Key.LeftAlt => "Alt",
+            Key.RightAlt => "Alt",
+            Key.OemSemicolon => ";",
+            Key.OemPlus => "=",
+            Key.OemComma => ",",
+            Key.OemMinus => "-",
+            Key.OemPeriod => ".",
+            Key.OemQuestion => "/",
+            Key.OemTilde => "`",
+            Key.OemOpenBrackets => "[",
+            Key.OemPipe => "\\",
+            Key.OemCloseBrackets => "]",
+            Key.OemQuotes => "'",
+            Key.OemBackslash => "\\",
+            Key.FnLeftArrow => "Left Arrow",
+            Key.FnRightArrow => "Right Arrow",
+            Key.FnUpArrow => "Up Arrow",
+            Key.FnDownArrow => "Down Arrow",
+            Key.MediaHome => "Home",
+            _ => key.ToString()
+        };
+    }
+}

+ 30 - 6
src/PixiEditor.Linux/LinuxOperatingSystem.cs

@@ -10,19 +10,26 @@ public sealed class LinuxOperatingSystem : IOperatingSystem
     public string Name { get; } = "Linux";
     public string AnalyticsId => "Linux";
     public string AnalyticsName => LinuxOSInformation.FromReleaseFile().ToString();
-    public IInputKeys InputKeys { get; }
-    public IProcessUtility ProcessUtility { get; }
+    public IInputKeys InputKeys { get; } = new LinuxInputKeys();
+    public IProcessUtility ProcessUtility { get; } = new LinuxProcessUtility();
 
     public string ExecutableExtension { get; } = string.Empty;
 
     public void OpenUri(string uri)
     {
-        throw new NotImplementedException();
+        ProcessUtility.Execute($"xdg-open", uri);
     }
 
     public void OpenFolder(string path)
     {
-        throw new NotImplementedException();
+        try
+        {
+            ProcessUtility.Execute($"dbus-send", $"--session --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:\"file://{path}\" string:\"\"");
+        }
+        catch (Exception e)
+        {
+            ProcessUtility.Execute($"xdg-open", Path.GetDirectoryName(path));
+        }
     }
 
     public bool HandleNewInstance(Dispatcher? dispatcher, Action<string, bool> openInExistingAction, IApplicationLifetime lifetime)
@@ -32,12 +39,12 @@ public sealed class LinuxOperatingSystem : IOperatingSystem
 
     public void HandleActivatedWithFile(FileActivatedEventArgs fileActivatedEventArgs)
     {
-        throw new NotImplementedException();
+        // TODO: Check if this is executed on Linux at all
     }
 
     public void HandleActivatedWithUri(ProtocolActivatedEventArgs openUriEventArgs)
     {
-        throw new NotImplementedException();
+        // TODO: Check if this is executed on Linux at all
     }
 
     class LinuxOSInformation
@@ -79,4 +86,21 @@ public sealed class LinuxOperatingSystem : IOperatingSystem
 
         public override string ToString() => $"{Name} {Version}";
     }
+
+    public string GetActiveDesktopEnvironment()
+    {
+        var desktopSession = Environment.GetEnvironmentVariable("DESKTOP_SESSION");
+        if (desktopSession != null)
+        {
+            return desktopSession;
+        }
+
+        var desktopSessionFile = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP");
+        if (desktopSessionFile != null)
+        {
+            return desktopSessionFile;
+        }
+
+        return "Unknown";
+    }
 }

+ 56 - 0
src/PixiEditor.Linux/LinuxProcessUtility.cs

@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using System.Net;
+using System.Security;
+using PixiEditor.OperatingSystem;
+
+namespace PixiEditor.Linux;
+
+public class LinuxProcessUtility : IProcessUtility
+{
+    public Process RunAsAdmin(string path)
+    {
+        throw new NotImplementedException("Running as admin is not supported on Linux");
+    }
+
+    public Process RunAsAdmin(string path, bool createWindow)
+    {
+        throw new NotImplementedException("Running as admin is not supported on Linux");
+    }
+
+    public bool IsRunningAsAdministrator()
+    {
+        return Environment.IsPrivilegedProcess;
+    }
+
+    public Process ShellExecute(string toExecute)
+    {
+        Process process = new Process();
+        process.StartInfo.FileName = toExecute;
+        process.StartInfo.UseShellExecute = true;
+        process.Start();
+        
+        return process;
+    }
+
+    public Process ShellExecute(string toExecute, string args)
+    {
+        Process process = new Process();
+        process.StartInfo.FileName = toExecute;
+        process.StartInfo.Arguments = args;
+        process.StartInfo.UseShellExecute = true;
+        process.Start();
+        
+        return process;
+    }
+
+    public Process Execute(string path, string args)
+    {
+        Process process = new Process();
+        process.StartInfo.FileName = path;
+        process.StartInfo.Arguments = args;
+        process.StartInfo.UseShellExecute = false;
+        process.Start();
+        
+        return process;
+    }
+}

+ 4 - 1
src/PixiEditor.SVG/Elements/SvgGroup.cs

@@ -5,19 +5,22 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgGroup() : SvgElement("g"), ITransformable, IFillable, IStrokable, IElementContainer
+public class SvgGroup() : SvgElement("g"), ITransformable, IFillable, IStrokable, IOpacity, IElementContainer
 {
     public List<SvgElement> Children { get; } = new();
     public SvgProperty<SvgTransformUnit> Transform { get; } = new("transform");
     public SvgProperty<SvgColorUnit> Fill { get; } = new("fill");
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; } = new("fill-opacity");
     public SvgProperty<SvgColorUnit> Stroke { get; } = new("stroke");
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; } = new("stroke-width");
     public SvgProperty<SvgEnumUnit<SvgStrokeLineCap>> StrokeLineCap { get; } = new("stroke-linecap");
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; } = new("stroke-linejoin");
+    public SvgProperty<SvgNumericUnit> Opacity { get; } = new("opacity");
 
     public override void ParseData(XmlReader reader)
     {
         List<SvgProperty> properties = new List<SvgProperty>() { Transform, Fill, Stroke, StrokeWidth, StrokeLineCap, StrokeLineJoin };
         ParseAttributes(properties, reader);
     }
+
 }

+ 6 - 1
src/PixiEditor.SVG/Elements/SvgPrimitive.cs

@@ -5,10 +5,11 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG.Elements;
 
-public abstract class SvgPrimitive(string tagName) : SvgElement(tagName), ITransformable, IFillable, IStrokable
+public abstract class SvgPrimitive(string tagName) : SvgElement(tagName), ITransformable, IFillable, IStrokable, IOpacity
 {
     public SvgProperty<SvgTransformUnit> Transform { get; } = new("transform");
     public SvgProperty<SvgColorUnit> Fill { get; } = new("fill");
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; } = new("fill-opacity");
     public SvgProperty<SvgColorUnit> Stroke { get; } = new("stroke");
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; } = new("stroke-width");
     
@@ -16,16 +17,20 @@ public abstract class SvgPrimitive(string tagName) : SvgElement(tagName), ITrans
     
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; } = new("stroke-linejoin");
 
+    public SvgProperty<SvgNumericUnit> Opacity { get; } = new("opacity");
+
     public override void ParseData(XmlReader reader)
     {
         List<SvgProperty> properties = GetProperties().ToList();
         
         properties.Add(Transform);
         properties.Add(Fill);
+        properties.Add(FillOpacity);
         properties.Add(Stroke);
         properties.Add(StrokeWidth);
         properties.Add(StrokeLineCap);
         properties.Add(StrokeLineJoin);
+        properties.Add(Opacity);
 
         do
         {

+ 51 - 0
src/PixiEditor.SVG/Elements/SvgText.cs

@@ -0,0 +1,51 @@
+using System.Xml;
+using PixiEditor.SVG.Enums;
+using PixiEditor.SVG.Units;
+
+namespace PixiEditor.SVG.Elements;
+
+public class SvgText() : SvgPrimitive("text")
+{
+    public SvgProperty<SvgStringUnit> Text { get; } = new("");
+    public SvgProperty<SvgNumericUnit> X { get; } = new("x");
+    public SvgProperty<SvgNumericUnit> Y { get; } = new("y");
+    public SvgProperty<SvgNumericUnit> FontSize { get; } = new("font-size");
+    public SvgProperty<SvgStringUnit> FontFamily { get; } = new("font-family");
+    public SvgProperty<SvgEnumUnit<SvgFontWeight>> FontWeight { get; } = new("font-weight");
+    public SvgProperty<SvgEnumUnit<SvgFontStyle>> FontStyle { get; } = new("font-style");
+
+    public override void ParseData(XmlReader reader)
+    {
+        base.ParseData(reader);
+        Text.Unit = new SvgStringUnit(ParseContent(reader));
+    }
+
+    protected override IEnumerable<SvgProperty> GetProperties()
+    {
+        yield return X;
+        yield return Y;
+        yield return FontSize;
+        yield return FontFamily;
+        yield return FontWeight;
+        yield return FontStyle;
+    }
+
+    private string ParseContent(XmlReader reader)
+    {
+        string content = string.Empty;
+        if (reader.NodeType == XmlNodeType.None) return content;
+        while (reader.Read())
+        {
+            if (reader.NodeType == XmlNodeType.Text || reader.NodeType == XmlNodeType.CDATA)
+            {
+                content = reader.Value;
+            }
+            else if (reader is { NodeType: XmlNodeType.EndElement, Name: "text" })
+            {
+                break;
+            }
+        }
+
+        return content;
+    }
+}

+ 8 - 0
src/PixiEditor.SVG/Enums/SvgFontStyle.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.SVG.Enums;
+
+public enum SvgFontStyle
+{
+    Normal,
+    Italic,
+    Oblique,
+}

+ 9 - 0
src/PixiEditor.SVG/Enums/SvgFontWeight.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.SVG.Enums;
+
+public enum SvgFontWeight
+{
+    Lighter = 100,
+    Normal = 400,
+    Bold = 700,
+    Bolder = 900,
+}

+ 1 - 0
src/PixiEditor.SVG/Features/IFillable.cs

@@ -6,4 +6,5 @@ namespace PixiEditor.SVG.Features;
 public interface IFillable
 {
     public SvgProperty<SvgColorUnit> Fill { get; }
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; }
 }

+ 8 - 0
src/PixiEditor.SVG/Features/IOpacity.cs

@@ -0,0 +1,8 @@
+using PixiEditor.SVG.Units;
+
+namespace PixiEditor.SVG.Features;
+
+public interface IOpacity
+{
+    public SvgProperty<SvgNumericUnit> Opacity { get; }
+}

+ 154 - 32
src/PixiEditor.SVG/StyleContext.cs

@@ -1,4 +1,6 @@
-using PixiEditor.SVG.Enums;
+using Drawie.Backend.Core.Numerics;
+using Drawie.Numerics;
+using PixiEditor.SVG.Enums;
 using PixiEditor.SVG.Features;
 using PixiEditor.SVG.Units;
 
@@ -9,69 +11,102 @@ public struct StyleContext
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; }
     public SvgProperty<SvgColorUnit> Stroke { get; }
     public SvgProperty<SvgColorUnit> Fill { get; }
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; }
     public SvgProperty<SvgTransformUnit> Transform { get; }
-    
     public SvgProperty<SvgEnumUnit<SvgStrokeLineCap>> StrokeLineCap { get; }
-    
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; }
+    public SvgProperty<SvgNumericUnit> Opacity { get; }
+    public SvgProperty<SvgStyleUnit> InlineStyle { get; set; }
+    public VecD ViewboxOrigin { get; set; }
 
     public StyleContext()
     {
         StrokeWidth = new("stroke-width");
         Stroke = new("stroke");
         Fill = new("fill");
+        FillOpacity = new("fill-opacity");
+        Fill.Unit = new SvgColorUnit?(new SvgColorUnit("black"));
         Transform = new("transform");
         StrokeLineCap = new("stroke-linecap");
         StrokeLineJoin = new("stroke-linejoin");
+        Opacity = new("opacity");
+        InlineStyle = new("style");
     }
-    
+
     public StyleContext(SvgDocument document)
     {
-        StrokeWidth = document.StrokeWidth;
-        Stroke = document.Stroke;
-        Fill = document.Fill;
-        Transform = document.Transform;
-        StrokeLineCap = document.StrokeLineCap;
-        StrokeLineJoin = document.StrokeLineJoin;
+        StrokeWidth = FallbackToCssStyle(document.StrokeWidth, document.Style);
+        Stroke = FallbackToCssStyle(document.Stroke, document.Style);
+        Fill = FallbackToCssStyle(document.Fill, document.Style, new SvgColorUnit("black"));
+        FillOpacity = FallbackToCssStyle(document.FillOpacity, document.Style);
+        Transform = FallbackToCssStyle(document.Transform, document.Style, new SvgTransformUnit(Matrix3X3.Identity));
+        StrokeLineCap = FallbackToCssStyle(document.StrokeLineCap, document.Style);
+        StrokeLineJoin = FallbackToCssStyle(document.StrokeLineJoin, document.Style);
+        Opacity = FallbackToCssStyle(document.Opacity, document.Style);
+        ViewboxOrigin = new VecD(
+            document.ViewBox.Unit.HasValue ? -document.ViewBox.Unit.Value.Value.X : 0,
+            document.ViewBox.Unit.HasValue ? -document.ViewBox.Unit.Value.Value.Y : 0);
+        InlineStyle = document.Style;
     }
 
     public StyleContext WithElement(SvgElement element)
     {
         StyleContext styleContext = Copy();
 
-        if (element is ITransformable { Transform.Unit: not null } transformableElement)
+        styleContext.InlineStyle = MergeInlineStyle(element.Style, InlineStyle);
+
+        if (element is ITransformable transformableElement)
         {
-            styleContext.Transform.Unit = transformableElement.Transform.Unit;
+            if (styleContext.Transform.Unit == null)
+            {
+                styleContext.Transform.Unit =
+                    FallbackToCssStyle(transformableElement.Transform, styleContext.Transform, styleContext.InlineStyle)
+                        .Unit;
+            }
+            else
+            {
+                styleContext.Transform.Unit = new SvgTransformUnit(
+                    styleContext.Transform.Unit.Value.MatrixValue.Concat(
+                        FallbackToCssStyle(transformableElement.Transform, styleContext.InlineStyle).Unit
+                            ?.MatrixValue ??
+                        Matrix3X3.Identity));
+            }
         }
 
-        if (element is IFillable { Fill.Unit: not null } fillableElement)
+        if (element is IFillable fillableElement)
         {
-            styleContext.Fill.Unit = fillableElement.Fill.Unit;
+            styleContext.Fill.Unit = FallbackToCssStyle(fillableElement.Fill, styleContext.Fill,
+                styleContext.InlineStyle, new SvgColorUnit("black")).Unit;
+            styleContext.FillOpacity.Unit =
+                FallbackToCssStyle(fillableElement.FillOpacity, styleContext.FillOpacity, styleContext.InlineStyle)
+                    .Unit;
         }
 
         if (element is IStrokable strokableElement)
         {
-            if (strokableElement.Stroke.Unit != null)
-            {
-                styleContext.Stroke.Unit = strokableElement.Stroke.Unit;
-            }
+            styleContext.Stroke.Unit =
+                FallbackToCssStyle(strokableElement.Stroke, styleContext.Stroke, styleContext.InlineStyle).Unit;
 
-            if (strokableElement.StrokeWidth.Unit != null)
-            {
-                styleContext.StrokeWidth.Unit = strokableElement.StrokeWidth.Unit;
-            }
-            
-            if (strokableElement.StrokeLineCap.Unit != null)
-            {
-                styleContext.StrokeLineCap.Unit = strokableElement.StrokeLineCap.Unit;
-            }
-            
-            if (strokableElement.StrokeLineJoin.Unit != null)
-            {
-                styleContext.StrokeLineJoin.Unit = strokableElement.StrokeLineJoin.Unit;
-            }
+            styleContext.StrokeWidth.Unit =
+                FallbackToCssStyle(strokableElement.StrokeWidth, styleContext.StrokeWidth, styleContext.InlineStyle)
+                    .Unit;
+
+            styleContext.StrokeLineCap.Unit =
+                FallbackToCssStyle(strokableElement.StrokeLineCap, styleContext.StrokeLineCap, styleContext.InlineStyle)
+                    .Unit;
+
+            styleContext.StrokeLineJoin.Unit =
+                FallbackToCssStyle(strokableElement.StrokeLineJoin, styleContext.StrokeLineJoin,
+                    styleContext.InlineStyle).Unit;
         }
 
+        if (element is IOpacity opacityElement)
+        {
+            styleContext.Opacity.Unit =
+                FallbackToCssStyle(opacityElement.Opacity, styleContext.Opacity, styleContext.InlineStyle).Unit;
+        }
+
+
         return styleContext;
     }
 
@@ -93,11 +128,98 @@ public struct StyleContext
             styleContext.Fill.Unit = Fill.Unit;
         }
 
+        if (FillOpacity.Unit != null)
+        {
+            styleContext.FillOpacity.Unit = FillOpacity.Unit;
+        }
+
         if (Transform.Unit != null)
         {
             styleContext.Transform.Unit = Transform.Unit;
         }
 
+        if (StrokeLineCap.Unit != null)
+        {
+            styleContext.StrokeLineCap.Unit = StrokeLineCap.Unit;
+        }
+
+        if (StrokeLineJoin.Unit != null)
+        {
+            styleContext.StrokeLineJoin.Unit = StrokeLineJoin.Unit;
+        }
+
+        if (Opacity.Unit != null)
+        {
+            styleContext.Opacity.Unit = Opacity.Unit;
+        }
+
+        styleContext.ViewboxOrigin = ViewboxOrigin;
+
+        if (InlineStyle.Unit != null)
+        {
+            styleContext.InlineStyle.Unit = InlineStyle.Unit;
+        }
+
         return styleContext;
     }
+
+
+    private SvgProperty<TUnit>? FallbackToCssStyle<TUnit>(
+        SvgProperty<TUnit> property,
+        SvgProperty<SvgStyleUnit> inlineStyle, TUnit? fallback = null) where TUnit : struct, ISvgUnit
+    {
+        if (property.Unit != null)
+        {
+            return property;
+        }
+
+        SvgStyleUnit? style = inlineStyle.Unit;
+        return style?.TryGetStyleFor<SvgProperty<TUnit>, TUnit>(property.SvgName)
+               ?? (fallback.HasValue
+                   ? new SvgProperty<TUnit>(property.SvgName) { Unit = fallback.Value }
+                   : new SvgProperty<TUnit>(property.SvgName));
+    }
+
+    private SvgProperty<TUnit>? FallbackToCssStyle<TUnit>(
+        SvgProperty<TUnit> property,
+        SvgProperty<TUnit> parentStyleProperty,
+        SvgProperty<SvgStyleUnit> inlineStyle, TUnit? fallback = null) where TUnit : struct, ISvgUnit
+    {
+        if (property.Unit != null)
+        {
+            return property;
+        }
+
+        SvgStyleUnit? style = inlineStyle.Unit;
+        var styleProp = style?.TryGetStyleFor<SvgProperty<TUnit>, TUnit>(property.SvgName);
+        if (styleProp != null) return styleProp;
+        if(parentStyleProperty.Unit != null)
+        {
+            return parentStyleProperty;
+        }
+
+        return (fallback.HasValue
+            ? new SvgProperty<TUnit>(property.SvgName) { Unit = fallback.Value }
+            : new SvgProperty<TUnit>(property.SvgName));
+    }
+
+    private SvgProperty<SvgStyleUnit> MergeInlineStyle(SvgProperty<SvgStyleUnit> elementStyle,
+        SvgProperty<SvgStyleUnit> parentStyle)
+    {
+        SvgStyleUnit? elementStyleUnit = elementStyle.Unit;
+        SvgStyleUnit? parentStyleUnit = parentStyle.Unit;
+
+        if (elementStyleUnit == null)
+        {
+            return parentStyle;
+        }
+
+        if (parentStyleUnit == null)
+        {
+            return elementStyle;
+        }
+
+        SvgStyleUnit style = parentStyleUnit.Value.MergeWith(elementStyleUnit.Value);
+        return new SvgProperty<SvgStyleUnit>("style") { Unit = style };
+    }
 }

+ 16 - 13
src/PixiEditor.SVG/SvgDocument.cs

@@ -7,27 +7,28 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG;
 
-public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFillable, IStrokable
+public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFillable, IStrokable, IOpacity
 {
     public string RootNamespace { get; set; } = "http://www.w3.org/2000/svg";
     public string Version { get; set; } = "1.1";
-    
+
     public SvgProperty<SvgRectUnit> ViewBox { get; } = new("viewBox");
     public SvgProperty<SvgColorUnit> Fill { get; } = new("fill");
     public SvgProperty<SvgColorUnit> Stroke { get; } = new("stroke");
     public SvgProperty<SvgNumericUnit> StrokeWidth { get; } = new("stroke-width");
-    
+
     public SvgProperty<SvgEnumUnit<SvgStrokeLineCap>> StrokeLineCap { get; } = new("stroke-linecap");
-    
+
     public SvgProperty<SvgEnumUnit<SvgStrokeLineJoin>> StrokeLineJoin { get; } = new("stroke-linejoin");
     public SvgProperty<SvgTransformUnit> Transform { get; } = new("transform");
+    public SvgProperty<SvgNumericUnit> Opacity { get; } = new("opacity");
+    public SvgProperty<SvgNumericUnit> FillOpacity { get; } = new("fill-opacity");
     public List<SvgElement> Children { get; } = new();
 
     public SvgDocument() : base("svg")
     {
-        
     }
-    
+
     public SvgDocument(RectD viewBox) : base("svg")
     {
         ViewBox.Unit = new SvgRectUnit(viewBox);
@@ -38,14 +39,16 @@ public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFilla
         List<SvgProperty> properties = new()
         {
             Fill,
+            FillOpacity,
             Stroke,
             StrokeWidth,
             Transform,
             ViewBox,
             StrokeLineCap,
-            StrokeLineJoin
+            StrokeLineJoin,
+            Opacity
         };
-        
+
         ParseAttributes(properties, reader);
     }
 
@@ -102,11 +105,11 @@ public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFilla
 
     private void AppendProperties(XElement? root)
     {
-        if(ViewBox.Unit != null)
+        if (ViewBox.Unit != null)
         {
             root.Add(new XAttribute("viewBox", ViewBox.Unit.Value.ToXml()));
         }
-        
+
         if (Fill.Unit != null)
         {
             root.Add(new XAttribute("fill", Fill.Unit.Value.ToXml()));
@@ -121,17 +124,17 @@ public class SvgDocument : SvgElement, IElementContainer, ITransformable, IFilla
         {
             root.Add(new XAttribute("stroke-width", StrokeWidth.Unit.Value.ToXml()));
         }
-        
+
         if (Transform.Unit != null)
         {
             root.Add(new XAttribute("transform", Transform.Unit.Value.ToXml()));
         }
-        
+
         if (StrokeLineCap.Unit != null)
         {
             root.Add(new XAttribute("stroke-linecap", StrokeLineCap.Unit.Value.ToXml()));
         }
-        
+
         if (StrokeLineJoin.Unit != null)
         {
             root.Add(new XAttribute("stroke-linejoin", StrokeLineJoin.Unit.Value.ToXml()));

+ 24 - 23
src/PixiEditor.SVG/SvgElement.cs

@@ -13,6 +13,7 @@ public class SvgElement(string tagName)
     public Dictionary<string, string> RequiredNamespaces { get; } = new();
     public string TagName { get; } = tagName;
 
+    public SvgProperty<SvgStyleUnit> Style { get; } = new("style");
 
     public XElement ToXml(XNamespace nameSpace)
     {
@@ -25,14 +26,21 @@ public class SvgElement(string tagName)
                 SvgProperty prop = (SvgProperty)property.GetValue(this);
                 if (prop?.Unit != null)
                 {
-                    if (!string.IsNullOrEmpty(prop.NamespaceName))
+                    if (string.IsNullOrEmpty(prop.SvgName))
                     {
-                        XName name = XNamespace.Get(RequiredNamespaces[prop.NamespaceName]) + prop.SvgName;
-                        element.Add(new XAttribute(name, prop.Unit.ToXml()));
+                        element.Value = prop.Unit.ToXml();
                     }
                     else
                     {
-                        element.Add(new XAttribute(prop.SvgName, prop.Unit.ToXml()));
+                        if (!string.IsNullOrEmpty(prop.NamespaceName))
+                        {
+                            XName name = XNamespace.Get(RequiredNamespaces[prop.NamespaceName]) + prop.SvgName;
+                            element.Add(new XAttribute(name, prop.Unit.ToXml()));
+                        }
+                        else
+                        {
+                            element.Add(new XAttribute(prop.SvgName, prop.Unit.ToXml()));
+                        }
                     }
                 }
             }
@@ -57,6 +65,16 @@ public class SvgElement(string tagName)
 
     protected void ParseAttributes(List<SvgProperty> properties, XmlReader reader)
     {
+        if (!properties.Contains(Id))
+        {
+            properties.Insert(0, Id);
+        }
+
+        if (!properties.Contains(Style))
+        {
+            properties.Insert(0, Style);
+        }
+
         do
         {
             SvgProperty matchingProperty = properties.FirstOrDefault(x =>
@@ -76,31 +94,14 @@ public class SvgElement(string tagName)
         }
         else
         {
-            property.Unit ??= CreateDefaultUnit(property);
+            property.Unit ??= property.CreateDefaultUnit();
             property.Unit.ValuesFromXml(reader.Value);
         }
     }
 
     private void ParseListProperty(SvgList list, XmlReader reader)
     {
-        list.Unit ??= CreateDefaultUnit(list);
+        list.Unit ??= list.CreateDefaultUnit();
         list.Unit.ValuesFromXml(reader.Value);
     }
-
-    private ISvgUnit CreateDefaultUnit(SvgProperty property)
-    {
-        var genericType = property.GetType().GetGenericArguments();
-        if (genericType.Length == 0)
-        {
-            throw new InvalidOperationException("Property does not have a generic type");
-        }
-
-        ISvgUnit unit = Activator.CreateInstance(genericType[0]) as ISvgUnit;
-        if (unit == null)
-        {
-            throw new InvalidOperationException("Could not create unit");
-        }
-
-        return unit;
-    }
 }

+ 14 - 12
src/PixiEditor.SVG/SvgParser.cs

@@ -1,4 +1,5 @@
-using System.Xml;
+using System.Globalization;
+using System.Xml;
 using System.Xml.Linq;
 using Drawie.Numerics;
 using PixiEditor.SVG.Elements;
@@ -19,7 +20,8 @@ public class SvgParser
         { "g", typeof(SvgGroup) },
         { "mask", typeof(SvgMask) },
         { "image", typeof(SvgImage) },
-        { "svg", typeof(SvgDocument) }
+        { "svg", typeof(SvgDocument) },
+        { "text", typeof(SvgText) }
     };
 
     public string Source { get; set; }
@@ -35,7 +37,7 @@ public class SvgParser
         using var reader = document.CreateReader();
 
         XmlNodeType node = reader.MoveToContent();
-        if (node != XmlNodeType.Element || reader.Name != "svg")
+        if (node != XmlNodeType.Element || reader.LocalName != "svg")
         {
             return null;
         }
@@ -92,7 +94,7 @@ public class SvgParser
 
     private SvgElement? ParseElement(XmlReader reader)
     {
-        if (wellKnownElements.TryGetValue(reader.Name, out Type elementType))
+        if (wellKnownElements.TryGetValue(reader.LocalName, out Type elementType))
         {
             SvgElement element = (SvgElement)Activator.CreateInstance(elementType);
             if (reader.MoveToFirstAttribute())
@@ -129,16 +131,16 @@ public class SvgParser
             string[] parts = viewBox.Split(' ').Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
             if (parts.Length == 4)
             {
-                finalX = double.Parse(parts[0]);
-                finalY = double.Parse(parts[1]);
-                finalWidth = double.Parse(parts[2]);
-                finalHeight = double.Parse(parts[3]);
+                finalX = double.Parse(parts[0], CultureInfo.InvariantCulture);
+                finalY = double.Parse(parts[1], CultureInfo.InvariantCulture);
+                finalWidth = double.Parse(parts[2], CultureInfo.InvariantCulture);
+                finalHeight = double.Parse(parts[3], CultureInfo.InvariantCulture);
             }
         }
 
         if (x != null)
         {
-            if (double.TryParse(x, out double xValue))
+            if (double.TryParse(x, CultureInfo.InvariantCulture, out double xValue))
             {
                 finalX = xValue;
             }
@@ -146,7 +148,7 @@ public class SvgParser
 
         if (y != null)
         {
-            if (double.TryParse(y, out double yValue))
+            if (double.TryParse(y, CultureInfo.InvariantCulture, out double yValue))
             {
                 finalY = yValue;
             }
@@ -154,7 +156,7 @@ public class SvgParser
 
         if (width != null)
         {
-            if (double.TryParse(width, out double widthValue))
+            if (double.TryParse(width, CultureInfo.InvariantCulture, out double widthValue))
             {
                 finalWidth = widthValue;
             }
@@ -162,7 +164,7 @@ public class SvgParser
 
         if (height != null)
         {
-            if (double.TryParse(height, out double heightValue))
+            if (double.TryParse(height, CultureInfo.InvariantCulture, out double heightValue))
             {
                 finalHeight = heightValue;
             }

+ 20 - 3
src/PixiEditor.SVG/SvgProperty.cs

@@ -9,7 +9,7 @@ public abstract class SvgProperty
     {
         SvgName = svgName;
     }
-    
+
     protected SvgProperty(string svgName, string? namespaceName) : this(svgName)
     {
         NamespaceName = namespaceName;
@@ -18,6 +18,23 @@ public abstract class SvgProperty
     public string? NamespaceName { get; set; }
     public string SvgName { get; set; }
     public ISvgUnit? Unit { get; set; }
+
+    public ISvgUnit? CreateDefaultUnit()
+    {
+        var genericType = this.GetType().GetGenericArguments();
+        if (genericType.Length == 0)
+        {
+            return null;
+        }
+
+        ISvgUnit unit = Activator.CreateInstance(genericType[0]) as ISvgUnit;
+        if (unit == null)
+        {
+            throw new InvalidOperationException("Could not create unit");
+        }
+
+        return unit;
+    }
 }
 
 public class SvgProperty<T> : SvgProperty where T : struct, ISvgUnit
@@ -27,11 +44,11 @@ public class SvgProperty<T> : SvgProperty where T : struct, ISvgUnit
         get => (T?)base.Unit;
         set => base.Unit = value;
     }
-    
+
     public SvgProperty(string svgName) : base(svgName)
     {
     }
-    
+
     public SvgProperty(string svgName, string? namespaceName) : base(svgName, namespaceName)
     {
     }

Some files were not shown because too many files changed in this diff