Quellcode durchsuchen

Merge branch 'master' into posterization-node

Jakub Ciemała vor 1 Woche
Ursprung
Commit
07949d6ded
97 geänderte Dateien mit 1916 neuen und 518 gelöschten Zeilen
  1. 23 0
      .github/workflows/localization-key-reference.yml
  2. 1 0
      .github/workflows/localization/check-key-references.pip.txt
  3. 70 0
      .github/workflows/localization/check-key-references.py
  4. 10 2
      src/ChunkyImageLib/Chunk.cs
  5. 6 6
      src/ChunkyImageLib/ChunkyImage.cs
  6. 8 7
      src/ChunkyImageLib/ChunkyImageEx.cs
  7. 2 2
      src/ChunkyImageLib/IReadOnlyChunkyImage.cs
  8. 5 5
      src/ChunkyImageLib/Operations/ChunkyImageOperation.cs
  9. 0 81
      src/Custom.ruleset
  10. 2 8
      src/Directory.Build.props
  11. 1 1
      src/Drawie
  12. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Interfaces/SceneObjectRenderContext.cs
  13. 3 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs
  14. 5 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Effects/OutlineNode.cs
  15. 84 27
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ApplyFilterNode.cs
  16. 15 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Image/MaskNode.cs
  17. 29 3
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ImageLayerNode.cs
  18. 14 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/LayerNode.cs
  19. 163 3
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/NoiseNode.cs
  20. 1 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/OutputNode.cs
  21. 11 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs
  22. 10 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs
  23. 7 4
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/StructureNode.cs
  24. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/TextureCache.cs
  25. 8 3
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs
  26. 2 1
      src/PixiEditor.ChangeableDocument/Changes/NodeGraph/EvaluateGraph_Change.cs
  27. 21 6
      src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs
  28. 5 3
      src/PixiEditor.ChangeableDocument/Rendering/RenderContext.cs
  29. 5 0
      src/PixiEditor.Extensions.CommonApi/UserPreferences/PreferencesConstants.cs
  30. 44 17
      src/PixiEditor.Extensions.CommonApi/UserPreferences/Settings/PixiEditor/PixiEditorSettings.cs
  31. BIN
      src/PixiEditor.Extensions.Sdk/build/PixiEditor.Api.CGlueMSBuild.dll
  32. 1 0
      src/PixiEditor.IdentityProvider.PixiAuth/PixiAuthIdentityProvider.cs
  33. 0 1
      src/PixiEditor.sln
  34. 27 1
      src/PixiEditor/Data/Localization/Languages/en.json
  35. 27 17
      src/PixiEditor/Helpers/Converters/EnumToLocalizedStringConverter.cs
  36. 0 19
      src/PixiEditor/Helpers/EnumDescriptionConverter.cs
  37. 18 0
      src/PixiEditor/Helpers/LocalizeEnumAttribute.cs
  38. 26 7
      src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs
  39. 2 1
      src/PixiEditor/Models/DocumentModels/DocumentStructureHelper.cs
  40. 3 2
      src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs
  41. 16 15
      src/PixiEditor/Models/DocumentModels/Public/DocumentStructureModule.cs
  42. 7 0
      src/PixiEditor/Models/DocumentPassthroughActions/RefreshPreviews_PassthroughAction.cs
  43. 126 0
      src/PixiEditor/Models/EnumTranslations.cs
  44. 2 1
      src/PixiEditor/Models/Handlers/IDocument.cs
  45. 11 7
      src/PixiEditor/Models/Handlers/INodeHandler.cs
  46. 1 0
      src/PixiEditor/Models/Handlers/INodePropertyHandler.cs
  47. 1 0
      src/PixiEditor/Models/Handlers/IToolsHandler.cs
  48. 5 1
      src/PixiEditor/Models/Handlers/Toolbars/PaintBrushShape.cs
  49. 29 11
      src/PixiEditor/Models/Rendering/AffectedAreasGatherer.cs
  50. 47 22
      src/PixiEditor/Models/Rendering/MemberPreviewUpdater.cs
  51. 40 3
      src/PixiEditor/Models/Rendering/PreviewPainter.cs
  52. 35 13
      src/PixiEditor/Models/Rendering/SceneRenderer.cs
  53. 144 0
      src/PixiEditor/Models/Structures/ObservableHashSet.cs
  54. 5 1
      src/PixiEditor/Models/Tools/BrightnessMode.cs
  55. 2 2
      src/PixiEditor/Properties/AssemblyInfo.cs
  56. 4 7
      src/PixiEditor/Styles/Templates/NodeFrameView.axaml
  57. 4 10
      src/PixiEditor/Styles/Templates/NodeGraphView.axaml
  58. 15 0
      src/PixiEditor/ViewModels/Document/DocumentViewModel.cs
  59. 81 0
      src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs
  60. 21 1
      src/PixiEditor/ViewModels/Document/Nodes/FilterNodes/ApplyFilterNodeViewModel.cs
  61. 1 1
      src/PixiEditor/ViewModels/Document/Nodes/ModifyImageLeftNodeViewModel.cs
  62. 1 1
      src/PixiEditor/ViewModels/Document/Nodes/ModifyImageRightNodeViewModel.cs
  63. 29 1
      src/PixiEditor/ViewModels/Document/Nodes/NoiseNodeViewModel.cs
  64. 2 2
      src/PixiEditor/ViewModels/Document/Nodes/StructureMemberViewModel.cs
  65. 2 1
      src/PixiEditor/ViewModels/Document/StructureTree.cs
  66. 6 0
      src/PixiEditor/ViewModels/Nodes/IPairNodeEndViewModel.cs
  67. 6 0
      src/PixiEditor/ViewModels/Nodes/IPairNodeStartViewModel.cs
  68. 2 28
      src/PixiEditor/ViewModels/Nodes/NodeFrameViewModel.cs
  69. 23 21
      src/PixiEditor/ViewModels/Nodes/NodeFrameViewModelBase.cs
  70. 2 0
      src/PixiEditor/ViewModels/Nodes/NodePropertyViewModel.cs
  71. 99 24
      src/PixiEditor/ViewModels/Nodes/NodeViewModel.cs
  72. 222 35
      src/PixiEditor/ViewModels/Nodes/NodeZoneViewModel.cs
  73. 19 0
      src/PixiEditor/ViewModels/Nodes/Traverse.cs
  74. 4 0
      src/PixiEditor/ViewModels/PixiObservableObject.cs
  75. 16 3
      src/PixiEditor/ViewModels/SettingsWindowViewModel.cs
  76. 17 0
      src/PixiEditor/ViewModels/SubViewModels/ToolsViewModel.cs
  77. 19 0
      src/PixiEditor/ViewModels/SubViewModels/ViewOptionsViewModel.cs
  78. 4 3
      src/PixiEditor/ViewModels/SubViewModels/ViewportWindowViewModel.cs
  79. 21 0
      src/PixiEditor/ViewModels/UserPreferences/Settings/PerformanceSettings.cs
  80. 9 0
      src/PixiEditor/ViewModels/UserPreferences/Settings/SceneSettings.cs
  81. 16 1
      src/PixiEditor/ViewModels/UserPreferences/SettingsGroup.cs
  82. 2 0
      src/PixiEditor/ViewModels/UserPreferences/SettingsViewModel.cs
  83. 1 0
      src/PixiEditor/Views/Dock/DocumentTemplate.axaml
  84. 1 0
      src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml
  85. 8 0
      src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml.cs
  86. 9 1
      src/PixiEditor/Views/Main/ViewportControls/ViewportOverlays.cs
  87. 6 21
      src/PixiEditor/Views/Nodes/NodeFrameView.cs
  88. 15 2
      src/PixiEditor/Views/Nodes/Properties/StringPropertyView.axaml.cs
  89. 8 1
      src/PixiEditor/Views/Overlays/BrushShapeOverlay/BrushShape.cs
  90. 2 1
      src/PixiEditor/Views/Overlays/TextOverlay/TextOverlay.cs
  91. 25 1
      src/PixiEditor/Views/Rendering/Scene.cs
  92. 3 4
      src/PixiEditor/Views/Tools/ToolSettings/Settings/EnumSettingView.axaml
  93. 8 0
      src/PixiEditor/Views/Visuals/PreviewPainterControl.cs
  94. 46 2
      src/PixiEditor/Views/Windows/Settings/SettingsWindow.axaml
  95. 0 20
      src/stylecop.json
  96. 3 2
      tests/PixiEditor.Tests/BlendingTests.cs
  97. 2 1
      tests/PixiEditor.Tests/RenderTests.cs

+ 23 - 0
.github/workflows/localization-key-reference.yml

@@ -0,0 +1,23 @@
+name: Localization Reference Key Check
+
+on:
+  push:
+    branches: [ "master" ]
+  pull_request:
+    branches: [ "master" ]
+
+jobs:
+  check-key-references:
+    name: "Check Localization Keys are referenced"
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.13'
+          cache: 'pip'
+          cache-dependency-path: .github/workflows/localization/check-key-references.pip.txt
+      - name: Install dependencies
+        run: pip install -r .github/workflows/localization/check-key-references.pip.txt
+      - name: Check Localization Key References
+        run: python .github/workflows/localization/check-key-references.py

+ 1 - 0
.github/workflows/localization/check-key-references.pip.txt

@@ -0,0 +1 @@
+pyahocorasick

+ 70 - 0
.github/workflows/localization/check-key-references.py

@@ -0,0 +1,70 @@
+import json
+import os
+import logging
+from collections import OrderedDict
+import time
+import ahocorasick
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger()
+
+# PATHS
+REFERENCE_LANGUAGE = "src/PixiEditor/Data/Localization/Languages/en.json"
+SEARCH_DIRECTORIES = ["src/PixiEditor/Views", "src/PixiEditor/ViewModels", "src/PixiEditor", "src/"]
+IGNORE_DIRECTORIES = ["src/PixiEditor/Data/Localization"]
+
+def load_json(file_path):
+    """Load language JSON"""
+    try:
+        with open(file_path, "r", encoding="utf-8-sig") as f:
+            return json.load(f, object_pairs_hook=OrderedDict)
+    except FileNotFoundError:
+        print(f"::error::File not found: {file_path}")
+        return OrderedDict()
+    except json.JSONDecodeError as e:
+        print(f"::error::Failed to parse JSON in {file_path}: {e}")
+        return OrderedDict()
+
+def build_automaton(keys: list[str]) -> ahocorasick.Automaton:
+    A = ahocorasick.Automaton()
+    for i, k in enumerate(keys):
+        A.add_word(k, (i, k))
+    A.make_automaton()
+    return A
+
+def find_missing_keys(keys):
+    automaton = build_automaton(keys)
+    present = set()
+
+    ignore_prefixes = tuple(os.path.abspath(p) for p in IGNORE_DIRECTORIES)
+    for base_dir in SEARCH_DIRECTORIES:
+        for root, dirs, files in os.walk(base_dir, topdown=True):
+            dirs[:] = [d for d in dirs if not os.path.abspath(os.path.join(root, d)).startswith(ignore_prefixes)]
+            for file in files:
+                with open(os.path.join(root, file), "r", encoding="utf‑8", errors="ignore") as f:
+                    for _, (_, k) in automaton.iter(f.read()):
+                        present.add(k)
+                        if len(present) == len(keys):
+                            return []
+    return sorted(set(keys) - present)
+
+def main():
+    keys = load_json(REFERENCE_LANGUAGE)
+
+    print("Searching trough keys...")
+    start = time.time()
+    missing_keys = find_missing_keys(keys)
+    end = time.time()
+    print(f"Done, searching took {end - start}s")
+
+    if len(missing_keys) > 0:
+        print("Unreferenced keys have been found")
+        for key in missing_keys:
+            print(f"::error file={REFERENCE_LANGUAGE},title=Unreferenced key::No reference to '{key}' found")
+        return 1
+    else:
+        print("All keys have been referenced")
+        return 0
+    
+if __name__ == "__main__":
+    exit(main())

+ 10 - 2
src/ChunkyImageLib/Chunk.cs

@@ -83,9 +83,17 @@ public class Chunk : IDisposable
     /// </summary>
     /// <param name="pos">The destination for the <paramref name="surface"/></param>
     /// <param name="paint">The paint to use while drawing</param>
-    public void DrawChunkOn(DrawingSurface surface, VecD pos, Paint? paint = null)
+    public void DrawChunkOn(DrawingSurface surface, VecD pos, Paint? paint = null, SamplingOptions? samplingOptions = null)
     {
-        surface.Canvas.DrawSurface(Surface.DrawingSurface, (float)pos.X, (float)pos.Y, paint);
+        if (samplingOptions == null || samplingOptions == SamplingOptions.Default)
+        {
+            surface.Canvas.DrawSurface(Surface.DrawingSurface, (float)pos.X, (float)pos.Y, paint);
+        }
+        else
+        {
+            using var snapshot = Surface.DrawingSurface.Snapshot();
+            surface.Canvas.DrawImage(snapshot, (float)pos.X, (float)pos.Y, samplingOptions.Value, paint);
+        }
     }
 
     public unsafe RectI? FindPreciseBounds(RectI? passedSearchRegion = null)

+ 6 - 6
src/ChunkyImageLib/ChunkyImage.cs

@@ -365,7 +365,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICache
     /// </returns>
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecD pos,
-        Paint? paint = null)
+        Paint? paint = null, SamplingOptions? samplingOptions = null)
     {
         lock (lockObject)
         {
@@ -390,7 +390,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICache
             {
                 if (committedChunk is null)
                     return false;
-                committedChunk.DrawChunkOn(surface, pos, paint);
+                committedChunk.DrawChunkOn(surface, pos, paint, samplingOptions);
                 return true;
             }
 
@@ -399,7 +399,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICache
             {
                 if (latestChunk.IsT2)
                 {
-                    latestChunk.AsT2.DrawChunkOn(surface, pos, paint);
+                    latestChunk.AsT2.DrawChunkOn(surface, pos, paint, samplingOptions);
                     return true;
                 }
 
@@ -415,7 +415,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICache
                 blendModePaint);
             if (lockTransparency)
                 OperationHelper.ClampAlpha(tempChunk.Surface.DrawingSurface, committedChunk.Surface.DrawingSurface);
-            tempChunk.DrawChunkOn(surface, pos, paint);
+            tempChunk.DrawChunkOn(surface, pos, paint, samplingOptions);
 
             return true;
         }
@@ -458,7 +458,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICache
 
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public bool DrawCommittedChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecD pos,
-        Paint? paint = null)
+        Paint? paint = null, SamplingOptions? samplingOptions = null)
     {
         lock (lockObject)
         {
@@ -466,7 +466,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICache
             var chunk = GetCommittedChunk(chunkPos, resolution);
             if (chunk is null)
                 return false;
-            chunk.DrawChunkOn(surface, pos, paint);
+            chunk.DrawChunkOn(surface, pos, paint, samplingOptions);
             return true;
         }
     }

+ 8 - 7
src/ChunkyImageLib/ChunkyImageEx.cs

@@ -19,9 +19,10 @@ public static class IReadOnlyChunkyImageEx
     /// <param name="pos">Starting position on the surface</param>
     /// <param name="paint">Paint to use for drawing</param>
     public static void DrawMostUpToDateRegionOn
-        (this IReadOnlyChunkyImage image, RectI fullResRegion, ChunkResolution resolution, DrawingSurface surface, VecD pos, Paint? paint = null)
+    (this IReadOnlyChunkyImage image, RectI fullResRegion, ChunkResolution resolution, DrawingSurface surface,
+        VecD pos, Paint? paint = null, SamplingOptions? sampling = null)
     {
-        DrawRegionOn(fullResRegion, resolution, surface, pos, image.DrawMostUpToDateChunkOn, paint);
+        DrawRegionOn(fullResRegion, resolution, surface, pos, image.DrawMostUpToDateChunkOn, paint, sampling);
     }
     
     /// <summary>
@@ -35,9 +36,9 @@ public static class IReadOnlyChunkyImageEx
     /// <param name="pos">Starting position on the surface</param>
     /// <param name="paint">Paint to use for drawing</param>
     public static void DrawCommittedRegionOn
-        (this IReadOnlyChunkyImage image, RectI fullResRegion, ChunkResolution resolution, DrawingSurface surface, VecI pos, Paint? paint = null)
+        (this IReadOnlyChunkyImage image, RectI fullResRegion, ChunkResolution resolution, DrawingSurface surface, VecI pos, Paint? paint = null, SamplingOptions? samplingOptions = null)
     {
-        DrawRegionOn(fullResRegion, resolution, surface, pos, image.DrawCommittedChunkOn, paint);
+        DrawRegionOn(fullResRegion, resolution, surface, pos, image.DrawCommittedChunkOn, paint, samplingOptions);
     }
     
     private static void DrawRegionOn(
@@ -45,8 +46,8 @@ public static class IReadOnlyChunkyImageEx
         ChunkResolution resolution,
         DrawingSurface surface,
         VecD pos,
-        Func<VecI, ChunkResolution, DrawingSurface, VecD, Paint?, bool> drawingFunc,
-        Paint? paint = null)
+        Func<VecI, ChunkResolution, DrawingSurface, VecD, Paint?, SamplingOptions?, bool> drawingFunc,
+        Paint? paint = null, SamplingOptions? samplingOptions = null)
     {
         int count = surface.Canvas.Save();
         surface.Canvas.ClipRect(new RectD(pos, fullResRegion.Size));
@@ -61,7 +62,7 @@ public static class IReadOnlyChunkyImageEx
             for (int i = chunkTopLeft.X; i <= chunkBotRight.X; i++)
             {
                 var chunkPos = new VecI(i, j);
-                drawingFunc(chunkPos, resolution, surface, offsetTargetRes + (chunkPos - chunkTopLeft) * resolution.PixelSize() + pos, paint);
+                drawingFunc(chunkPos, resolution, surface, offsetTargetRes + (chunkPos - chunkTopLeft) * resolution.PixelSize() + pos, paint, samplingOptions);
             }
         }
 

+ 2 - 2
src/ChunkyImageLib/IReadOnlyChunkyImage.cs

@@ -10,8 +10,8 @@ namespace ChunkyImageLib;
 
 public interface IReadOnlyChunkyImage
 {
-    bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecD pos, Paint? paint = null);
-    bool DrawCommittedChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecD pos, Paint? paint = null);
+    bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecD pos, Paint? paint = null, SamplingOptions? sampling = null);
+    bool DrawCommittedChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecD pos, Paint? paint = null, SamplingOptions? sampling = null);
     RectI? FindChunkAlignedMostUpToDateBounds();
     RectI? FindChunkAlignedCommittedBounds();
     RectI? FindTightCommittedBounds(ChunkResolution precision = ChunkResolution.Full, bool fallbackToChunkAligned = false);

+ 5 - 5
src/ChunkyImageLib/Operations/ChunkyImageOperation.cs

@@ -63,13 +63,13 @@ internal class ChunkyImageOperation : IMirroredDrawOperation
         VecI bottomLeft = OperationHelper.GetChunkPos(
             new VecI(chunkCenterOnImage.X - halfChunk.X, chunkCenterOnImage.Y + halfChunk.Y), ChunkyImage.FullChunkSize);
 
-        Func<VecI, ChunkResolution, DrawingSurface, VecD, Paint?, bool> drawMethod = drawUpToDate ? imageToDraw.DrawMostUpToDateChunkOn : imageToDraw.DrawCommittedChunkOn;
+        Func<VecI, ChunkResolution, DrawingSurface, VecD, Paint?, SamplingOptions?, bool> drawMethod = drawUpToDate ? imageToDraw.DrawMostUpToDateChunkOn : imageToDraw.DrawCommittedChunkOn;
         
         drawMethod(
             topLeft,
             targetChunk.Resolution,
             targetChunk.Surface.DrawingSurface,
-            (VecI)((topLeft * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * targetChunk.Resolution.Multiplier()), null);
+            (VecI)((topLeft * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * targetChunk.Resolution.Multiplier()), null, null);
 
         VecI gridShift = targetPos % ChunkyImage.FullChunkSize;
         if (gridShift.X != 0)
@@ -79,7 +79,7 @@ internal class ChunkyImageOperation : IMirroredDrawOperation
             targetChunk.Resolution,
             targetChunk.Surface.DrawingSurface,
             (VecI)((topRight * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * targetChunk.Resolution.Multiplier()),
-            null);
+            null, null);
         }
         if (gridShift.Y != 0)
         {
@@ -88,7 +88,7 @@ internal class ChunkyImageOperation : IMirroredDrawOperation
             targetChunk.Resolution,
             targetChunk.Surface.DrawingSurface,
             (VecI)((bottomLeft * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * targetChunk.Resolution.Multiplier()),
-            null);
+            null, null);
         }
         if (gridShift.X != 0 && gridShift.Y != 0)
         {
@@ -97,7 +97,7 @@ internal class ChunkyImageOperation : IMirroredDrawOperation
             targetChunk.Resolution,
             targetChunk.Surface.DrawingSurface,
             (VecI)((bottomRight * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * targetChunk.Resolution.Multiplier()),
-            null);
+            null, null);
         }
 
         targetChunk.Surface.DrawingSurface.Canvas.Restore();

+ 0 - 81
src/Custom.ruleset

@@ -13,85 +13,4 @@
     <Rule Id="CA1303" Action="None" />
     <Rule Id="CA1416" Action="None" />
   </Rules>
-  <Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers">
-    <Rule Id="SA0001" Action="None" />
-    <Rule Id="SA1000" Action="None" />
-    <Rule Id="SA1005" Action="None" />
-    <Rule Id="SA1008" Action="None" />
-    <Rule Id="SA1009" Action="None" />
-    <Rule Id="SA1011" Action="None" />
-    <Rule Id="SA1023" Action="None" />
-    <Rule Id="SA1028" Action="None" />
-    <Rule Id="SA1101" Action="None" />
-    <Rule Id="SA1110" Action="None" />
-    <Rule Id="SA1111" Action="None" />
-    <Rule Id="SA1112" Action="None" />
-    <Rule Id="SA1117" Action="None" />
-    <Rule Id="SA1119" Action="None" />
-    <Rule Id="SA1121" Action="None" />
-    <Rule Id="SA1122" Action="None" />
-    <Rule Id="SA1124" Action="None" />
-    <Rule Id="SA1127" Action="None" />
-    <Rule Id="SA1128" Action="None" />
-    <Rule Id="SA1129" Action="None" />
-    <Rule Id="SA1130" Action="None" />
-    <Rule Id="SA1132" Action="None" />
-    <Rule Id="SA1135" Action="None" />
-    <Rule Id="SA1136" Action="None" />
-    <Rule Id="SA1139" Action="None" />
-    <Rule Id="SA1200" Action="None" />
-    <Rule Id="SA1201" Action="None" />
-    <Rule Id="SA1202" Action="None" />
-    <Rule Id="SA1204" Action="None" />
-    <Rule Id="SA1207" Action="None" />
-    <Rule Id="SA1208" Action="None" />
-    <Rule Id="SA1209" Action="None" />
-    <Rule Id="SA1210" Action="None" />
-    <Rule Id="SA1211" Action="None" />
-    <Rule Id="SA1214" Action="None" />
-    <Rule Id="SA1216" Action="None" />
-    <Rule Id="SA1217" Action="None" />
-    <Rule Id="SA1303" Action="None" />
-    <Rule Id="SA1304" Action="None" />
-    <Rule Id="SA1307" Action="None" />
-    <Rule Id="SA1309" Action="None" />
-    <Rule Id="SA1310" Action="None" />
-    <Rule Id="SA1311" Action="None" />
-    <Rule Id="SA1313" Action="None" />
-    <Rule Id="SA1316" Action="None" />
-    <Rule Id="SA1400" Action="None" />
-    <Rule Id="SA1401" Action="None" />
-    <Rule Id="SA1402" Action="None" />
-    <Rule Id="SA1405" Action="None" />
-    <Rule Id="SA1406" Action="None" />
-    <Rule Id="SA1407" Action="None" />
-    <Rule Id="SA1408" Action="None" />
-    <Rule Id="SA1410" Action="None" />
-    <Rule Id="SA1411" Action="None" />
-    <Rule Id="SA1413" Action="None" />
-    <Rule Id="SA1501" Action="None" />
-    <Rule Id="SA1502" Action="None" />
-    <Rule Id="SA1503" Action="None" />
-    <Rule Id="SA1505" Action="None" />
-    <Rule Id="SA1507" Action="None" />
-    <Rule Id="SA1508" Action="None" />
-    <Rule Id="SA1512" Action="None" />
-    <Rule Id="SA1513" Action="None" />
-    <Rule Id="SA1515" Action="None" />
-    <Rule Id="SA1516" Action="None" />
-    <Rule Id="SA1518" Action="None" />
-    <Rule Id="SA1600" Action="None" />
-    <Rule Id="SA1601" Action="None" />
-    <Rule Id="SA1602" Action="None" />
-    <Rule Id="SA1604" Action="None" />
-    <Rule Id="SA1605" Action="None" />
-    <Rule Id="SA1606" Action="None" />
-    <Rule Id="SA1607" Action="None" />
-    <Rule Id="SA1623" Action="None" />
-    <Rule Id="SA1629" Action="None" />
-    <Rule Id="SA1633" Action="None" />
-    <Rule Id="SA1642" Action="None" />
-    <Rule Id="SA1643" Action="None" />
-    <Rule Id="SA1648" Action="None" />
-  </Rules>
 </RuleSet>

+ 2 - 8
src/Directory.Build.props

@@ -1,15 +1,9 @@
 <Project>
     <PropertyGroup>
-        <CodeAnalysisRuleSet>../Custom.ruleset</CodeAnalysisRuleSet>
+        <CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)Custom.ruleset</CodeAnalysisRuleSet>
 		    <AvaloniaVersion>11.3.0</AvaloniaVersion>
     </PropertyGroup>
-    <ItemGroup>
-        <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" />
-    </ItemGroup>
-    <ItemGroup>
-        <AdditionalFiles Include="$(SolutionDir)/stylecop.json" />
-    </ItemGroup>
-
+  
   <PropertyGroup Condition="$([MSBuild]::IsOsPlatform('Windows')) AND '$(Platform)' == 'x64'">
     <RuntimeIdentifier>win-x64</RuntimeIdentifier>
   </PropertyGroup>

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit 1be85ac9f4bc6b584e6a3a5a3d0287201c6a5f03
+Subproject commit b6c34c96ac5b01abad69604465445270270270d2

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

@@ -13,7 +13,7 @@ public class SceneObjectRenderContext : RenderContext
     public RenderOutputProperty TargetPropertyOutput { get; }
 
     public SceneObjectRenderContext(RenderOutputProperty targetPropertyOutput, DrawingSurface surface, RectD localBounds, KeyFrameTime frameTime,
-        ChunkResolution chunkResolution, VecI renderOutputSize, VecI documentSize, bool renderSurfaceIsScene, ColorSpace processingColorSpace, double opacity) : base(surface, frameTime, chunkResolution, renderOutputSize, documentSize, processingColorSpace, opacity)
+        ChunkResolution chunkResolution, VecI renderOutputSize, VecI documentSize, bool renderSurfaceIsScene, ColorSpace processingColorSpace, SamplingOptions desiredSampling, double opacity) : base(surface, frameTime, chunkResolution, renderOutputSize, documentSize, processingColorSpace, desiredSampling, opacity)
     {
         TargetPropertyOutput = targetPropertyOutput;
         LocalBounds = localBounds;

+ 3 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/CreateImageNode.cs

@@ -72,10 +72,9 @@ public class CreateImageNode : Node, IPreviewRenderable
 
         int saved = surface.DrawingSurface.Canvas.Save();
 
-        RenderContext ctx = new RenderContext(surface.DrawingSurface, context.FrameTime, context.ChunkResolution,
-            surface.Size, context.DocumentSize, context.ProcessingColorSpace);
-        ctx.FullRerender = context.FullRerender;
-        ctx.TargetOutput = context.TargetOutput;
+        RenderContext ctx = context.Clone();
+        ctx.RenderSurface = surface.DrawingSurface;
+        ctx.RenderOutputSize = surface.Size;
 
         float chunkMultiplier = (float)context.ChunkResolution.Multiplier();
 

+ 5 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Effects/OutlineNode.cs

@@ -88,7 +88,8 @@ public class OutlineNode : RenderNode, IRenderInput
 
             var ctx = context.Clone();
             ctx.ChunkResolution = ChunkResolution.Full;
-            ctx.RenderOutputSize = (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier());
+            bool isAdjusted = context.DocumentSize == context.RenderOutputSize;
+            ctx.RenderOutputSize = isAdjusted ? context.RenderOutputSize : (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier());
 
             Background.Value.Paint(ctx, temp.DrawingSurface);
 
@@ -124,7 +125,10 @@ public class OutlineNode : RenderNode, IRenderInput
 
     public override bool RenderPreview(DrawingSurface renderOn, RenderContext context, string elementToRenderName)
     {
+        int saved = renderOn.Canvas.Save();
+        renderOn.Canvas.Scale((float)context.ChunkResolution.Multiplier());
         OnPaint(context, renderOn);
+        renderOn.Canvas.RestoreToCount(saved);
         return true;
     }
 

+ 84 - 27
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FilterNodes/ApplyFilterNode.cs

@@ -11,67 +11,124 @@ using Drawie.Numerics;
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
 
 [NodeInfo("ApplyFilter")]
-public class ApplyFilterNode : RenderNode, IRenderInput
+public sealed class ApplyFilterNode : RenderNode, IRenderInput
 {
-    private Paint _paint = new();
+    private readonly Paint _paint = new();
+    private readonly Paint _maskPaint = new()
+    {
+        BlendMode = BlendMode.DstIn,
+        ColorFilter = Filters.MaskFilter
+    };
+
     public InputProperty<Filter?> Filter { get; }
 
     public RenderInputProperty Background { get; }
 
+    public RenderInputProperty Mask { get; }
+    
+    public InputProperty<bool> InvertMask { get; }
+
     public ApplyFilterNode()
     {
         Background = CreateRenderInput("Input", "IMAGE");
         Filter = CreateInput<Filter>("Filter", "FILTER", null);
+        Mask = CreateRenderInput("Mask", "MASK");
+        InvertMask = CreateInput("InvertMask", "INVERT_MASK", false);
         Output.FirstInChain = null;
         AllowHighDpiRendering = true;
     }
 
-
     protected override void Paint(RenderContext context, DrawingSurface surface)
     {
         AllowHighDpiRendering = (Background.Connection.Node as RenderNode)?.AllowHighDpiRendering ?? true;
         base.Paint(context, surface);
     }
 
-    protected override void OnPaint(RenderContext context, DrawingSurface surface)
+    protected override void OnPaint(RenderContext context, DrawingSurface outputSurface)
     {
-        if (_paint == null)
-            return;
+        using var _ = DetermineTargetSurface(context, outputSurface, out var processingSurface);
+
+        DrawWithFilter(context, outputSurface, processingSurface);
+        
+        // If the Mask is null, we already drew to the output surface, otherwise we still need to draw to it (and apply the mask)
+        if (processingSurface != outputSurface)
+        {
+            ApplyWithMask(context, processingSurface, outputSurface);
+        }
+    }
 
+    private void DrawWithFilter(RenderContext context, DrawingSurface outputSurface, DrawingSurface processingSurface)
+    {
         _paint.SetFilters(Filter.Value);
 
         if (!context.ProcessingColorSpace.IsSrgb)
         {
-            var intermediate = Texture.ForProcessing(surface, context.ProcessingColorSpace);
+            HandleNonSrgbContext(context, outputSurface, processingSurface);
+            return;
+        }
+
+        var layer = processingSurface.Canvas.SaveLayer(_paint);
+        Background.Value?.Paint(context, processingSurface);
+        processingSurface.Canvas.RestoreToCount(layer);
+    }
 
-            int saved = surface.Canvas.Save();
-            surface.Canvas.SetMatrix(Matrix3X3.Identity);
+    private void HandleNonSrgbContext(RenderContext context, DrawingSurface surface, DrawingSurface targetSurface)
+    {
+        using var intermediate = Texture.ForProcessing(surface, context.ProcessingColorSpace);
 
-            Background.Value?.Paint(context, intermediate.DrawingSurface);
+        Background.Value?.Paint(context, intermediate.DrawingSurface);
 
-            var srgbSurface = Texture.ForProcessing(intermediate.Size, ColorSpace.CreateSrgb());
+        using var srgbSurface = Texture.ForProcessing(intermediate.Size, ColorSpace.CreateSrgb());
 
-            srgbSurface.DrawingSurface.Canvas.SaveLayer(_paint);
-            srgbSurface.DrawingSurface.Canvas.DrawSurface(intermediate.DrawingSurface, 0, 0);
-            srgbSurface.DrawingSurface.Canvas.Restore();
+        srgbSurface.DrawingSurface.Canvas.SaveLayer(_paint);
+        srgbSurface.DrawingSurface.Canvas.DrawSurface(intermediate.DrawingSurface, 0, 0);
+        srgbSurface.DrawingSurface.Canvas.Restore();
 
-            surface.Canvas.DrawSurface(srgbSurface.DrawingSurface, 0, 0);
-            surface.Canvas.RestoreToCount(saved);
-            intermediate.Dispose();
-            srgbSurface.Dispose();
-        }
-        else
-        {
-            int layer = surface.Canvas.SaveLayer(_paint);
-            Background.Value?.Paint(context, surface);
-            surface.Canvas.RestoreToCount(layer);
-        }
+        var saved = targetSurface.Canvas.Save();
+        targetSurface.Canvas.SetMatrix(Matrix3X3.Identity);
+
+        targetSurface.Canvas.DrawSurface(srgbSurface.DrawingSurface, 0, 0);
+        targetSurface.Canvas.RestoreToCount(saved);
+    }
+
+    private Texture? DetermineTargetSurface(RenderContext context, DrawingSurface outputSurface, out DrawingSurface targetSurface)
+    {
+        targetSurface = outputSurface;
+        
+        if (Mask.Value == null)
+            return null;
+        
+        Background.Value?.Paint(context, outputSurface);
+        var texture = Texture.ForProcessing(outputSurface, context.ProcessingColorSpace);
+        targetSurface = texture.DrawingSurface;
+        
+        return texture;
     }
 
-    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "")
+    private void ApplyWithMask(RenderContext context, DrawingSurface processedSurface, DrawingSurface finalSurface)
     {
-        return PreviewUtils.FindPreviewBounds(Background.Connection, frame, elementToRenderName);
+        _maskPaint.BlendMode = !InvertMask.Value ? BlendMode.DstIn : BlendMode.DstOut;
+        var maskLayer = processedSurface.Canvas.SaveLayer(_maskPaint);
+        Mask.Value?.Paint(context, processedSurface);
+        processedSurface.Canvas.RestoreToCount(maskLayer);
+
+        var saved = finalSurface.Canvas.Save();
+        finalSurface.Canvas.SetMatrix(Matrix3X3.Identity);
+
+        finalSurface.Canvas.DrawSurface(processedSurface, 0, 0);
+        finalSurface.Canvas.RestoreToCount(saved);
     }
 
+    public override RectD? GetPreviewBounds(int frame, string elementToRenderName = "") =>
+        PreviewUtils.FindPreviewBounds(Background.Connection, frame, elementToRenderName);
+
     public override Node CreateCopy() => new ApplyFilterNode();
+
+    public override void Dispose()
+    {
+        base.Dispose();
+        
+        _paint.Dispose();
+        _maskPaint.Dispose();
+    }
 }

+ 15 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Image/MaskNode.cs

@@ -6,20 +6,23 @@ using PixiEditor.ChangeableDocument.Rendering;
 namespace PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Image;
 
 [NodeInfo("Mask")]
-public class MaskNode : RenderNode, IRenderInput
+public sealed class MaskNode : RenderNode, IRenderInput
 {
     public RenderInputProperty Background { get; }
     public RenderInputProperty Mask { get; }
+    public InputProperty<bool> Invert { get; }
 
-    protected Paint maskPaint = new Paint()
+    private readonly Paint maskPaint = new()
     {
-        BlendMode = Drawie.Backend.Core.Surfaces.BlendMode.DstIn, ColorFilter = Nodes.Filters.MaskFilter
+        BlendMode = BlendMode.DstIn,
+        ColorFilter = Filters.MaskFilter
     };
 
     public MaskNode()
     {
         Background = CreateRenderInput("Background", "INPUT");
         Mask = CreateRenderInput("Mask", "MASK");
+        Invert = CreateInput("Invert", "INVERT", false);
         AllowHighDpiRendering = true;
         Output.FirstInChain = null;
     }
@@ -38,14 +41,22 @@ public class MaskNode : RenderNode, IRenderInput
             return;
         }
 
+        maskPaint.BlendMode = !Invert.Value ? BlendMode.DstIn : BlendMode.DstOut;
+
         int layer = surface.Canvas.SaveLayer(maskPaint);
         Mask.Value.Paint(context, surface);
         surface.Canvas.RestoreToCount(layer);
     }
 
-
     public override Node CreateCopy()
     {
         return new MaskNode();
     }
+
+    public override void Dispose()
+    {
+        base.Dispose();
+        
+        maskPaint.Dispose();
+    }
 }

+ 29 - 3
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ImageLayerNode.cs

@@ -5,6 +5,7 @@ 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;
@@ -130,6 +131,7 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
 
         var sceneSize = GetSceneSize(ctx.FrameTime);
         VecD topLeft = sceneSize / 2f;
+
         if (renderedSurfaceFrame == null || ctx.FullRerender || ctx.FrameTime.Frame != renderedSurfaceFrame)
         {
             GetLayerImageAtFrame(ctx.FrameTime.Frame).DrawMostUpToDateRegionOn(
@@ -139,7 +141,18 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
         }
         else
         {
-            workingSurface.Canvas.DrawSurface(fullResrenderedSurface.DrawingSurface, -topLeft, paint);
+            if (ctx.DesiredSamplingOptions == SamplingOptions.Default)
+            {
+                workingSurface.Canvas.DrawSurface(
+                    fullResrenderedSurface.DrawingSurface, -(float)topLeft.X, -(float)topLeft.Y, paint);
+            }
+            else
+            {
+                using var snapshot = fullResrenderedSurface.DrawingSurface.Snapshot();
+                workingSurface.Canvas.DrawImage(snapshot, -(float)topLeft.X, -(float)topLeft.Y,
+                    ctx.DesiredSamplingOptions,
+                    paint);
+            }
         }
 
         workingSurface.Canvas.RestoreToCount(saved);
@@ -242,14 +255,27 @@ public class ImageLayerNode : LayerNode, IReadOnlyImageNode
 
         if (renderedSurfaceFrame == cacheFrame)
         {
-            renderOnto.Canvas.DrawSurface(fullResrenderedSurface.DrawingSurface, VecI.Zero, blendPaint);
+            int saved = renderOnto.Canvas.Save();
+            renderOnto.Canvas.Scale((float)context.ChunkResolution.Multiplier());
+            if (context.DesiredSamplingOptions == SamplingOptions.Default)
+            {
+                renderOnto.Canvas.DrawSurface(
+                    fullResrenderedSurface.DrawingSurface, 0, 0, blendPaint);
+            }
+            else
+            {
+                using var snapshot = fullResrenderedSurface.DrawingSurface.Snapshot();
+                renderOnto.Canvas.DrawImage(snapshot, 0, 0, context.DesiredSamplingOptions, blendPaint);
+            }
+
+            renderOnto.Canvas.RestoreToCount(saved);
         }
         else
         {
             img.DrawMostUpToDateRegionOn(
                 new RectI(0, 0, img.LatestSize.X, img.LatestSize.Y),
                 context.ChunkResolution,
-                renderOnto, VecI.Zero, blendPaint);
+                renderOnto, VecI.Zero, blendPaint, context.DesiredSamplingOptions);
         }
 
         return true;

+ 14 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/LayerNode.cs

@@ -62,7 +62,8 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
                 DrawLayerOnTexture(context, tempSurface.DrawingSurface, context.ChunkResolution, useFilters, targetPaint);
 
                 blendPaint.SetFilters(null);
-                DrawWithResolution(tempSurface.DrawingSurface, renderOnto, context.ChunkResolution);
+                DrawWithResolution(tempSurface.DrawingSurface, renderOnto, context.ChunkResolution,
+                    context.DesiredSamplingOptions);
             }
 
             return;
@@ -130,7 +131,8 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
             blendPaint.SetFilters(null);
         }
 
-        DrawWithResolution(outputWorkingSurface.DrawingSurface, renderOnto, adjustedResolution);
+        DrawWithResolution(outputWorkingSurface.DrawingSurface, renderOnto, adjustedResolution,
+            context.DesiredSamplingOptions);
 
         renderOnto.Canvas.RestoreToCount(saved);
         outputWorkingSurface.DrawingSurface.Canvas.Restore();
@@ -149,13 +151,21 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
         workingSurface.Canvas.RestoreToCount(scaled);
     }
 
-    private void DrawWithResolution(DrawingSurface source, DrawingSurface target, ChunkResolution resolution)
+    private void DrawWithResolution(DrawingSurface source, DrawingSurface target, ChunkResolution resolution, SamplingOptions sampling)
     {
         int scaled = target.Canvas.Save();
         float multiplier = (float)resolution.InvertedMultiplier();
         target.Canvas.Scale(multiplier, multiplier);
 
-        target.Canvas.DrawSurface(source, 0, 0, blendPaint);
+        if (sampling == SamplingOptions.Default)
+        {
+            target.Canvas.DrawSurface(source, 0, 0, blendPaint);
+        }
+        else
+        {
+            using var snapshot = source.Snapshot();
+            target.Canvas.DrawImage(snapshot, 0, 0, sampling, blendPaint);
+        }
 
         target.Canvas.RestoreToCount(scaled);
     }

+ 163 - 3
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/NoiseNode.cs

@@ -18,6 +18,11 @@ public class NoiseNode : RenderNode
     private NoiseType previousNoiseType = Nodes.NoiseType.FractalPerlin;
     private int previousOctaves = -1;
     private VecD previousOffset = new VecD(0d, 0d);
+    private VoronoiFeature previousVoronoiFeature = Nodes.VoronoiFeature.F1;
+    private double previousRandomness = double.NaN;
+    private double previousAngleOffset = double.NaN;
+    
+    private Shader voronoiShader;
 
     private Paint paint = new();
 
@@ -25,7 +30,7 @@ public class NoiseNode : RenderNode
         ColorMatrix.MapAlphaToRedGreenBlue + ColorMatrix.OpaqueAlphaOffset);
 
     public InputProperty<NoiseType> NoiseType { get; }
-
+    
     public InputProperty<VecD> Offset { get; }
     
     public InputProperty<double> Scale { get; }
@@ -33,6 +38,12 @@ public class NoiseNode : RenderNode
     public InputProperty<int> Octaves { get; }
 
     public InputProperty<double> Seed { get; }
+    
+    public InputProperty<VoronoiFeature> VoronoiFeature { get; }
+    
+    public InputProperty<double> Randomness { get; }
+    
+    public InputProperty<double> AngleOffset { get; }
 
     public NoiseNode()
     {
@@ -45,6 +56,13 @@ public class NoiseNode : RenderNode
             .WithRules(validator => validator.Min(1));
 
         Seed = CreateInput(nameof(Seed), "SEED", 0d);
+        
+        VoronoiFeature = CreateInput(nameof(VoronoiFeature), "VORONOI_FEATURE", Nodes.VoronoiFeature.F1);
+        
+        Randomness = CreateInput(nameof(Randomness), "RANDOMNESS", 1d)
+            .WithRules(v => v.Min(0d).Max(1d));
+        
+        AngleOffset = CreateInput(nameof(AngleOffset), "ANGLE_OFFSET", 0d);
     }
 
     protected override void OnPaint(RenderContext context, DrawingSurface target)
@@ -54,6 +72,9 @@ public class NoiseNode : RenderNode
             || previousOctaves != Octaves.Value
             || previousNoiseType != NoiseType.Value
             || previousOffset != Offset.Value
+            || previousVoronoiFeature != VoronoiFeature.Value
+            || Math.Abs(previousRandomness - Randomness.Value) > 0.000001
+            || Math.Abs(previousAngleOffset - AngleOffset.Value) > 0.000001
             || double.IsNaN(previousScale))
         {
             if (Scale.Value < 0.000001)
@@ -67,6 +88,10 @@ public class NoiseNode : RenderNode
                 return;
             }
 
+            if (paint.Shader != voronoiShader)
+            {
+                paint?.Shader?.Dispose();
+            }
             paint.Shader = shader;
 
             // Define a grayscale color filter to apply to the image
@@ -76,6 +101,9 @@ public class NoiseNode : RenderNode
             previousSeed = Seed.Value;
             previousOctaves = Octaves.Value;
             previousNoiseType = NoiseType.Value;
+            previousVoronoiFeature = VoronoiFeature.Value;
+            previousRandomness = Randomness.Value;
+            previousAngleOffset = AngleOffset.Value;
         }
 
         RenderNoise(target);
@@ -101,7 +129,11 @@ public class NoiseNode : RenderNode
         {
             return false;
         }
-        
+
+        if (paint.Shader != voronoiShader)
+        {
+            paint?.Shader?.Dispose();
+        }
         paint.Shader = shader;
         paint.ColorFilter = grayscaleFilter;
         
@@ -122,17 +154,145 @@ public class NoiseNode : RenderNode
                 (float)(1d / Scale.Value),
                 (float)(1d / Scale.Value),
                 octaves, (float)Seed.Value),
+            Nodes.NoiseType.Voronoi => GetVoronoiShader((float)(1d / (Scale.Value)), octaves, (float)Seed.Value, (int)VoronoiFeature.Value, (float)Randomness.Value, (float)AngleOffset.Value),
             _ => null
         };
 
         return shader;
     }
 
+    private Shader GetVoronoiShader(float frequency, int octaves, float seed, int feature, float randomness, float angleOffset)
+    {
+        string voronoiShaderCode = """
+                                   uniform float iSeed;
+                                   uniform float iFrequency;
+                                   uniform int iOctaves;
+                                   uniform float iRandomness;
+                                   uniform int iFeature;
+                                   uniform float iAngleOffset;
+                                   
+                                   const int MAX_OCTAVES = 8;
+                                   const float LARGE_NUMBER = 1e9;
+                                   const float FEATURE_SEED_SCALE = 10.0;
+                                   const float PI = 3.14159265;
+                                   
+                                   float hashPoint(float2 p, float seed) {
+                                       p = fract(p * float2(0.3183099, 0.3678794) + seed);
+                                       p += dot(p, p.yx + 19.19);
+                                       return fract(p.x * p.y);
+                                   }
+                                   
+                                   float2 getFeaturePoint(float2 cell, float seed, float randomness, float angleOffset) {
+                                       float2 randomCellOffset = float2(
+                                           hashPoint(cell, seed),
+                                           hashPoint(cell, seed + 17.0)
+                                       );
+                                       
+                                       float2 featurePoint = mix(float2(0.5, 0.5), randomCellOffset, randomness);
+                                       
+                                       float angle = hashPoint(cell, seed + 53.0) * PI * 2.0;
+                                       angle += angleOffset;
+                                       
+                                       float2 dir = float2(cos(angle), sin(angle));
+                                       float offsetAmount = 0.15;
+                                       featurePoint += dir * offsetAmount * randomness;
+                                       
+                                       featurePoint = clamp(featurePoint, 0.0, 1.0);
+                                       
+                                       return featurePoint;
+                                   }
+                                   
+                                   float2 getVoronoiDistances(float2 pos, float seed) {
+                                       float2 cell = floor(pos);
+                                       float minDist = LARGE_NUMBER;
+                                       float secondMinDist = LARGE_NUMBER;
+                                   
+                                       for (int y = -1; y <= 1; y++) {
+                                           for (int x = -1; x <= 1; x++) {
+                                               float2 neighborCell = cell + float2(float(x), float(y));
+                                               float2 featurePoint = getFeaturePoint(neighborCell, seed, iRandomness, iAngleOffset);
+                                               float2 delta = pos - (neighborCell + featurePoint);
+                                               float dist = length(delta);
+                                               
+                                               if (dist < minDist) {
+                                                   secondMinDist = minDist;
+                                                   minDist = dist;
+                                               } else if (dist < secondMinDist) {
+                                                   secondMinDist = dist;
+                                               }
+                                           }
+                                       }
+                                       return float2(minDist, secondMinDist);
+                                   }
+                                   
+                                   half4 main(float2 uv) {
+                                       float noiseSum = 0.0;
+                                       float amplitude = 1.0;
+                                       float amplitudeSum = 0.0;
+                                   
+                                       for (int octave = 0; octave < MAX_OCTAVES; octave++) {
+                                           if (octave >= iOctaves) break;
+                                   
+                                           float freq = iFrequency * exp2(float(octave));
+                                           float2 samplePos = uv * freq;
+                                           
+                                           float dist = 0.0;
+                                           float2 distances = getVoronoiDistances(samplePos, iSeed + float(octave) * FEATURE_SEED_SCALE);
+                                           float f1 = distances.x;
+                                           float f2 = distances.y;
+                                           
+                                           if (iFeature == 0) {
+                                               dist = f1;
+                                           }
+                                           else if (iFeature == 1) {
+                                               dist = f2;
+                                           }
+                                           else if (iFeature == 2) {
+                                               dist = f2 - f1;
+                                           }
+                                   
+                                           noiseSum += dist * amplitude;
+                                           amplitudeSum += amplitude;
+                                           amplitude *= 0.5;
+                                       }
+                                   
+                                       return half4(noiseSum / amplitudeSum);
+                                   }
+                                   """;
+        
+        Uniforms uniforms = new Uniforms();
+        uniforms.Add("iSeed", new Uniform("iSeed", seed));
+        uniforms.Add("iFrequency", new Uniform("iFrequency", frequency));
+        uniforms.Add("iOctaves", new Uniform("iOctaves", octaves));
+        uniforms.Add("iRandomness", new Uniform("iRandomness", randomness));
+        uniforms.Add("iFeature", new Uniform("iFeature", feature));
+        uniforms.Add("iAngleOffset", new Uniform("iAngleOffset", angleOffset));
+
+        if (voronoiShader == null)
+        {
+            voronoiShader = Shader.Create(voronoiShaderCode, uniforms, out _);
+        }
+        else
+        {
+            voronoiShader = voronoiShader.WithUpdatedUniforms(uniforms);
+        }
+        
+        return voronoiShader;
+    }
+
     public override Node CreateCopy() => new NoiseNode();
 }
 
 public enum NoiseType
 {
     TurbulencePerlin,
-    FractalPerlin
+    FractalPerlin,
+    Voronoi
+}
+
+public enum VoronoiFeature
+{
+    F1 = 0, // Distance to the closest feature point
+    F2 = 1, // Distance to the second-closest feature point
+    F2MinusF1 = 2
 }

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

@@ -56,6 +56,7 @@ public class OutputNode : Node, IRenderInput, IPreviewRenderable
         }
 
         int saved = renderOn.Canvas.Save();
+        renderOn.Canvas.Scale((float)context.ChunkResolution.Multiplier());
         Input.Value.Paint(context, renderOn);
 
         renderOn.Canvas.RestoreToCount(saved);

+ 11 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/RenderNode.cs

@@ -66,7 +66,15 @@ public abstract class RenderNode : Node, IPreviewRenderable, IHighDpiRenderNode
                 surface.Canvas.Scale((float)context.ChunkResolution.InvertedMultiplier());
             }
 
-            surface.Canvas.DrawSurface(target, 0, 0);
+            if (context.DesiredSamplingOptions != SamplingOptions.Default)
+            {
+                using var snapshot = target.Snapshot();
+                surface.Canvas.DrawImage(snapshot, 0, 0, context.DesiredSamplingOptions);
+            }
+            else
+            {
+                surface.Canvas.DrawSurface(target, 0, 0);
+            }
 
             if (RendersInAbsoluteCoordinates)
             {
@@ -85,7 +93,9 @@ public abstract class RenderNode : Node, IPreviewRenderable, IHighDpiRenderNode
     public virtual bool RenderPreview(DrawingSurface renderOn, RenderContext context,
         string elementToRenderName)
     {
+        int saved = renderOn.Canvas.Save();
         OnPaint(context, renderOn);
+        renderOn.Canvas.RestoreToCount(saved);
         return true;
     }
 

+ 10 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/ShaderNode.cs

@@ -104,7 +104,8 @@ public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
         Uniforms uniforms;
         uniforms = new Uniforms();
 
-        VecI finalSize = (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier());
+        bool isAdjusted = context.RenderOutputSize == context.DocumentSize;
+        VecI finalSize = isAdjusted ? context.RenderOutputSize : (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier());
 
         uniforms.Add("iResolution", new Uniform("iResolution", (VecD)finalSize));
         uniforms.Add("iNormalizedTime", new Uniform("iNormalizedTime", (float)context.FrameTime.NormalizedTime));
@@ -123,8 +124,11 @@ public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
         int saved = texture.DrawingSurface.Canvas.Save();
         //texture.DrawingSurface.Canvas.Scale((float)context.ChunkResolution.Multiplier(), (float)context.ChunkResolution.Multiplier());
 
-        var ctx = new RenderContext(texture.DrawingSurface, context.FrameTime, ChunkResolution.Full, finalSize,
-            context.DocumentSize, context.ProcessingColorSpace, context.Opacity);
+        var ctx = context.Clone();
+        ctx.RenderSurface = texture.DrawingSurface;
+        ctx.RenderOutputSize = finalSize;
+        ctx.ChunkResolution = ChunkResolution.Full;
+
         Background.Value.Paint(ctx, texture.DrawingSurface);
         texture.DrawingSurface.Canvas.RestoreToCount(saved);
 
@@ -156,8 +160,10 @@ public class ShaderNode : RenderNode, IRenderInput, ICustomShaderNode
 
         if (context.ChunkResolution != ChunkResolution.Full)
         {
+            bool isAdjusted = context.RenderOutputSize == context.DocumentSize;
+            VecI finalSize = isAdjusted ? context.RenderOutputSize : (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier());
             var intermediateSurface = RequestTexture(51,
-                (VecI)(context.RenderOutputSize * context.ChunkResolution.InvertedMultiplier()),
+                finalSize,
                 ColorSpace.Value == ColorSpaceType.Inherit
                     ? context.ProcessingColorSpace
                     : ColorSpace.Value == ColorSpaceType.Srgb

+ 7 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/StructureNode.cs

@@ -184,8 +184,7 @@ public abstract class StructureNode : RenderNode, IReadOnlyStructureNode, IRende
 
         SceneObjectRenderContext renderObjectContext = new SceneObjectRenderContext(output, renderTarget, localBounds,
             context.FrameTime, context.ChunkResolution, context.RenderOutputSize, context.DocumentSize, renderTarget == context.RenderSurface,
-            context.ProcessingColorSpace,
-            context.Opacity);
+            context.ProcessingColorSpace, context.DesiredSamplingOptions, context.Opacity);
         renderObjectContext.FullRerender = context.FullRerender;
         return renderObjectContext;
     }
@@ -247,7 +246,12 @@ public abstract class StructureNode : RenderNode, IReadOnlyStructureNode, IRende
 
         VecI targetSize = img.LatestSize;
 
-        var ctx = DrawingBackendApi.Current.RenderingDispatcher.EnsureContext();
+        if (targetSize.X <= 0 || targetSize.Y <= 0)
+        {
+            return;
+        }
+
+        using var ctx = DrawingBackendApi.Current.RenderingDispatcher.EnsureContext();
         renderSurface = RequestTexture(textureId, targetSize, processingColorSpace, false);
 
         int saved = renderSurface.DrawingSurface.Canvas.Save();
@@ -265,7 +269,6 @@ public abstract class StructureNode : RenderNode, IReadOnlyStructureNode, IRende
         }
 
         renderSurface.DrawingSurface.Canvas.RestoreToCount(saved);
-        ctx?.Dispose();
     }
 
     protected void ApplyRasterClip(DrawingSurface toClip, DrawingSurface clipSource)

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

@@ -14,7 +14,7 @@ public class TextureCache : IDisposable
     {
         if (_managedTextures.TryGetValue(id, out var texture))
         {
-            if (texture.Size != size || texture.IsDisposed || texture.ColorSpace != processingCs)
+            if (texture.Size != size || texture.IsDisposed || !texture.ColorSpace.Equals(processingCs))
             {
                 texture.Dispose();
                 texture = new Texture(CreateImageInfo(size, processingCs));

+ 8 - 3
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs

@@ -132,7 +132,11 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
             return false;
         }
 
+
+        int savedCount = renderOn.Canvas.Save();
+        renderOn.Canvas.Scale((float)context.ChunkResolution.Multiplier());
         Rasterize(renderOn, paint);
+        renderOn.Canvas.RestoreToCount(savedCount);
 
         return true;
     }
@@ -188,8 +192,9 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
     {
         int layer;
         // TODO: This can be further optimized by passing opacity, blend mode and filters directly to the rasterization method
-        if (paint != null && (paint.Color.A < 255 || paint.ColorFilter != null || paint.ImageFilter != null || paint.Shader != null ||
-            paint.BlendMode != Drawie.Backend.Core.Surfaces.BlendMode.SrcOver))
+        if (paint != null && (paint.Color.A < 255 || paint.ColorFilter != null || paint.ImageFilter != null ||
+                              paint.Shader != null ||
+                              paint.BlendMode != Drawie.Backend.Core.Surfaces.BlendMode.SrcOver))
         {
             layer = surface.Canvas.SaveLayer(paint);
         }
@@ -197,7 +202,7 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
         {
             layer = surface.Canvas.Save();
         }
-        
+
         RenderableShapeData?.RasterizeTransformed(surface.Canvas);
 
         surface.Canvas.RestoreToCount(layer);

+ 2 - 1
src/PixiEditor.ChangeableDocument/Changes/NodeGraph/EvaluateGraph_Change.cs

@@ -1,4 +1,5 @@
 using Drawie.Backend.Core;
+using Drawie.Backend.Core.Surfaces;
 using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Rendering;
@@ -33,7 +34,7 @@ internal class EvaluateGraph_Change : Change
         using Texture renderTexture = Texture.ForProcessing(target.Size, target.ProcessingColorSpace);
         RenderContext context =
             new(renderTexture.DrawingSurface, frameTime, ChunkResolution.Full, target.Size, target.Size,
-                target.ProcessingColorSpace) { FullRerender = true };
+                target.ProcessingColorSpace, SamplingOptions.Default) { FullRerender = true };
         foreach (var nodeToEvaluate in queue)
         {
             nodeToEvaluate.Execute(context);

+ 21 - 6
src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs

@@ -21,6 +21,7 @@ public class DocumentRenderer : IPreviewRenderable, IDisposable
 {
     private Queue<RenderRequest> renderRequests = new();
     private Texture renderTexture;
+    private int lastExecutedGraphFrame = -1;
 
     public DocumentRenderer(IReadOnlyDocument document)
     {
@@ -68,7 +69,7 @@ public class DocumentRenderer : IPreviewRenderable, IDisposable
         toRenderOn.Canvas.SetMatrix(Matrix3X3.Identity);
 
         RenderContext context = new(renderTexture.DrawingSurface, frame, resolution, Document.Size, Document.Size,
-            Document.ProcessingColorSpace);
+            Document.ProcessingColorSpace, SamplingOptions.Default);
         context.FullRerender = true;
         IReadOnlyNodeGraph membersOnlyGraph = ConstructMembersOnlyGraph(layersToCombine, Document.NodeGraph);
         try
@@ -114,7 +115,7 @@ public class DocumentRenderer : IPreviewRenderable, IDisposable
         toRenderOn.Canvas.SetMatrix(Matrix3X3.Identity);
 
         RenderContext context = new(renderTexture.DrawingSurface, frameTime, resolution, Document.Size, Document.Size,
-            Document.ProcessingColorSpace);
+            Document.ProcessingColorSpace, SamplingOptions.Default);
         context.FullRerender = true;
 
         node.RenderForOutput(context, toRenderOn, null);
@@ -134,7 +135,7 @@ public class DocumentRenderer : IPreviewRenderable, IDisposable
         RenderRequest request = new(tcs, context, renderOn, previewRenderable, elementToRenderName);
 
         renderRequests.Enqueue(request);
-        ExecuteRenderRequests();
+        ExecuteRenderRequests(context.FrameTime);
 
         return await tcs.Task;
     }
@@ -201,8 +202,12 @@ public class DocumentRenderer : IPreviewRenderable, IDisposable
         IsBusy = true;
 
         renderOn.Canvas.Clear();
+        int savedCount = renderOn.Canvas.Save();
+        renderOn.Canvas.Scale((float)context.ChunkResolution.Multiplier());
         context.RenderSurface = renderOn;
         Document.NodeGraph.Execute(context);
+        lastExecutedGraphFrame = context.FrameTime.Frame;
+        renderOn.Canvas.RestoreToCount(savedCount);
 
         IsBusy = false;
 
@@ -236,8 +241,9 @@ public class DocumentRenderer : IPreviewRenderable, IDisposable
             : Document.NodeGraph;
 
         RenderContext context =
-            new(renderTexture.DrawingSurface, frameTime, ChunkResolution.Full, SolveRenderOutputSize(customOutput, graph, Document.Size),
-                Document.Size, Document.ProcessingColorSpace) { FullRerender = true };
+            new(renderTexture.DrawingSurface, frameTime, ChunkResolution.Full,
+                SolveRenderOutputSize(customOutput, graph, Document.Size),
+                Document.Size, Document.ProcessingColorSpace, SamplingOptions.Default) { FullRerender = true };
 
         if (hasCustomOutput)
         {
@@ -264,19 +270,28 @@ public class DocumentRenderer : IPreviewRenderable, IDisposable
         renderTexture.DrawingSurface.Canvas.Restore();
         toRenderOn.Canvas.Restore();
 
+        lastExecutedGraphFrame = frameTime.Frame;
+
         IsBusy = false;
     }
 
-    private void ExecuteRenderRequests()
+    private void ExecuteRenderRequests(KeyFrameTime frameTime)
     {
         if (isExecuting) return;
 
         isExecuting = true;
         using var ctx = DrawingBackendApi.Current?.RenderingDispatcher.EnsureContext();
+
         while (renderRequests.Count > 0)
         {
             RenderRequest request = renderRequests.Dequeue();
 
+            if (frameTime.Frame != lastExecutedGraphFrame && request.PreviewRenderable != this)
+            {
+                using Texture executeSurface = Texture.ForDisplay(new VecI(1));
+                RenderDocument(executeSurface.DrawingSurface, frameTime, VecI.One);
+            }
+
             try
             {
                 bool result = true;

+ 5 - 3
src/PixiEditor.ChangeableDocument/Rendering/RenderContext.cs

@@ -13,6 +13,7 @@ public class RenderContext
 
     public KeyFrameTime FrameTime { get; }
     public ChunkResolution ChunkResolution { get; set; }
+    public SamplingOptions DesiredSamplingOptions { get; set; } = SamplingOptions.Default;
     public VecI RenderOutputSize { get; set; }
 
     public VecI DocumentSize { get; set; }
@@ -24,7 +25,7 @@ public class RenderContext
 
 
     public RenderContext(DrawingSurface renderSurface, KeyFrameTime frameTime, ChunkResolution chunkResolution,
-        VecI renderOutputSize, VecI documentSize, ColorSpace processingColorSpace, double opacity = 1)
+        VecI renderOutputSize, VecI documentSize, ColorSpace processingColorSpace, SamplingOptions desiredSampling, double opacity = 1)
     {
         RenderSurface = renderSurface;
         FrameTime = frameTime;
@@ -33,6 +34,7 @@ public class RenderContext
         Opacity = opacity;
         ProcessingColorSpace = processingColorSpace;
         DocumentSize = documentSize;
+        DesiredSamplingOptions = desiredSampling;
     }
 
     public static DrawingApiBlendMode GetDrawingBlendMode(BlendMode blendMode)
@@ -63,10 +65,10 @@ public class RenderContext
 
     public RenderContext Clone()
     {
-        return new RenderContext(RenderSurface, FrameTime, ChunkResolution, RenderOutputSize, DocumentSize, ProcessingColorSpace, Opacity)
+        return new RenderContext(RenderSurface, FrameTime, ChunkResolution, RenderOutputSize, DocumentSize, ProcessingColorSpace, DesiredSamplingOptions, Opacity)
         {
             FullRerender = FullRerender,
-            TargetOutput = TargetOutput
+            TargetOutput = TargetOutput,
         };
     }
 }

+ 5 - 0
src/PixiEditor.Extensions.CommonApi/UserPreferences/PreferencesConstants.cs

@@ -50,4 +50,9 @@ public static class PreferencesConstants
     public const string SecondaryBackgroundColorDefault = "#353535";
     public const string SecondaryBackgroundColor = "SecondaryBackgroundColor";
 
+    public const string MaxBilinearSampleSize = "MaxBilinearSampleSize";
+    public const int MaxBilinearSampleSizeDefault = 4096;
+
+    public const string DisablePreviews = "DisablePreviews";
+    public const bool DisablePreviewsDefault = false;
 }

+ 44 - 17
src/PixiEditor.Extensions.CommonApi/UserPreferences/Settings/PixiEditor/PixiEditorSettings.cs

@@ -3,10 +3,11 @@
 public static class PixiEditorSettings
 {
     private const string PixiEditor = "PixiEditor";
-    
+
     public static class Palettes
     {
-        public static LocalSetting<IEnumerable<string>> FavouritePalettes { get; } = LocalSetting.NonOwned<IEnumerable<string>>(PixiEditor);
+        public static LocalSetting<IEnumerable<string>> FavouritePalettes { get; } =
+            LocalSetting.NonOwned<IEnumerable<string>>(PixiEditor);
     }
 
     public static class Update
@@ -22,16 +23,20 @@ public static class PixiEditorSettings
 
         public static LocalSetting<string> PoEditorApiKey { get; } = new($"{PixiEditor}:POEditor_API_Key");
     }
-    
+
     public static class Tools
     {
         public static SyncedSetting<bool> EnableSharedToolbar { get; } = SyncedSetting.NonOwned<bool>(PixiEditor);
 
-        public static SyncedSetting<RightClickMode> RightClickMode { get; } = SyncedSetting.NonOwned<RightClickMode>(PixiEditor);
-        
+        public static SyncedSetting<bool> SelectionTintingEnabled { get; } = SyncedSetting.NonOwned(PixiEditor, true);
+
+        public static SyncedSetting<RightClickMode> RightClickMode { get; } =
+            SyncedSetting.NonOwned<RightClickMode>(PixiEditor);
+
         public static SyncedSetting<bool> IsPenModeEnabled { get; } = SyncedSetting.NonOwned<bool>(PixiEditor);
 
-        public static SyncedSetting<string> PrimaryToolset { get; } = SyncedSetting.NonOwned<string>(PixiEditor, "PAINT_TOOLSET");
+        public static SyncedSetting<string> PrimaryToolset { get; } =
+            SyncedSetting.NonOwned<string>(PixiEditor, "PAINT_TOOLSET");
     }
 
     public static class File
@@ -39,12 +44,13 @@ public static class PixiEditorSettings
         public static SyncedSetting<int> DefaultNewFileWidth { get; } = SyncedSetting.NonOwned(PixiEditor, 64);
 
         public static SyncedSetting<int> DefaultNewFileHeight { get; } = SyncedSetting.NonOwned(PixiEditor, 64);
-        
-        public static LocalSetting<IEnumerable<string>> RecentlyOpened { get; } = LocalSetting.NonOwned<IEnumerable<string>>(PixiEditor, []);
-    
+
+        public static LocalSetting<IEnumerable<string>> RecentlyOpened { get; } =
+            LocalSetting.NonOwned<IEnumerable<string>>(PixiEditor, []);
+
         public static SyncedSetting<int> MaxOpenedRecently { get; } = SyncedSetting.NonOwned(PixiEditor, 8);
     }
-    
+
     public static class StartupWindow
     {
         public static SyncedSetting<bool> ShowStartupWindow { get; } = SyncedSetting.NonOwned(PixiEditor, true);
@@ -53,9 +59,10 @@ public static class PixiEditorSettings
 
         public static SyncedSetting<bool> NewsPanelCollapsed { get; } = SyncedSetting.NonOwned<bool>(PixiEditor);
 
-        public static SyncedSetting<IEnumerable<int>> LastCheckedNewsIds { get; } = SyncedSetting.NonOwned<IEnumerable<int>>(PixiEditor);
+        public static SyncedSetting<IEnumerable<int>> LastCheckedNewsIds { get; } =
+            SyncedSetting.NonOwned<IEnumerable<int>>(PixiEditor);
     }
-    
+
     public static class Discord
     {
         public static SyncedSetting<bool> EnableRichPresence { get; } = SyncedSetting.NonOwned(PixiEditor, true);
@@ -74,12 +81,32 @@ public static class PixiEditorSettings
 
     public static class Scene
     {
-        public static SyncedSetting<bool> AutoScaleBackground { get; } = SyncedSetting.NonOwned(PixiEditor, PreferencesConstants.AutoScaleBackgroundDefault, PreferencesConstants.AutoScaleBackground);
-        public static SyncedSetting<double> CustomBackgroundScaleX { get; } = SyncedSetting.NonOwned(PixiEditor, PreferencesConstants.CustomBackgroundScaleDefault, PreferencesConstants.CustomBackgroundScaleX);
-        public static SyncedSetting<double> CustomBackgroundScaleY { get; } = SyncedSetting.NonOwned(PixiEditor, PreferencesConstants.CustomBackgroundScaleDefault, PreferencesConstants.CustomBackgroundScaleY);
+        public static SyncedSetting<bool> AutoScaleBackground { get; } = SyncedSetting.NonOwned(PixiEditor,
+            PreferencesConstants.AutoScaleBackgroundDefault, PreferencesConstants.AutoScaleBackground);
+
+        public static SyncedSetting<double> CustomBackgroundScaleX { get; } = SyncedSetting.NonOwned(PixiEditor,
+            PreferencesConstants.CustomBackgroundScaleDefault, PreferencesConstants.CustomBackgroundScaleX);
+
+        public static SyncedSetting<double> CustomBackgroundScaleY { get; } = SyncedSetting.NonOwned(PixiEditor,
+            PreferencesConstants.CustomBackgroundScaleDefault, PreferencesConstants.CustomBackgroundScaleY);
 
-        public static SyncedSetting<string> PrimaryBackgroundColor { get; } = SyncedSetting.NonOwned(PixiEditor, PreferencesConstants.PrimaryBackgroundColorDefault, PreferencesConstants.PrimaryBackgroundColor);
-        public static SyncedSetting<string> SecondaryBackgroundColor { get; } = SyncedSetting.NonOwned(PixiEditor, PreferencesConstants.SecondaryBackgroundColorDefault, PreferencesConstants.SecondaryBackgroundColor);
+        public static SyncedSetting<string> PrimaryBackgroundColor { get; } = SyncedSetting.NonOwned(PixiEditor,
+            PreferencesConstants.PrimaryBackgroundColorDefault, PreferencesConstants.PrimaryBackgroundColor);
 
+        public static SyncedSetting<string> SecondaryBackgroundColor { get; } = SyncedSetting.NonOwned(PixiEditor,
+            PreferencesConstants.SecondaryBackgroundColorDefault, PreferencesConstants.SecondaryBackgroundColor);
+    }
+
+    public static class Performance
+    {
+        public static SyncedSetting<int> MaxBilinearSampleSize { get; } = SyncedSetting.NonOwned(
+            PixiEditor,
+            PreferencesConstants.MaxBilinearSampleSizeDefault,
+            PreferencesConstants.MaxBilinearSampleSize);
+
+        public static SyncedSetting<bool> DisablePreviews { get; } = SyncedSetting.NonOwned(
+            PixiEditor,
+            PreferencesConstants.DisablePreviewsDefault,
+            PreferencesConstants.DisablePreviews);
     }
 }

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


+ 1 - 0
src/PixiEditor.IdentityProvider.PixiAuth/PixiAuthIdentityProvider.cs

@@ -129,6 +129,7 @@ public class PixiAuthIdentityProvider : IIdentityProvider
             Error(e.Message, e.TimeLeft);
             LoginTimeout?.Invoke(e.TimeLeft);
         }
+        // Can produce SESSION_NOT_FOUND or USER_NOT_FOUND as a message, this comment ensure it's catched by the localization key checker
         catch (PixiAuthException authException)
         {
             Error(authException.Message);

+ 0 - 1
src/PixiEditor.sln

@@ -11,7 +11,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildConfiguration", "Build
 	ProjectSection(SolutionItems) = preProject
 		Custom.ruleset = Custom.ruleset
 		Directory.Build.props = Directory.Build.props
-		stylecop.json = stylecop.json
 	EndProjectSection
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ChunkyImageLib", "ChunkyImageLib\ChunkyImageLib.csproj", "{6A9DA760-1E47-414C-B8E8-3B4927F18131}"

+ 27 - 1
src/PixiEditor/Data/Localization/Languages/en.json

@@ -951,6 +951,13 @@
   "RAW_COLOR_SAMPLE_MODE": "Raw",
   "FRACTAL_PERLIN_NOISE_TYPE": "Perlin",
   "TURBULENCE_PERLIN_NOISE_TYPE": "Turbulence",
+  "VORONOI_NOISE_TYPE": "Voronoi",
+  "VORONOI_FEATURE": "Feature",
+  "F1_VORONOI_FEATURE": "F1",
+  "F2_VORONOI_FEATURE": "F2",
+  "F2_MINUS_F1_VORONOI_FEATURE": "F2-F1",
+  "RANDOMNESS": "Randomness",
+  "ANGLE_OFFSET": "Angle Offset",
   "INHERIT_COLOR_SPACE_TYPE": "Inherit",
   "SRGB_COLOR_SPACE_TYPE": "sRGB",
   "LINEAR_SRGB_COLOR_SPACE_TYPE": "Linear sRGB",
@@ -1090,6 +1097,7 @@
   "REMAP_NODE": "Remap",
   "TEXT_TOOL_ACTION_DISPLAY": "Click on the canvas to add a new text (drag while clicking to set the size). Click on existing text to edit it.",
   "PASTE_CELS": "Paste cels",
+  "PASTE_CELS_DESCRIPTIVE": "Paste cels from clipboard into the current frame",
   "SCALE_X": "Scale X",
   "SCALE_Y": "Scale Y",
   "TRANSLATE_X": "Translate X",
@@ -1108,5 +1116,23 @@
   "ERROR_GPU_RESOURCES_CREATION": "Failed to create resources: Try updating your GPU drivers or try setting different rendering api in settings. \nError: '{0}'",
   "ERROR_SAVING_PREFERENCES_DESC": "Failed to save preferences with error: '{0}'. Please check if you have write permissions to the PixiEditor data folder.",
   "ERROR_SAVING_PREFERENCES": "Failed to save preferences",
-  "PREFERRED_RENDERER": "Preferred Render Api"
+  "PREFERRED_RENDERER": "Preferred Render Api",
+  "PERFORMANCE": "Performance",
+  "DISABLE_PREVIEWS": "Disable Previews",
+  "MAX_BILINEAR_CANVAS_SIZE": "Max Bilinear Canvas Size",
+  "MAX_BILINEAR_CANVAS_SIZE_DESC": "Maximum canvas size for bilinear filtering. Set to 0 to disable bilinear filtering. Bilinear filtering improves the quality of the canvas, but can cause performance issues on large canvases.",
+  "INVERT_MASK": "Invert mask",
+  "TOGGLE_TINTING_SELECTION": "Toggle selection tinting",
+  "TOGGLE_TINTING_SELECTION_DESCRIPTIVE": "Toggle selection tinting",
+  "TINT_SELECTION": "Selection tinting",
+  "PAINT_BRUSH_SHAPE_CIRCLE": "Circle",
+  "PAINT_BRUSH_SHAPE_SQUARE": "Square",
+  "BRIGHTNESS_MODE_DEFAULT": "Default",
+  "BRIGHTNESS_MODE_REPEAT": "Repeat",
+  "ROUND_STROKE_CAP": "Round",
+  "BUTT_STROKE_CAP": "Butt",
+  "SQUARE_STROKE_CAP": "Square",
+  "ROUND_STROKE_JOIN": "Round",
+  "MITER_STROKE_JOIN": "Miter",
+  "BEVEL_STROKE_JOIN": "Bevel"
 }

+ 27 - 17
src/PixiEditor/Helpers/Converters/EnumToLocalizedStringConverter.cs

@@ -1,4 +1,6 @@
-using System.Globalization;
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
 using PixiEditor.Extensions.Helpers;
 using PixiEditor.UI.Common.Localization;
 
@@ -6,30 +8,38 @@ namespace PixiEditor.Helpers.Converters;
 
 internal class EnumToLocalizedStringConverter : SingleInstanceConverter<EnumToLocalizedStringConverter>
 {
+    private Dictionary<object, string> enumTranslations = new(
+        typeof(EnumToLocalizedStringConverter).Assembly
+            .GetCustomAttributes()
+            .OfType<ILocalizeEnumInfo>()
+            .Select(x => new KeyValuePair<object, string>(x.GetEnumValue(), x.LocalizationKey)));
+    
     public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
     {
-        if (value is Enum enumValue)
+        if (value is not Enum enumValue)
         {
-            if (EnumHelpers.HasDescription(enumValue))
-            {
-                return EnumHelpers.GetDescription(enumValue);
-            }
+            return value;
+        }
+
+        if (enumTranslations.TryGetValue(enumValue, out var assemblyDefinedKey))
+        {
+            return assemblyDefinedKey;
+        }
 
-            return ToLocalizedStringFormat(enumValue);
+        if (EnumHelpers.HasDescription(enumValue))
+        {
+            return EnumHelpers.GetDescription(enumValue);
         }
 
-        return value;
+        ThrowUntranslatedEnumValue(enumValue);
+        return enumValue;
     }
 
-    private string ToLocalizedStringFormat(Enum enumValue)
+    [Conditional("DEBUG")]
+    private static void ThrowUntranslatedEnumValue(object value)
     {
-        // VALUE_ENUMTYPE
-        // for example BlendMode.Normal becomes NORMAL_BLEND_MODE
-
-        string enumType = enumValue.GetType().Name;
-
-        string value = enumValue.ToString();
-
-        return $"{value.ToSnakeCase()}_{enumType.ToSnakeCase()}".ToUpper();
+        throw new ArgumentException(
+            $"Enum value '{value.GetType()}.{value}' has no value defined. Either add a Description attribute to the enum values or a LocalizeEnum attribute in EnumTranslations.cs for third party enums",
+            nameof(value));
     }
 }

+ 0 - 19
src/PixiEditor/Helpers/EnumDescriptionConverter.cs

@@ -1,19 +0,0 @@
-using System.Globalization;
-using PixiEditor.Extensions.Helpers;
-using PixiEditor.Helpers.Converters;
-using PixiEditor.UI.Common.Localization;
-
-namespace PixiEditor.Helpers;
-
-internal class EnumDescriptionConverter : SingleInstanceConverter<EnumDescriptionConverter>
-{
-    public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
-    {
-        if (value is Enum enumValue)
-        {
-            return EnumHelpers.GetDescription(enumValue);
-        }
-
-        return value;
-    }
-}

+ 18 - 0
src/PixiEditor/Helpers/LocalizeEnumAttribute.cs

@@ -0,0 +1,18 @@
+namespace PixiEditor.Helpers;
+
+public interface ILocalizeEnumInfo
+{
+    public object GetEnumValue();
+    
+    public string LocalizationKey { get; }
+}
+
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
+public class LocalizeEnumAttribute<T>(T value, string key) : Attribute, ILocalizeEnumInfo where T : Enum
+{
+    public T Value { get; } = value;
+
+    object ILocalizeEnumInfo.GetEnumValue() => Value;
+    
+    public string LocalizationKey { get; } = key;
+}

+ 26 - 7
src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs

@@ -6,6 +6,7 @@ using PixiEditor.ChangeableDocument.Actions.Generated;
 using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.ChangeInfos;
 using Drawie.Backend.Core.Bridge;
+using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
 using PixiEditor.Helpers;
 using PixiEditor.Models.DocumentPassthroughActions;
 using PixiEditor.Models.Handlers;
@@ -104,7 +105,8 @@ internal class ActionAccumulator
                 queuedActions = new();
 
                 List<IChangeInfo?> changes;
-                if (AreAllPassthrough(toExecute))
+                bool allPassthrough = AreAllPassthrough(toExecute);
+                if (allPassthrough)
                 {
                     changes = toExecute.Select(a => (IChangeInfo?)a.action).ToList();
                 }
@@ -119,6 +121,8 @@ internal class ActionAccumulator
                         action.action is ChangeBoundary_Action or Redo_Action or Undo_Action);
                 bool viewportRefreshRequest =
                     toExecute.Any(static action => action.action is RefreshViewport_PassthroughAction);
+                bool refreshPreviewsRequest =
+                    toExecute.Any(static action => action.action is RefreshPreviews_PassthroughAction);
                 bool changeFrameRequest =
                     toExecute.Any(static action => action.action is SetActiveFrame_PassthroughAction);
 
@@ -130,10 +134,12 @@ internal class ActionAccumulator
                 if (undoBoundaryPassed)
                     internals.Updater.AfterUndoBoundaryPassed();
 
+
                 var affectedAreas = new AffectedAreasGatherer(document.AnimationHandler.ActiveFrameTime,
                     internals.Tracker,
-                    optimizedChanges);
-                if (DrawingBackendApi.Current.IsHardwareAccelerated)
+                    optimizedChanges, refreshPreviewsRequest);
+
+                if (DrawingBackendApi.Current.IsHardwareAccelerated && !allPassthrough)
                 {
                     canvasUpdater.UpdateGatheredChunksSync(affectedAreas,
                         undoBoundaryPassed || viewportRefreshRequest);
@@ -144,10 +150,21 @@ internal class ActionAccumulator
                         undoBoundaryPassed || viewportRefreshRequest);
                 }*/
 
-                previewUpdater.UpdatePreviews(
-                    affectedAreas.ChangedMembers,
-                    affectedAreas.ChangedMasks,
-                    affectedAreas.ChangedNodes, affectedAreas.ChangedKeyFrames);
+                bool previewsDisabled = PixiEditorSettings.Performance.DisablePreviews.Value;
+
+                if (!previewsDisabled)
+                {
+                    if (undoBoundaryPassed || viewportRefreshRequest || changeFrameRequest ||
+                        document.SizeBindable.LongestAxis <= LiveUpdatePerformanceThreshold)
+                    {
+                        previewUpdater.UpdatePreviews(
+                            affectedAreas.ChangedMembers,
+                            affectedAreas.ChangedMasks,
+                            affectedAreas.ChangedNodes, affectedAreas.ChangedKeyFrames,
+                            affectedAreas.IgnoreAnimationPreviews,
+                            undoBoundaryPassed || refreshPreviewsRequest);
+                    }
+                }
 
                 // force refresh viewports for better responsiveness
                 foreach (var (_, value) in internals.State.Viewports)
@@ -175,6 +192,8 @@ internal class ActionAccumulator
         executing = false;
     }
 
+    private const int LiveUpdatePerformanceThreshold = 2048;
+
     private bool AreAllPassthrough(List<(ActionSource, IAction)> actions)
     {
         foreach (var action in actions)

+ 2 - 1
src/PixiEditor/Models/DocumentModels/DocumentStructureHelper.cs

@@ -8,6 +8,7 @@ using PixiEditor.ChangeableDocument.Enums;
 using PixiEditor.Models.Handlers;
 using PixiEditor.Models.Layers;
 using PixiEditor.UI.Common.Localization;
+using PixiEditor.ViewModels.Nodes;
 
 namespace PixiEditor.Models.DocumentModels;
 #nullable enable
@@ -34,7 +35,7 @@ internal class DocumentStructureHelper
                     count++;
             }
 
-            return true;
+            return Traverse.Further;
         });
         return $"{name} {count}";
     }

+ 3 - 2
src/PixiEditor/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -21,6 +21,7 @@ using PixiEditor.Models.Position;
 using PixiEditor.Models.Tools;
 using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
+using PixiEditor.ViewModels.Nodes;
 
 namespace PixiEditor.Models.DocumentModels.Public;
 #nullable enable
@@ -617,10 +618,10 @@ internal class DocumentOperationsModule : IDocumentOperations
             if (!members.Contains(traversedNode.Id))
             {
                 parent = traversedNode;
-                return false;
+                return Traverse.Exit;
             }
 
-            return true;
+            return Traverse.Further;
         });
 
         if (parent is null)

+ 16 - 15
src/PixiEditor/Models/DocumentModels/Public/DocumentStructureModule.cs

@@ -1,5 +1,6 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.Models.Handlers;
+using PixiEditor.ViewModels.Nodes;
 
 namespace PixiEditor.Models.DocumentModels.Public;
 #nullable enable
@@ -45,10 +46,10 @@ internal class DocumentStructureModule
             if (!guids.Contains(traversedNode.Id) && traversedNode is IStructureMemberHandler)
             {
                 parent = traversedNode;
-                return false;
+                return Traverse.Exit;
             }
 
-            return true;
+            return Traverse.Further;
         });
 
         if (parent is null)
@@ -62,10 +63,10 @@ internal class DocumentStructureModule
                 if (!guids.Contains(traversedNode.Id) && traversedNode is IStructureMemberHandler)
                 {
                     parent = traversedNode;
-                    return false;
+                    return Traverse.Exit;
                 }
 
-                return true;
+                return Traverse.Further;
             });
         }
 
@@ -110,7 +111,7 @@ internal class DocumentStructureModule
         {
             if (node is IStructureMemberHandler parent && input is { PropertyName: FolderNode.ContentInternalName })
                 parents.Add(parent);
-            return true;
+            return Traverse.Further;
         });
 
         return parents;
@@ -187,7 +188,7 @@ internal class DocumentStructureModule
                 toFill.Add(strNode);
             }
 
-            return true;
+            return Traverse.Further;
         });
     }
 
@@ -197,10 +198,10 @@ internal class DocumentStructureModule
         startNode.TraverseForwards(node =>
         {
             if (node == startNode)
-                return true;
+                return Traverse.Further;
 
             result = node;
-            return false;
+            return Traverse.Exit;
         });
 
         return result;
@@ -218,13 +219,13 @@ internal class DocumentStructureModule
             if (node != member && node is IStructureMemberHandler structureMemberNode)
             {
                 if (node is IFolderHandler && !includeFolders)
-                    return true;
+                    return Traverse.Further;
 
                 result = structureMemberNode;
-                return false;
+                return Traverse.Exit;
             }
 
-            return true;
+            return Traverse.Further;
         });
 
         return result;
@@ -242,13 +243,13 @@ internal class DocumentStructureModule
             if (node != member && node is IStructureMemberHandler structureMemberNode)
             {
                 if (node is IFolderHandler && !includeFolders)
-                    return true;
+                    return Traverse.Further;
 
                 result = structureMemberNode;
-                return false;
+                return Traverse.Exit;
             }
 
-            return true;
+            return Traverse.Further;
         });
 
         return result;
@@ -268,7 +269,7 @@ internal class DocumentStructureModule
             if (node is IStructureMemberHandler structureMemberNode)
                 children.Add(structureMemberNode);
 
-            return true;
+            return Traverse.Further;
         });
 
         return children;

+ 7 - 0
src/PixiEditor/Models/DocumentPassthroughActions/RefreshPreviews_PassthroughAction.cs

@@ -0,0 +1,7 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.Models.Position;
+
+namespace PixiEditor.Models.DocumentPassthroughActions;
+
+internal record class RefreshPreviews_PassthroughAction() : IAction, IChangeInfo;

+ 126 - 0
src/PixiEditor/Models/EnumTranslations.cs

@@ -0,0 +1,126 @@
+using Drawie.Backend.Core.Shaders.Generation;
+using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Backend.Core.Vector;
+using PixiEditor.AnimationRenderer.Core;
+using PixiEditor.ChangeableDocument.Changeables.Graph.ColorSpaces;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Animable;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.CombineSeparate;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Effects;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Matrix;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Handlers.Toolbars;
+using PixiEditor.Models.Tools;
+using PixiEditor.Views.Overlays.BrushShapeOverlay;
+using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
+
+[assembly: LocalizeEnum<StrokeCap>(StrokeCap.Butt, "BUTT_STROKE_CAP")]
+[assembly: LocalizeEnum<StrokeCap>(StrokeCap.Round, "ROUND_STROKE_CAP")]
+[assembly: LocalizeEnum<StrokeCap>(StrokeCap.Square, "SQUARE_STROKE_CAP")]
+
+[assembly: LocalizeEnum<StrokeJoin>(StrokeJoin.Bevel, "BEVEL_STROKE_JOIN")]
+[assembly: LocalizeEnum<StrokeJoin>(StrokeJoin.Round, "ROUND_STROKE_JOIN")]
+[assembly: LocalizeEnum<StrokeJoin>(StrokeJoin.Miter, "MITER_STROKE_JOIN")]
+
+[assembly: LocalizeEnum<GrayscaleNode.GrayscaleMode>(GrayscaleNode.GrayscaleMode.Weighted, "WEIGHTED_GRAYSCALE_MODE")]
+[assembly: LocalizeEnum<GrayscaleNode.GrayscaleMode>(GrayscaleNode.GrayscaleMode.Average, "AVERAGE_GRAYSCALE_MODE")]
+[assembly: LocalizeEnum<GrayscaleNode.GrayscaleMode>(GrayscaleNode.GrayscaleMode.Custom, "CUSTOM_GRAYSCALE_MODE")]
+
+[assembly: LocalizeEnum<ColorSampleMode>(ColorSampleMode.ColorManaged, "COLOR_MANAGED_COLOR_SAMPLE_MODE")]
+[assembly: LocalizeEnum<ColorSampleMode>(ColorSampleMode.Raw, "RAW_COLOR_SAMPLE_MODE")]
+
+[assembly: LocalizeEnum<TileMode>(TileMode.Clamp, "CLAMP_TILE_MODE")]
+[assembly: LocalizeEnum<TileMode>(TileMode.Decal, "DECAL_TILE_MODE")]
+[assembly: LocalizeEnum<TileMode>(TileMode.Mirror, "MIRROR_TILE_MODE")]
+[assembly: LocalizeEnum<TileMode>(TileMode.Repeat, "REPEAT_TILE_MODE")]
+
+[assembly: LocalizeEnum<CombineSeparateColorMode>(CombineSeparateColorMode.RGB, "R_G_B_COMBINE_SEPARATE_COLOR_MODE")] 
+[assembly: LocalizeEnum<CombineSeparateColorMode>(CombineSeparateColorMode.HSV, "H_S_V_COMBINE_SEPARATE_COLOR_MODE")]
+[assembly: LocalizeEnum<CombineSeparateColorMode>(CombineSeparateColorMode.HSL, "H_S_L_COMBINE_SEPARATE_COLOR_MODE")]
+
+[assembly: LocalizeEnum<VectorPathOp>(VectorPathOp.Difference, "DIFFERENCE_VECTOR_PATH_OP")]
+[assembly: LocalizeEnum<VectorPathOp>(VectorPathOp.Intersect, "INTERSECT_VECTOR_PATH_OP")]
+[assembly: LocalizeEnum<VectorPathOp>(VectorPathOp.Union, "UNION_VECTOR_PATH_OP")]
+[assembly: LocalizeEnum<VectorPathOp>(VectorPathOp.Xor, "XOR_VECTOR_PATH_OP")]
+[assembly: LocalizeEnum<VectorPathOp>(VectorPathOp.ReverseDifference, "REVERSE_DIFFERENCE_VECTOR_PATH_OP")]
+
+[assembly: LocalizeEnum<QualityPreset>(QualityPreset.VeryLow, "VERY_LOW_QUALITY_PRESET")]
+[assembly: LocalizeEnum<QualityPreset>(QualityPreset.Low, "LOW_QUALITY_PRESET")]
+[assembly: LocalizeEnum<QualityPreset>(QualityPreset.Medium, "MEDIUM_QUALITY_PRESET")]
+[assembly: LocalizeEnum<QualityPreset>(QualityPreset.High, "HIGH_QUALITY_PRESET")]
+[assembly: LocalizeEnum<QualityPreset>(QualityPreset.VeryHigh, "VERY_HIGH_QUALITY_PRESET")]
+
+[assembly: LocalizeEnum<ColorSpaceType>(ColorSpaceType.Inherit, "INHERIT_COLOR_SPACE_TYPE")]
+[assembly: LocalizeEnum<ColorSpaceType>(ColorSpaceType.Srgb, "SRGB_COLOR_SPACE_TYPE")]
+[assembly: LocalizeEnum<ColorSpaceType>(ColorSpaceType.LinearSrgb, "LINEAR_SRGB_COLOR_SPACE_TYPE")]
+
+[assembly: LocalizeEnum<EasingType>(EasingType.Linear, "LINEAR_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InSine, "IN_SINE_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutSine, "OUT_SINE_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutSine, "IN_OUT_SINE_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InQuad, "IN_QUAD_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutQuad, "OUT_QUAD_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutQuad, "IN_OUT_QUAD_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InCubic, "IN_CUBIC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutCubic, "OUT_CUBIC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutCubic, "IN_OUT_CUBIC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InQuart, "IN_QUART_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutQuart, "OUT_QUART_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutQuart, "IN_OUT_QUART_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InQuint, "IN_QUINT_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutQuint, "OUT_QUINT_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutQuint, "IN_OUT_QUINT_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InExpo, "IN_EXPO_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutExpo, "OUT_EXPO_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutExpo, "IN_OUT_EXPO_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InCirc, "IN_CIRC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutCirc, "OUT_CIRC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutCirc, "IN_OUT_CIRC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InBack, "IN_BACK_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutBack, "OUT_BACK_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutBack, "IN_OUT_BACK_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InElastic, "IN_ELASTIC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutElastic, "OUT_ELASTIC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutElastic, "IN_OUT_ELASTIC_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InBounce, "IN_BOUNCE_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.OutBounce, "OUT_BOUNCE_EASING_TYPE")]
+[assembly: LocalizeEnum<EasingType>(EasingType.InOutBounce, "IN_OUT_BOUNCE_EASING_TYPE")]
+
+[assembly: LocalizeEnum<RotationType>(RotationType.Degrees, "DEGREES_ROTATION_TYPE")]
+[assembly: LocalizeEnum<RotationType>(RotationType.Radians, "RADIANS_ROTATION_TYPE")]
+
+[assembly: LocalizeEnum<NoiseType>(NoiseType.TurbulencePerlin, "TURBULENCE_PERLIN_NOISE_TYPE")]
+[assembly: LocalizeEnum<NoiseType>(NoiseType.FractalPerlin, "FRACTAL_PERLIN_NOISE_TYPE")]
+[assembly: LocalizeEnum<NoiseType>(NoiseType.Voronoi, "VORONOI_NOISE_TYPE")]
+
+[assembly: LocalizeEnum<OutlineType>(OutlineType.Simple, "SIMPLE_OUTLINE_TYPE")]
+[assembly: LocalizeEnum<OutlineType>(OutlineType.Gaussian, "GAUSSIAN_OUTLINE_TYPE")]
+[assembly: LocalizeEnum<OutlineType>(OutlineType.PixelPerfect, "PIXEL_PERFECT_OUTLINE_TYPE")]
+
+[assembly: LocalizeEnum<VoronoiFeature>(VoronoiFeature.F1, "F1_VORONOI_FEATURE")]
+[assembly: LocalizeEnum<VoronoiFeature>(VoronoiFeature.F2, "F2_VORONOI_FEATURE")]
+[assembly: LocalizeEnum<VoronoiFeature>(VoronoiFeature.F2MinusF1, "F2_MINUS_F1_VORONOI_FEATURE")]
+
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Normal, "NORMAL_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Darken, "DARKEN_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Lighten, "LIGHTEN_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Multiply, "MULTIPLY_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Screen, "SCREEN_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Overlay, "OVERLAY_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.HardLight, "HARD_LIGHT_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.SoftLight, "SOFT_LIGHT_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.ColorDodge, "COLOR_DODGE_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.ColorBurn, "COLOR_BURN_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Hue, "HUE_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Saturation, "SATURATION_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Color, "COLOR_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Luminosity, "LUMINOSITY_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Difference, "DIFFERENCE_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Exclusion, "EXCLUSION_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.Erase, "ERASE_BLEND_MODE")]
+[assembly: LocalizeEnum<BlendMode>(BlendMode.LinearDodge, "LINEAR_DODGE_BLEND_MODE")]
+
+[assembly: LocalizeEnum<PaintBrushShape>(PaintBrushShape.Circle, "PAINT_BRUSH_SHAPE_CIRCLE")]
+[assembly: LocalizeEnum<PaintBrushShape>(PaintBrushShape.Square, "PAINT_BRUSH_SHAPE_SQUARE")]

+ 2 - 1
src/PixiEditor/Models/Handlers/IDocument.cs

@@ -33,7 +33,7 @@ internal interface IDocument : IHandler, Extensions.CommonApi.Documents.IDocumen
     public VectorPath SelectionPathBindable { get; }
     public INodeGraphHandler NodeGraphHandler { get; }
     public DocumentStructureModule StructureHelper { get; }
-    public PreviewPainter PreviewPainter { get; set; }
+    public PreviewPainter? PreviewPainter { get; set; }
     public bool AllChangesSaved { get; }
     public string CoordinatesString { get; set; }
     public IReadOnlyCollection<IStructureMemberHandler> SoftSelectedStructureMembers { get; }
@@ -50,6 +50,7 @@ internal interface IDocument : IHandler, Extensions.CommonApi.Documents.IDocumen
     public DocumentRenderer Renderer { get; }
     public ISnappingHandler SnappingHandler { get; }
     public IReadOnlyCollection<Guid> SelectedMembers { get; }
+    public PreviewPainter? MiniPreviewPainter { get; set; }
     public void RemoveSoftSelectedMember(IStructureMemberHandler member);
     public void ClearSoftSelectedMembers();
     public void AddSoftSelectedMember(IStructureMemberHandler member);

+ 11 - 7
src/PixiEditor/Models/Handlers/INodeHandler.cs

@@ -1,5 +1,6 @@
 using System.Collections.ObjectModel;
 using System.ComponentModel;
+using Avalonia;
 using Avalonia.Media;
 using ChunkyImageLib;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
@@ -8,6 +9,7 @@ using Drawie.Backend.Core;
 using PixiEditor.Models.Rendering;
 using PixiEditor.Models.Structures;
 using Drawie.Numerics;
+using PixiEditor.ViewModels.Nodes;
 
 namespace PixiEditor.Models.Handlers;
 
@@ -22,15 +24,17 @@ public interface INodeHandler : INotifyPropertyChanged, IDisposable
     public ObservableRangeCollection<INodePropertyHandler> Outputs { get; }
     public PreviewPainter? ResultPainter { get; set; }
     public VecD PositionBindable { get; set; }
+    public Rect UiSize { get; set; }
     public bool IsNodeSelected { get; set; }
     public string Icon { get; }
-    public void TraverseBackwards(Func<INodeHandler, bool> func);
-    public void TraverseBackwards(Func<INodeHandler, INodeHandler, bool> func);
-    public void TraverseBackwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, bool> func);
-    public void TraverseForwards(Func<INodeHandler, bool> func);
-    public void TraverseForwards(Func<INodeHandler, INodeHandler, bool> func);
-    public void TraverseForwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, bool> func);
-    public void TraverseForwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, INodePropertyHandler, bool> func);
+    public void TraverseBackwards(Func<INodeHandler, Traverse> func);
+    public void TraverseBackwards(Func<INodeHandler, INodeHandler, Traverse> func);
+    public void TraverseBackwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, Traverse> func);
+    public void TraverseForwards(Func<INodeHandler, Traverse> func);
+    public void TraverseForwards(Func<INodeHandler, INodeHandler, Traverse> func);
+    public void TraverseForwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, Traverse> func);
+    public void TraverseForwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, INodePropertyHandler, Traverse> func);
+    public HashSet<NodeFrameViewModelBase> Frames { get; }
     public IReadOnlyDictionary<string, INodePropertyHandler> InputPropertyMap { get; }
     public IReadOnlyDictionary<string, INodePropertyHandler> OutputPropertyMap { get; }
 }

+ 1 - 0
src/PixiEditor/Models/Handlers/INodePropertyHandler.cs

@@ -15,6 +15,7 @@ public interface INodePropertyHandler
     public ObservableCollection<INodePropertyHandler> ConnectedInputs { get; }
 
     public event NodePropertyValueChanged ValueChanged;
+    public event EventHandler ConnectedOutputChanged;
     public INodeHandler Node { get; set; }
     public Type PropertyType { get; }
     public void UpdateComputedValue();

+ 1 - 0
src/PixiEditor/Models/Handlers/IToolsHandler.cs

@@ -19,6 +19,7 @@ internal interface IToolsHandler : IHandler
     public ICollection<IToolSetHandler> AllToolSets { get; }
     public RightClickMode RightClickMode { get; set; }
     public bool EnableSharedToolbar { get; set; }
+    public bool SelectionTintingEnabled { get; set; }
     public event EventHandler<SelectedToolEventArgs> SelectedToolChanged;
     public void SetupTools(IServiceProvider services, ToolSetsConfig toolSetConfig);
     public void SetupToolsTooltipShortcuts();

+ 5 - 1
src/PixiEditor/Models/Handlers/Toolbars/PaintBrushShape.cs

@@ -1,7 +1,11 @@
-namespace PixiEditor.Models.Handlers.Toolbars;
+using System.ComponentModel;
+
+namespace PixiEditor.Models.Handlers.Toolbars;
 
 public enum PaintBrushShape
 {
+    [Description("PAINT_BRUSH_SHAPE_CIRCLE")]
     Circle,
+    [Description("PAINT_BRUSH_SHAPE_SQUARE")]
     Square,
 }

+ 29 - 11
src/PixiEditor/Models/Rendering/AffectedAreasGatherer.cs

@@ -30,6 +30,7 @@ internal class AffectedAreasGatherer
 
     public AffectedArea MainImageArea { get; private set; } = new();
     public HashSet<Guid> ChangedMembers { get; private set; } = new();
+    public bool IgnoreAnimationPreviews { get; private set; }
     public HashSet<Guid> ChangedMasks { get; private set; } = new();
     public HashSet<Guid> ChangedKeyFrames { get; private set; } = new();
 
@@ -40,10 +41,20 @@ internal class AffectedAreasGatherer
     private bool alreadyAddedWholeCanvasToEveryImagePreview = false;
 
     public AffectedAreasGatherer(KeyFrameTime activeFrame, DocumentChangeTracker tracker,
-        IReadOnlyList<IChangeInfo> changes)
+        IReadOnlyList<IChangeInfo> changes, bool refreshAllPreviews)
     {
         this.tracker = tracker;
         ActiveFrame = activeFrame;
+
+        if (refreshAllPreviews)
+        {
+            AddWholeCanvasToMainImage();
+            AddWholeCanvasToEveryImagePreview(false);
+            AddWholeCanvasToEveryMaskPreview();
+            AddAllNodesToImagePreviews();
+            return;
+        }
+
         ProcessChanges(changes);
 
         var outputNode = tracker.Document.NodeGraph.OutputNode;
@@ -102,7 +113,7 @@ internal class AffectedAreasGatherer
                     break;
                 case Size_ChangeInfo:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(false);
                     AddWholeCanvasToEveryMaskPreview();
                     break;
                 case StructureMemberMask_ChangeInfo info:
@@ -151,24 +162,25 @@ internal class AffectedAreasGatherer
                     break;
                 case SetActiveFrame_PassthroughAction:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(true);
                     AddAllNodesToImagePreviews();
+                    IgnoreAnimationPreviews = true;
                     break;
                 case KeyFrameLength_ChangeInfo:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(true);
                     break;
                 case DeleteKeyFrame_ChangeInfo:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(true);
                     break;
                 case KeyFrameVisibility_ChangeInfo:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(true);
                     break;
                 case ConnectProperty_ChangeInfo info:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(false);
                     AddToNodePreviews(info.InputNodeId);
                     if (info.OutputNodeId.HasValue)
                     {
@@ -178,7 +190,7 @@ internal class AffectedAreasGatherer
                     break;
                 case PropertyValueUpdated_ChangeInfo info:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(false);
                     AddToNodePreviews(info.NodeId);
                     break;
                 case ToggleOnionSkinning_PassthroughAction:
@@ -194,7 +206,7 @@ internal class AffectedAreasGatherer
                     break;
                 case ProcessingColorSpace_ChangeInfo:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryImagePreview(false);
                     AddWholeCanvasToEveryMaskPreview();
                     break;
             }
@@ -381,12 +393,18 @@ internal class AffectedAreasGatherer
         }
     }
 
-    private void AddWholeCanvasToEveryImagePreview()
+    private void AddWholeCanvasToEveryImagePreview(bool onlyWithKeyFrames)
     {
         if (alreadyAddedWholeCanvasToEveryImagePreview)
             return;
 
-        tracker.Document.ForEveryReadonlyMember((member) => AddWholeCanvasToImagePreviews(member.Id));
+        tracker.Document.ForEveryReadonlyMember((member) =>
+        {
+            if (!onlyWithKeyFrames || member.KeyFrames.Count > 0)
+            {
+                AddWholeCanvasToImagePreviews(member.Id);
+            }
+        });
         alreadyAddedWholeCanvasToEveryImagePreview = true;
     }
 

+ 47 - 22
src/PixiEditor/Models/Rendering/MemberPreviewUpdater.cs

@@ -8,6 +8,8 @@ using PixiEditor.Helpers;
 using PixiEditor.Models.DocumentModels;
 using PixiEditor.Models.Handlers;
 using Drawie.Numerics;
+using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ChangeableDocument.Rendering;
 
 namespace PixiEditor.Models.Rendering;
 
@@ -26,13 +28,15 @@ internal class MemberPreviewUpdater
     }
 
     public void UpdatePreviews(HashSet<Guid> membersToUpdate,
-        HashSet<Guid> masksToUpdate, HashSet<Guid> nodesToUpdate, HashSet<Guid> keyFramesToUpdate)
+        HashSet<Guid> masksToUpdate, HashSet<Guid> nodesToUpdate, HashSet<Guid> keyFramesToUpdate,
+        bool ignoreAnimationPreviews, bool renderMiniPreviews)
     {
         if (!membersToUpdate.Any() && !masksToUpdate.Any() && !nodesToUpdate.Any() &&
             !keyFramesToUpdate.Any())
             return;
 
-        UpdatePreviewPainters(membersToUpdate, masksToUpdate, nodesToUpdate, keyFramesToUpdate);
+        UpdatePreviewPainters(membersToUpdate, masksToUpdate, nodesToUpdate, keyFramesToUpdate, ignoreAnimationPreviews,
+            renderMiniPreviews);
     }
 
     /// <summary>
@@ -41,13 +45,20 @@ internal class MemberPreviewUpdater
     /// <param name="members">Members that should be rendered</param>
     /// <param name="masksToUpdate">Masks that should be rendered</param>
     private void UpdatePreviewPainters(HashSet<Guid> members, HashSet<Guid> masksToUpdate,
-        HashSet<Guid> nodesToUpdate, HashSet<Guid> keyFramesToUpdate)
+        HashSet<Guid> nodesToUpdate, HashSet<Guid> keyFramesToUpdate, bool ignoreAnimationPreviews,
+        bool renderLowPriorityPreviews)
     {
-        RenderWholeCanvasPreview();
-        RenderLayersPreview(members);
-        RenderMaskPreviews(masksToUpdate);
+        RenderWholeCanvasPreview(renderLowPriorityPreviews);
+        if (renderLowPriorityPreviews)
+        {
+            RenderLayersPreview(members);
+            RenderMaskPreviews(masksToUpdate);
+        }
 
-        RenderAnimationPreviews(members, keyFramesToUpdate);
+        if (!ignoreAnimationPreviews)
+        {
+            RenderAnimationPreviews(members, keyFramesToUpdate);
+        }
 
         RenderNodePreviews(nodesToUpdate);
     }
@@ -55,21 +66,33 @@ internal class MemberPreviewUpdater
     /// <summary>
     /// Re-renders the preview of the whole canvas which is shown as the tab icon
     /// </summary>
-    private void RenderWholeCanvasPreview()
+    /// <param name="renderMiniPreviews">Decides whether to re-render mini previews for the document</param>
+    private void RenderWholeCanvasPreview(bool renderMiniPreviews)
     {
         var previewSize = StructureHelpers.CalculatePreviewSize(internals.Tracker.Document.Size);
         //float scaling = (float)previewSize.X / doc.SizeBindable.X;
 
-        if (doc.PreviewPainter == null)
-        {
-            doc.PreviewPainter = new PreviewPainter(doc.Renderer, doc.Renderer, doc.AnimationHandler.ActiveFrameTime,
-                doc.SizeBindable, internals.Tracker.Document.ProcessingColorSpace);
-        }
+        doc.PreviewPainter ??= new PreviewPainter(doc.Renderer, doc.Renderer, doc.AnimationHandler.ActiveFrameTime,
+            doc.SizeBindable, internals.Tracker.Document.ProcessingColorSpace);
+
+        UpdateDocPreviewPainter(doc.PreviewPainter);
 
-        doc.PreviewPainter.DocumentSize = doc.SizeBindable;
-        doc.PreviewPainter.ProcessingColorSpace = internals.Tracker.Document.ProcessingColorSpace;
-        doc.PreviewPainter.FrameTime = doc.AnimationHandler.ActiveFrameTime;
-        doc.PreviewPainter.Repaint();
+        if (!renderMiniPreviews)
+            return;
+
+        doc.MiniPreviewPainter ??= new PreviewPainter(doc.Renderer, doc.Renderer,
+            doc.AnimationHandler.ActiveFrameTime,
+            doc.SizeBindable, internals.Tracker.Document.ProcessingColorSpace);
+
+        UpdateDocPreviewPainter(doc.MiniPreviewPainter);
+    }
+
+    private void UpdateDocPreviewPainter(PreviewPainter painter)
+    {
+        painter.DocumentSize = doc.SizeBindable;
+        painter.ProcessingColorSpace = internals.Tracker.Document.ProcessingColorSpace;
+        painter.FrameTime = doc.AnimationHandler.ActiveFrameTime;
+        painter.Repaint();
     }
 
     private void RenderLayersPreview(HashSet<Guid> memberGuids)
@@ -116,7 +139,8 @@ internal class MemberPreviewUpdater
                 {
                     if (!keyFramesGuids.Contains(childFrame.Id))
                     {
-                        if (!memberGuids.Contains(childFrame.LayerGuid) || !IsInFrame(childFrame))
+                        if (!memberGuids.Contains(childFrame.LayerGuid) || !IsInFrame(childFrame) ||
+                            !groupHandler.IsVisible)
                             continue;
                     }
 
@@ -134,7 +158,7 @@ internal class MemberPreviewUpdater
     private bool IsInFrame(ICelHandler cel)
     {
         return cel.StartFrameBindable <= doc.AnimationHandler.ActiveFrameBindable &&
-               cel.StartFrameBindable + cel.DurationBindable >= doc.AnimationHandler.ActiveFrameBindable;
+               cel.StartFrameBindable + cel.DurationBindable > doc.AnimationHandler.ActiveFrameBindable;
     }
 
     private void RenderFramePreview(ICelHandler cel)
@@ -265,16 +289,16 @@ internal class MemberPreviewUpdater
         nodeVm.TraverseForwards(next =>
         {
             if (next is not INodeHandler nextVm)
-                return true;
+                return Traverse.Further;
 
             var nextNode = allNodes.FirstOrDefault(x => x.Id == next.Id);
 
             if (nextNode is null || actualRepaintedNodes.Contains(next.Id))
-                return true;
+                return Traverse.Further;
 
             RequestRepaintNode(nextNode, nextVm);
             actualRepaintedNodes.Add(next.Id);
-            return true;
+            return Traverse.Further;
         });
     }
 
@@ -287,6 +311,7 @@ internal class MemberPreviewUpdater
                 nodeVm.ResultPainter = new PreviewPainter(doc.Renderer, renderable,
                     doc.AnimationHandler.ActiveFrameTime,
                     doc.SizeBindable, internals.Tracker.Document.ProcessingColorSpace);
+                nodeVm.ResultPainter.AllowPartialResolutions = false;
                 nodeVm.ResultPainter.Repaint();
             }
             else

+ 40 - 3
src/PixiEditor/Models/Rendering/PreviewPainter.cs

@@ -23,6 +23,8 @@ public class PreviewPainter : IDisposable
     public VecI DocumentSize { get; set; }
     public DocumentRenderer Renderer { get; set; }
 
+    public bool AllowPartialResolutions { get; set; } = true;
+
     public bool CanRender => canRender;
 
     public event Action<bool>? CanRenderChanged;
@@ -136,7 +138,8 @@ public class PreviewPainter : IDisposable
     {
         var dirtyArray = dirtyTextures.ToArray();
         bool couldRender = canRender;
-        canRender = PreviewRenderable?.GetPreviewBounds(FrameTime.Frame, ElementToRenderName) != null;
+        canRender = PreviewRenderable?.GetPreviewBounds(FrameTime.Frame, ElementToRenderName) != null &&
+                    painterInstances.Count > 0;
         if (couldRender != canRender)
         {
             CanRenderChanged?.Invoke(canRender);
@@ -167,11 +170,18 @@ public class PreviewPainter : IDisposable
             renderTexture.DrawingSurface.Canvas.Save();
 
             Matrix3X3? matrix = painterInstance.RequestMatrix?.Invoke();
+            VecI bounds = painterInstance.RequestRenderBounds?.Invoke() ?? VecI.Zero;
+
+            ChunkResolution finalResolution = FindResolution(bounds);
+            SamplingOptions samplingOptions = FindSamplingOptions(matrix);
 
             renderTexture.DrawingSurface.Canvas.SetMatrix(matrix ?? Matrix3X3.Identity);
+            renderTexture.DrawingSurface.Canvas.Scale((float)finalResolution.InvertedMultiplier());
 
-            RenderContext context = new(renderTexture.DrawingSurface, FrameTime, ChunkResolution.Full, DocumentSize, DocumentSize,
-                ProcessingColorSpace);
+            RenderContext context = new(renderTexture.DrawingSurface, FrameTime, finalResolution,
+                DocumentSize,
+                DocumentSize,
+                ProcessingColorSpace, samplingOptions);
 
             dirtyTextures.Remove(texture);
             Renderer.RenderNodePreview(PreviewRenderable, renderTexture.DrawingSurface, context, ElementToRenderName)
@@ -230,6 +240,31 @@ public class PreviewPainter : IDisposable
         }
     }
 
+    private ChunkResolution FindResolution(VecI bounds)
+    {
+        if (bounds.X <= 0 || bounds.Y <= 0 || !AllowPartialResolutions)
+        {
+            return ChunkResolution.Full;
+        }
+
+        double density = DocumentSize.X / (double)bounds.X;
+        if (density > 8.01)
+            return ChunkResolution.Eighth;
+        if (density > 4.01)
+            return ChunkResolution.Quarter;
+        if (density > 2.01)
+            return ChunkResolution.Half;
+        return ChunkResolution.Full;
+    }
+
+    private SamplingOptions FindSamplingOptions(Matrix3X3? matrix)
+    {
+        Matrix3X3 mtx = matrix ?? Matrix3X3.Identity;
+        return mtx.ScaleX < 1f || mtx.ScaleY < 1f
+            ? SamplingOptions.Bilinear
+            : SamplingOptions.Default;
+    }
+
     public void Dispose()
     {
         foreach (var texture in renderTextures)
@@ -242,6 +277,8 @@ public class PreviewPainter : IDisposable
 public class PainterInstance
 {
     public int RequestId { get; set; }
+    public Func<VecI> RequestRenderBounds;
+
     public Func<Matrix3X3?>? RequestMatrix;
     public Action RequestRepaint;
 }

+ 35 - 13
src/PixiEditor/Models/Rendering/SceneRenderer.cs

@@ -4,6 +4,7 @@ using Drawie.Backend.Core.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Rendering;
 using Drawie.Backend.Core.Surfaces;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
@@ -34,11 +35,12 @@ internal class SceneRenderer : IDisposable
         DocumentViewModel = documentViewModel;
     }
 
-    public void RenderScene(DrawingSurface target, ChunkResolution resolution, string? targetOutput = null)
+    public void RenderScene(DrawingSurface target, ChunkResolution resolution, SamplingOptions samplingOptions,
+        string? targetOutput = null)
     {
         if (Document.Renderer.IsBusy || DocumentViewModel.Busy ||
             target.DeviceClipBounds.Size.ShortestAxis <= 0) return;
-        RenderOnionSkin(target, resolution, targetOutput);
+        RenderOnionSkin(target, resolution, samplingOptions, targetOutput);
 
         string adjustedTargetOutput = targetOutput ?? "";
 
@@ -55,7 +57,7 @@ internal class SceneRenderer : IDisposable
                 cachedTextures[adjustedTargetOutput]?.Dispose();
             }
 
-            var rendered = RenderGraph(target, resolution, targetOutput, finalGraph);
+            var rendered = RenderGraph(target, resolution, samplingOptions, targetOutput, finalGraph);
             cachedTextures[adjustedTargetOutput] = rendered;
             return;
         }
@@ -64,11 +66,21 @@ internal class SceneRenderer : IDisposable
         Matrix3X3 matrixDiff = SolveMatrixDiff(target, cachedTexture);
         int saved = target.Canvas.Save();
         target.Canvas.SetMatrix(matrixDiff);
-        target.Canvas.DrawSurface(cachedTexture.DrawingSurface, 0, 0);
+        if (samplingOptions == SamplingOptions.Default)
+        {
+            target.Canvas.DrawSurface(cachedTexture.DrawingSurface, 0, 0);
+        }
+        else
+        {
+            using var img = cachedTexture.DrawingSurface.Snapshot();
+            target.Canvas.DrawImage(img, 0, 0, samplingOptions);
+        }
+
         target.Canvas.RestoreToCount(saved);
     }
 
-    private Texture RenderGraph(DrawingSurface target, ChunkResolution resolution, string? targetOutput,
+    private Texture RenderGraph(DrawingSurface target, ChunkResolution resolution, SamplingOptions samplingOptions,
+        string? targetOutput,
         IReadOnlyNodeGraph finalGraph)
     {
         DrawingSurface renderTarget = target;
@@ -104,13 +116,22 @@ internal class SceneRenderer : IDisposable
         }
 
         RenderContext context = new(renderTarget, DocumentViewModel.AnimationHandler.ActiveFrameTime,
-            resolution, finalSize, Document.Size, Document.ProcessingColorSpace);
+            resolution, finalSize, Document.Size, Document.ProcessingColorSpace, samplingOptions);
         context.TargetOutput = targetOutput;
         finalGraph.Execute(context);
 
         if (renderTexture != null)
         {
-            target.Canvas.DrawSurface(renderTexture.DrawingSurface, 0, 0);
+            if (samplingOptions == SamplingOptions.Default)
+            {
+                target.Canvas.DrawSurface(renderTexture.DrawingSurface, 0, 0);
+            }
+            else
+            {
+                using var snapshot = renderTexture.DrawingSurface.Snapshot();
+                target.Canvas.DrawImage(snapshot, 0, 0, samplingOptions);
+            }
+
             target.Canvas.RestoreToCount(restoreCanvasTo);
         }
 
@@ -168,7 +189,9 @@ internal class SceneRenderer : IDisposable
         }
 
         bool renderInDocumentSize = RenderInOutputSize(finalGraph);
-        VecI compareSize = renderInDocumentSize ? (VecI)(Document.Size * resolution.Multiplier()) : target.DeviceClipBounds.Size;
+        VecI compareSize = renderInDocumentSize
+            ? (VecI)(Document.Size * resolution.Multiplier())
+            : target.DeviceClipBounds.Size;
 
         if (cachedTexture.DrawingSurface.DeviceClipBounds.Size != compareSize)
         {
@@ -243,7 +266,7 @@ internal class SceneRenderer : IDisposable
         return highDpiRenderNodePresent;
     }
 
-    private void RenderOnionSkin(DrawingSurface target, ChunkResolution resolution, string? targetOutput)
+    private void RenderOnionSkin(DrawingSurface target, ChunkResolution resolution, SamplingOptions sampling, string? targetOutput)
     {
         var animationData = Document.AnimationData;
         if (!DocumentViewModel.AnimationHandler.OnionSkinningEnabledBindable)
@@ -268,9 +291,9 @@ internal class SceneRenderer : IDisposable
 
             double finalOpacity = onionOpacity * alphaFalloffMultiplier * (animationData.OnionFrames - i + 1);
 
+
             RenderContext onionContext = new(target, frame, resolution, renderOutputSize, Document.Size,
-                Document.ProcessingColorSpace,
-                finalOpacity);
+                Document.ProcessingColorSpace, sampling, finalOpacity);
             onionContext.TargetOutput = targetOutput;
             finalGraph.Execute(onionContext);
         }
@@ -286,8 +309,7 @@ internal class SceneRenderer : IDisposable
 
             double finalOpacity = onionOpacity * alphaFalloffMultiplier * (animationData.OnionFrames - i + 1);
             RenderContext onionContext = new(target, frame, resolution, renderOutputSize, Document.Size,
-                Document.ProcessingColorSpace,
-                finalOpacity);
+                Document.ProcessingColorSpace, sampling, finalOpacity);
             onionContext.TargetOutput = targetOutput;
             finalGraph.Execute(onionContext);
         }

+ 144 - 0
src/PixiEditor/Models/Structures/ObservableHashSet.cs

@@ -0,0 +1,144 @@
+using System.Collections;
+using System.Collections.Immutable;
+using System.Collections.Specialized;
+using System.Runtime.Serialization;
+
+namespace PixiEditor.Models.Structures;
+
+public class ObservableHashSet<T> : ISet<T>, IReadOnlySet<T>, IDeserializationCallback, ISerializable, INotifyCollectionChanged
+{
+    private readonly HashSet<T> setImplementation;
+
+    public ObservableHashSet()
+    {
+        setImplementation = new HashSet<T>();
+    }
+
+    public ObservableHashSet(IEnumerable<T> collection)
+    {
+        setImplementation = new HashSet<T>(collection);
+    }
+    
+    public bool Add(T item)
+    {
+        var isAdded = setImplementation.Add(item);
+
+        if (isAdded)
+        {
+            CallCollectionChanged(NotifyCollectionChangedAction.Add, item);
+        }
+        
+        return isAdded;
+    }
+
+    void ICollection<T>.Add(T item) => Add(item);
+
+    public void Clear()
+    {
+        setImplementation.Clear();
+        CallCollectionChanged(NotifyCollectionChangedAction.Reset, Array.Empty<T>(), setImplementation.ToList());
+    }
+
+    public bool Remove(T item)
+    {
+        var isRemoved = setImplementation.Remove(item);
+
+        if (isRemoved)
+        {
+            CallCollectionChanged(NotifyCollectionChangedAction.Remove, item);
+        }
+        
+        return isRemoved;
+    }
+
+    /// <summary>
+    /// Not implemented
+    /// </summary>
+    /// <exception cref="NotSupportedException">This method is not implemented.</exception>
+    public void ExceptWith(IEnumerable<T> other)
+    {
+        throw new NotSupportedException();
+    }
+
+    /// <summary>
+    /// Not implemented
+    /// </summary>
+    /// <exception cref="NotSupportedException">This method is not implemented.</exception>
+    public void IntersectWith(IEnumerable<T> other)
+    {
+        throw new NotSupportedException();
+    }
+
+    /// <summary>
+    /// Not implemented
+    /// </summary>
+    /// <exception cref="NotSupportedException">This method is not implemented.</exception>
+    public void SymmetricExceptWith(IEnumerable<T> other)
+    {
+        throw new NotSupportedException();
+    }
+
+    public void UnionWith(IEnumerable<T> other)
+    {
+        var allOther = other.ToImmutableHashSet();
+        var addedOnly = allOther.Except(setImplementation);
+        
+        setImplementation.UnionWith(allOther);
+        CallCollectionChanged(
+            NotifyCollectionChangedAction.Reset, 
+            addedOnly.ToList());
+    }
+
+    public void ReplaceBy(IEnumerable<T> other)
+    {
+        var otherOriginal = other.ToHashSet();
+        var original = setImplementation.ToImmutableHashSet();
+        var removed = original.Except(otherOriginal);
+
+        setImplementation.Clear();
+        setImplementation.UnionWith(otherOriginal);
+        CallCollectionChanged(NotifyCollectionChangedAction.Replace, otherOriginal.ToList(), removed.ToList());
+    }
+
+    public bool IsProperSubsetOf(IEnumerable<T> other) => setImplementation.IsProperSubsetOf(other);
+
+    public bool IsProperSupersetOf(IEnumerable<T> other) => setImplementation.IsProperSupersetOf(other);
+
+    public bool IsSubsetOf(IEnumerable<T> other) => setImplementation.IsSubsetOf(other);
+
+    public bool IsSupersetOf(IEnumerable<T> other) => setImplementation.IsSupersetOf(other);
+
+    public bool Overlaps(IEnumerable<T> other) => setImplementation.Overlaps(other);
+
+    public bool SetEquals(IEnumerable<T> other) => setImplementation.SetEquals(other);
+
+    public bool Contains(T item) => setImplementation.Contains(item);
+
+    public void CopyTo(T[] array, int arrayIndex) => setImplementation.CopyTo(array, arrayIndex);
+
+    public IEnumerator<T> GetEnumerator() => setImplementation.GetEnumerator();
+
+    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)setImplementation).GetEnumerator();
+
+    public int Count => setImplementation.Count;
+
+    bool ICollection<T>.IsReadOnly => ((ISet<T>)setImplementation).IsReadOnly;
+
+    void IDeserializationCallback.OnDeserialization(object? sender) => setImplementation.OnDeserialization(sender);
+
+    void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context) => setImplementation.GetObjectData(info, context);
+
+    private void CallCollectionChanged(NotifyCollectionChangedAction action) =>
+        CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action));
+    
+    private void CallCollectionChanged(NotifyCollectionChangedAction action, IList added, IList removed) =>
+        CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, added, removed));
+
+    private void CallCollectionChanged(NotifyCollectionChangedAction action, IList changed) =>
+        CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, changed));
+
+    private void CallCollectionChanged(NotifyCollectionChangedAction action, T item) =>
+        CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, item));
+
+    public event NotifyCollectionChangedEventHandler? CollectionChanged;
+}

+ 5 - 1
src/PixiEditor/Models/Tools/BrightnessMode.cs

@@ -1,7 +1,11 @@
-namespace PixiEditor.Models.Tools;
+using System.ComponentModel;
+
+namespace PixiEditor.Models.Tools;
 
 public enum BrightnessMode
 {
+    [Description("BRIGHTNESS_MODE_DEFAULT")]
     Default,
+    [Description("BRIGHTNESS_MODE_REPEAT")]
     Repeat
 }

+ 2 - 2
src/PixiEditor/Properties/AssemblyInfo.cs

@@ -43,5 +43,5 @@ using System.Runtime.InteropServices;
 // You can specify all the values or you can default the Build and Revision Numbers
 // by using the '*' as shown below:
 // [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("2.0.1.9")]
-[assembly: AssemblyFileVersion("2.0.1.9")]
+[assembly: AssemblyVersion("2.0.1.11")]
+[assembly: AssemblyFileVersion("2.0.1.11")]

+ 4 - 7
src/PixiEditor/Styles/Templates/NodeFrameView.axaml

@@ -7,13 +7,10 @@
         <Setter Property="Template">
             <Setter.Value>
                 <ControlTemplate>
-                    <Grid Width="{Binding Size.X, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeFrameView}}">
-                        <Rectangle Fill="{TemplateBinding Background}"
-                                   Stroke="{TemplateBinding BorderBrush}"
-                                   StrokeThickness="2" RadiusX="10" RadiusY="10"
-                                   Width="{Binding Size.X, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeFrameView}}"
-                                   Height="{Binding Size.Y, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeFrameView}}" />
-                    </Grid>
+                    <Path Data="{Binding Geometry, RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeFrameView}}"
+                          Fill="{TemplateBinding Background}"
+                          Stroke="{TemplateBinding BorderBrush}"
+                          StrokeThickness="2" />
                 </ControlTemplate>
             </Setter.Value>
         </Setter>

+ 4 - 10
src/PixiEditor/Styles/Templates/NodeGraphView.axaml

@@ -59,7 +59,8 @@
                                         RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
                                     SocketDropCommand="{Binding SocketDropCommand,
                                         RelativeSource={RelativeSource FindAncestor, AncestorType=nodes:NodeGraphView}}"
-                                    ResultPreview="{Binding ResultPainter}" />
+                                    ResultPreview="{Binding ResultPainter}"
+                                    Bounds="{Binding UiSize, Mode=OneWayToSource}" />
                             </DataTemplate>
                         </ItemsControl.ItemTemplate>
                         <ItemsControl.ItemContainerTheme>
@@ -109,9 +110,8 @@
                         <ItemsControl.ItemTemplate>
                             <DataTemplate>
                                 <nodes:NodeFrameView
-                                    TopLeft="{Binding TopLeft}"
-                                    BottomRight="{Binding BottomRight}"
-                                    Size="{Binding Size}">
+                                    Geometry="{Binding Geometry}"
+                                    ClipToBounds="False">
                                     <nodes:NodeFrameView.Background>
                                         <MultiBinding Converter="{converters:UnsetSkipMultiConverter}">
                                             <Binding Path="InternalName"
@@ -131,12 +131,6 @@
                                 </nodes:NodeFrameView>
                             </DataTemplate>
                         </ItemsControl.ItemTemplate>
-                        <ItemsControl.ItemContainerTheme>
-                            <ControlTheme TargetType="ContentPresenter">
-                                <Setter Property="Canvas.Left" Value="{Binding TopLeft.X}" />
-                                <Setter Property="Canvas.Top" Value="{Binding TopLeft.Y}" />
-                            </ControlTheme>
-                        </ItemsControl.ItemContainerTheme>
                     </ItemsControl>
                 </Grid>
             </ControlTemplate>

+ 15 - 0
src/PixiEditor/ViewModels/Document/DocumentViewModel.cs

@@ -35,6 +35,7 @@ using PixiEditor.Models.Tools;
 using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Workspace;
 using PixiEditor.Models.IO;
+using PixiEditor.Models.Position;
 using PixiEditor.Parser;
 using PixiEditor.Parser.Skia;
 using PixiEditor.UI.Common.Localization;
@@ -184,6 +185,16 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
 
     public IStructureMemberHandler? SelectedStructureMember { get; private set; } = null;
 
+    private PreviewPainter miniPreviewPainter;
+
+    public PreviewPainter MiniPreviewPainter
+    {
+        get => miniPreviewPainter;
+        set
+        {
+            SetProperty(ref miniPreviewPainter, value);
+        }
+    }
 
     private PreviewPainter previewSurface;
 
@@ -374,11 +385,15 @@ internal partial class DocumentViewModel : PixiObservableObject, IDocument
             viewModel.MarkAsSaved();
         }));
 
+        acc.AddActions(new SetActiveFrame_PassthroughAction(1), new RefreshPreviews_PassthroughAction());
+
         foreach (var factory in allFactories)
         {
             factory.ResourceLocator = null;
         }
 
+        viewModel.NodeGraph.FinalizeCreation();
+
         return viewModel;
 
 

+ 81 - 0
src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs

@@ -19,6 +19,8 @@ namespace PixiEditor.ViewModels.Document;
 
 internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposable
 {
+    private bool isFullyCreated;
+    
     public DocumentViewModel DocumentViewModel { get; }
     public ObservableCollection<INodeHandler> AllNodes { get; } = new();
     public ObservableCollection<NodeConnectionViewModel> Connections { get; } = new();
@@ -108,6 +110,9 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposabl
         connection.OutputProperty.ConnectedInputs.Add(connection.InputProperty);
 
         Connections.Add(connection);
+        
+        UpdatesFramesPartOf(connection.InputNode);
+        UpdatesFramesPartOf(connection.OutputNode);
 
         StructureTree.Update(this);
     }
@@ -123,6 +128,9 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposabl
             Connections.Remove(connection);
         }
 
+        UpdatesFramesPartOf(connection.InputNode);
+        UpdatesFramesPartOf(connection.OutputNode);
+        
         var node = AllNodes.FirstOrDefault(x => x.Id == nodeId);
         if (node != null)
         {
@@ -136,6 +144,79 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposabl
         StructureTree.Update(this);
     }
 
+    public void UpdatesFramesPartOf(INodeHandler node)
+    {
+        if (!isFullyCreated)
+            return;
+
+        var lastKnownFramesPartOf = node.Frames.OfType<NodeZoneViewModel>().ToHashSet();
+        var startLookup = Frames.OfType<NodeZoneViewModel>().ToDictionary(x => x.Start);
+        var currentlyPartOf = new HashSet<NodeZoneViewModel>();
+        
+        node.TraverseBackwards(x =>
+        {
+            if (x is IPairNodeEndViewModel)
+                return Traverse.NoFurther;
+
+            if (x is not IPairNodeStartViewModel)
+                return Traverse.Further;
+
+            var zone = startLookup[x];
+            currentlyPartOf.Add(zone);
+
+            return Traverse.Further;
+        });
+
+        foreach (var frame in currentlyPartOf)
+        {
+            frame.Nodes.Add(node);
+            node.Frames.Add(frame);
+        }
+
+        lastKnownFramesPartOf.ExceptWith(currentlyPartOf);
+        foreach (var removedFrom in lastKnownFramesPartOf)
+        {
+            removedFrom.Nodes.Remove(node);
+            node.Frames.Remove(removedFrom);
+        }
+    }
+
+    public void FinalizeCreation()
+    {
+        if (isFullyCreated)
+            return;
+        
+        isFullyCreated = true;
+        
+        foreach (var nodeZoneViewModel in Frames.OfType<NodeZoneViewModel>())
+        {
+            UpdateNodesPartOf(nodeZoneViewModel);
+        }
+    }
+
+    private static void UpdateNodesPartOf(NodeZoneViewModel zone)
+    {
+        var currentlyPartOf = new HashSet<INodeHandler>([zone.Start, zone.End]);
+
+        foreach (var node in zone.Start
+                     .Outputs
+                     .SelectMany(x => x.ConnectedInputs)
+                     .Select(x => x.Node))
+        {
+            node.TraverseForwards((x) =>
+            {
+                if (x is IPairNodeEndViewModel)
+                    return Traverse.NoFurther;
+
+                currentlyPartOf.Add(x);
+
+                return Traverse.Further;
+            });
+        }
+
+        zone.Nodes.ReplaceBy(currentlyPartOf);
+    }
+
     public void RemoveConnections(Guid nodeId)
     {
         var connections = Connections

+ 21 - 1
src/PixiEditor/ViewModels/Document/Nodes/FilterNodes/ApplyFilterNodeViewModel.cs

@@ -1,3 +1,4 @@
+using System.Collections.Specialized;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.FilterNodes;
 using PixiEditor.UI.Common.Fonts;
 using PixiEditor.ViewModels.Nodes;
@@ -5,4 +6,23 @@ using PixiEditor.ViewModels.Nodes;
 namespace PixiEditor.ViewModels.Document.Nodes.FilterNodes;
 
 [NodeViewModel("APPLY_FILTER_NODE", "FILTERS", PixiPerfectIcons.Magic)]
-internal class ApplyFilterNodeViewModel : NodeViewModel<ApplyFilterNode>;
+internal class ApplyFilterNodeViewModel : NodeViewModel<ApplyFilterNode>
+{
+    private NodePropertyViewModel MaskInput { get; set; }
+    
+    private NodePropertyViewModel MaskInvertInput { get; set; }
+
+    public override void OnInitialized()
+    {
+        MaskInput = FindInputProperty("Mask");
+        MaskInvertInput = FindInputProperty("InvertMask");
+        
+        UpdateInvertVisible();
+        MaskInput.ConnectedOutputChanged += (_, _) => UpdateInvertVisible();
+    }
+
+    private void UpdateInvertVisible()
+    {
+        MaskInvertInput.IsVisible = MaskInput.ConnectedOutput != null;
+    }
+}

+ 1 - 1
src/PixiEditor/ViewModels/Document/Nodes/ModifyImageLeftNodeViewModel.cs

@@ -5,4 +5,4 @@ using PixiEditor.ViewModels.Nodes;
 namespace PixiEditor.ViewModels.Document.Nodes;
 
 [NodeViewModel("MODIFY_IMAGE_LEFT_NODE", "IMAGE", PixiPerfectIcons.PutImage)]
-internal class ModifyImageLeftNodeViewModel : NodeViewModel<ModifyImageLeftNode>;
+internal class ModifyImageLeftNodeViewModel : NodeViewModel<ModifyImageLeftNode>, IPairNodeStartViewModel;

+ 1 - 1
src/PixiEditor/ViewModels/Document/Nodes/ModifyImageRightNodeViewModel.cs

@@ -4,4 +4,4 @@ using PixiEditor.ViewModels.Nodes;
 namespace PixiEditor.ViewModels.Document.Nodes;
 
 [NodeViewModel("MODIFY_IMAGE_RIGHT_NODE", "IMAGE", null)]
-internal class ModifyImageRightNodeViewModel : NodeViewModel<ModifyImageRightNode>;
+internal class ModifyImageRightNodeViewModel : NodeViewModel<ModifyImageRightNode>, IPairNodeEndViewModel;

+ 29 - 1
src/PixiEditor/ViewModels/Document/Nodes/NoiseNodeViewModel.cs

@@ -1,8 +1,36 @@
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.UI.Common.Fonts;
 using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ViewModels.Nodes.Properties;
 
 namespace PixiEditor.ViewModels.Document.Nodes;
 
 [NodeViewModel("NOISE_NODE", "IMAGE", PixiPerfectIcons.Noise)]
-internal class NoiseNodeViewModel : NodeViewModel<NoiseNode>;
+internal class NoiseNodeViewModel : NodeViewModel<NoiseNode>
+{
+    private GenericEnumPropertyViewModel Type { get; set; }
+    private GenericEnumPropertyViewModel VoronoiFeature { get; set; }
+    private NodePropertyViewModel Randomness { get; set; }
+    private NodePropertyViewModel AngleOffset { get; set; }
+
+    public override void OnInitialized()
+    {
+        Type = FindInputProperty("NoiseType") as GenericEnumPropertyViewModel;
+        VoronoiFeature = FindInputProperty("VoronoiFeature") as GenericEnumPropertyViewModel;
+        Randomness = FindInputProperty("Randomness");
+        AngleOffset = FindInputProperty("AngleOffset");
+
+        Type.ValueChanged += (_, _) => UpdateInputVisibility();
+        UpdateInputVisibility();
+    }
+
+    private void UpdateInputVisibility()
+    {
+        if (Type.Value is not NoiseType type)
+            return;
+        
+        Randomness.IsVisible = type == NoiseType.Voronoi;
+        VoronoiFeature.IsVisible = type == NoiseType.Voronoi;
+        AngleOffset.IsVisible = type == NoiseType.Voronoi;
+    }
+}

+ 2 - 2
src/PixiEditor/ViewModels/Document/Nodes/StructureMemberViewModel.cs

@@ -60,10 +60,10 @@ internal abstract class StructureMemberViewModel<T> : NodeViewModel<T>, IStructu
                 if (node is IFolderHandler parent && input is { PropertyName: FolderNode.ContentInternalName })
                 {
                     visible = parent.IsVisibleBindable;
-                    return visible;
+                    return visible ? Traverse.Further : Traverse.Exit;
                 }
 
-                return true;
+                return Traverse.Further;
             });
 
             return visible;

+ 2 - 1
src/PixiEditor/ViewModels/Document/StructureTree.cs

@@ -1,5 +1,6 @@
 using System.Collections.ObjectModel;
 using PixiEditor.Models.Handlers;
+using PixiEditor.ViewModels.Nodes;
 
 namespace PixiEditor.ViewModels.Document;
 
@@ -60,7 +61,7 @@ internal class StructureTree
 
             _memberMap.TryAdd(node, lastRoot);
 
-            return true;
+            return Traverse.Further;
         });
 
         List<IStructureMemberHandler> toRemove = new();

+ 6 - 0
src/PixiEditor/ViewModels/Nodes/IPairNodeEndViewModel.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.ViewModels.Nodes;
+
+public interface IPairNodeEndViewModel
+{
+    
+}

+ 6 - 0
src/PixiEditor/ViewModels/Nodes/IPairNodeStartViewModel.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.ViewModels.Nodes;
+
+public interface IPairNodeStartViewModel
+{
+    
+}

+ 2 - 28
src/PixiEditor/ViewModels/Nodes/NodeFrameViewModel.cs

@@ -1,9 +1,4 @@
-using System.Collections.ObjectModel;
-using System.Collections.Specialized;
-using System.ComponentModel;
-using CommunityToolkit.Mvvm.ComponentModel;
-using PixiEditor.Models.Handlers;
-using Drawie.Numerics;
+using PixiEditor.Models.Handlers;
 
 namespace PixiEditor.ViewModels.Nodes;
 
@@ -16,27 +11,6 @@ internal sealed class NodeFrameViewModel : NodeFrameViewModelBase
 
     protected override void CalculateBounds()
     {
-        
-        // TODO: Use the GetBounds like in NodeZoneViewModel
-        if (Nodes.Count == 0)
-        {
-            if (TopLeft == BottomRight)
-            {
-                BottomRight = TopLeft + new VecD(100, 100);
-            }
-            
-            return;
-        }
-        
-        var minX = Nodes.Min(n => n.PositionBindable.X) - 30;
-        var minY = Nodes.Min(n => n.PositionBindable.Y) - 45;
-        
-        var maxX = Nodes.Max(n => n.PositionBindable.X) + 130;
-        var maxY = Nodes.Max(n => n.PositionBindable.Y) + 130;
-
-        TopLeft = new VecD(minX, minY);
-        BottomRight = new VecD(maxX, maxY);
-
-        Size = BottomRight - TopLeft;
+        throw new NotImplementedException();
     }
 }

+ 23 - 21
src/PixiEditor/ViewModels/Nodes/NodeFrameViewModelBase.cs

@@ -1,20 +1,23 @@
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.ComponentModel;
+using Avalonia.Media;
 using CommunityToolkit.Mvvm.ComponentModel;
 using PixiEditor.Models.Handlers;
 using Drawie.Numerics;
+using PixiEditor.Models.Structures;
 
 namespace PixiEditor.ViewModels.Nodes;
 
 public abstract class NodeFrameViewModelBase : ObservableObject
 {
     private Guid id;
+    private StreamGeometry geometry;
     private VecD topLeft;
     private VecD bottomRight;
     private VecD size;
     
-    public ObservableCollection<INodeHandler> Nodes { get; }
+    public ObservableHashSet<INodeHandler> Nodes { get; }
 
     public string InternalName { get; init; }
     
@@ -24,28 +27,16 @@ public abstract class NodeFrameViewModelBase : ObservableObject
         set => SetProperty(ref id, value);
     }
     
-    public VecD TopLeft
+    public StreamGeometry Geometry
     {
-        get => topLeft;
-        set => SetProperty(ref topLeft, value);
-    }
-
-    public VecD BottomRight
-    {
-        get => bottomRight;
-        set => SetProperty(ref bottomRight, value);
-    }
-
-    public VecD Size
-    {
-        get => size;
-        set => SetProperty(ref size, value);
+        get => geometry;
+        set => SetProperty(ref geometry, value);
     }
 
     public NodeFrameViewModelBase(Guid id, IEnumerable<INodeHandler> nodes)
     {
         Id = id;
-        Nodes = new ObservableCollection<INodeHandler>(nodes);
+        Nodes = new(nodes);
 
         Nodes.CollectionChanged += OnCollectionChanged;
         AddHandlers(Nodes);
@@ -54,13 +45,22 @@ public abstract class NodeFrameViewModelBase : ObservableObject
     private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
     {
         var action = e.Action;
-        if (action != NotifyCollectionChangedAction.Add && action != NotifyCollectionChangedAction.Remove && action != NotifyCollectionChangedAction.Replace && action != NotifyCollectionChangedAction.Reset)
+        if (action is
+            not NotifyCollectionChangedAction.Add and
+            not NotifyCollectionChangedAction.Remove and
+            not NotifyCollectionChangedAction.Replace and
+            not NotifyCollectionChangedAction.Reset)
         {
             return;
         }
+
+        CalculateBounds();
+        
+        if (e.NewItems != null)
+            AddHandlers(e.NewItems.Cast<INodeHandler>());
         
-        AddHandlers((IEnumerable<NodeViewModel>)e.NewItems);
-        RemoveHandlers((IEnumerable<NodeViewModel>)e.OldItems);
+        if (e.OldItems != null)
+            RemoveHandlers(e.OldItems.Cast<INodeHandler>());
     }
 
     private void AddHandlers(IEnumerable<INodeHandler> nodes)
@@ -81,7 +81,9 @@ public abstract class NodeFrameViewModelBase : ObservableObject
 
     private void NodePropertyChanged(object? sender, PropertyChangedEventArgs e)
     {
-        if (e.PropertyName != nameof(INodeHandler.PositionBindable))
+        if (e.PropertyName is
+            not nameof(INodeHandler.PositionBindable) and
+            not nameof(INodeHandler.UiSize))
         {
             return;
         }

+ 2 - 0
src/PixiEditor/ViewModels/Nodes/NodePropertyViewModel.cs

@@ -27,6 +27,7 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
     private INodePropertyHandler? connectedOutput;
 
     public event NodePropertyValueChanged? ValueChanged;
+    public event EventHandler? ConnectedOutputChanged;
 
     public string DisplayName
     {
@@ -115,6 +116,7 @@ internal abstract class NodePropertyViewModel : ViewModelBase, INodePropertyHand
             if (SetProperty(ref connectedOutput, value))
             {
                 OnPropertyChanged(nameof(ShowInputField));
+                ConnectedOutputChanged?.Invoke(this, EventArgs.Empty);
             }
         }
     }

+ 99 - 24
src/PixiEditor/ViewModels/Nodes/NodeViewModel.cs

@@ -1,6 +1,7 @@
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
 using System.Reflection;
 using Avalonia;
 using Avalonia.Media;
@@ -25,6 +26,7 @@ namespace PixiEditor.ViewModels.Nodes;
 internal abstract class NodeViewModel : ObservableObject, INodeHandler
 {
     private LocalizedString displayName;
+    private Rect size;
     private IBrush? categoryBrush;
     private string? nodeNameBindable;
     private VecD position;
@@ -111,6 +113,12 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
         }
     }
 
+    public Rect UiSize
+    {
+        get => size;
+        set => SetProperty(ref size, value);
+    }
+
     public ObservableRangeCollection<INodePropertyHandler> Inputs
     {
         get => inputs;
@@ -218,7 +226,11 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
 
     public string Icon => icon ??= GetType().GetCustomAttribute<NodeViewModelAttribute>().Icon;
 
-    public void TraverseBackwards(Func<INodeHandler, bool> func)
+    [DoesNotReturn]
+    private void ThrowInvalidTraverseResult(Traverse traverse) =>
+        throw new IndexOutOfRangeException($"Invalid Traverse Option '{traverse}'");
+
+    public void TraverseBackwards(Func<INodeHandler, Traverse> func)
     {
         var visited = new HashSet<INodeHandler>();
         var queueNodes = new Queue<INodeHandler>();
@@ -233,9 +245,18 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
                 continue;
             }
 
-            if (!func(node))
+            var result = func(node);
+            switch (result)
             {
-                return;
+                case Traverse.NoFurther:
+                    continue;
+                case Traverse.Exit:
+                    return;
+                case Traverse.Further:
+                    break;
+                default:
+                    ThrowInvalidTraverseResult(result);
+                    break;
             }
 
             foreach (var inputProperty in node.Inputs)
@@ -248,7 +269,7 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
         }
     }
 
-    public void TraverseBackwards(Func<INodeHandler, INodeHandler, bool> func)
+    public void TraverseBackwards(Func<INodeHandler, INodeHandler, Traverse> func)
     {
         var visited = new HashSet<INodeHandler>();
         var queueNodes = new Queue<(INodeHandler, INodeHandler)>();
@@ -263,9 +284,18 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
                 continue;
             }
 
-            if (!func(node.Item1, node.Item2))
+            var result = func(node.Item1, node.Item2);
+            switch (result)
             {
-                return;
+                case Traverse.NoFurther:
+                    continue;
+                case Traverse.Exit:
+                    return;
+                case Traverse.Further:
+                    break;
+                default:
+                    ThrowInvalidTraverseResult(result);
+                    break;
             }
 
             foreach (var inputProperty in node.Item1.Inputs)
@@ -278,7 +308,7 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
         }
     }
 
-    public void TraverseBackwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, bool> func)
+    public void TraverseBackwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, Traverse> func)
     {
         var visited = new HashSet<INodeHandler>();
         var queueNodes = new Queue<(INodeHandler, INodeHandler, INodePropertyHandler)>();
@@ -292,10 +322,18 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
             {
                 continue;
             }
-
-            if (!func(node.Item1, node.Item2, node.Item3))
+            var result = func(node.Item1, node.Item2, node.Item3);
+            switch (result)
             {
-                return;
+                case Traverse.NoFurther:
+                    continue;
+                case Traverse.Exit:
+                    return;
+                case Traverse.Further:
+                    break;
+                default:
+                    ThrowInvalidTraverseResult(result);
+                    break;
             }
 
             foreach (var inputProperty in node.Item1.Inputs)
@@ -308,7 +346,7 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
         }
     }
 
-    public void TraverseForwards(Func<INodeHandler, bool> func)
+    public void TraverseForwards(Func<INodeHandler, Traverse> func)
     {
         var visited = new HashSet<INodeHandler>();
         var queueNodes = new Queue<INodeHandler>();
@@ -322,10 +360,19 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
             {
                 continue;
             }
-
-            if (!func(node))
+            
+            var result = func(node);
+            switch (result)
             {
-                return;
+                case Traverse.NoFurther:
+                    continue;
+                case Traverse.Exit:
+                    return;
+                case Traverse.Further:
+                    break;
+                default:
+                    ThrowInvalidTraverseResult(result);
+                    break;
             }
 
             foreach (var outputProperty in node.Outputs)
@@ -338,7 +385,7 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
         }
     }
 
-    public void TraverseForwards(Func<INodeHandler, INodeHandler, bool> func)
+    public void TraverseForwards(Func<INodeHandler, INodeHandler, Traverse> func)
     {
         var visited = new HashSet<INodeHandler>();
         var queueNodes = new Queue<(INodeHandler, INodeHandler)>();
@@ -352,10 +399,19 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
             {
                 continue;
             }
-
-            if (!func(node.Item1, node.Item2))
+            
+            var result = func(node.Item1, node.Item2);
+            switch (result)
             {
-                return;
+                case Traverse.NoFurther:
+                    continue;
+                case Traverse.Exit:
+                    return;
+                case Traverse.Further:
+                    break;
+                default:
+                    ThrowInvalidTraverseResult(result);
+                    break;
             }
 
             foreach (var outputProperty in node.Item1.Outputs)
@@ -368,7 +424,7 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
         }
     }
 
-    public void TraverseForwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, bool> func)
+    public void TraverseForwards(Func<INodeHandler, INodeHandler, INodePropertyHandler, Traverse> func)
     {
         var visited = new HashSet<INodeHandler>();
         var queueNodes = new Queue<(INodeHandler, INodeHandler, INodePropertyHandler)>();
@@ -383,9 +439,18 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
                 continue;
             }
 
-            if (!func(node.Item1, node.Item2, node.Item3))
+            var result = func(node.Item1, node.Item2, node.Item3);
+            switch (result)
             {
-                return;
+                case Traverse.NoFurther:
+                    continue;
+                case Traverse.Exit:
+                    return;
+                case Traverse.Further:
+                    break;
+                default:
+                    ThrowInvalidTraverseResult(result);
+                    break;
             }
 
             foreach (var outputProperty in node.Item1.Outputs)
@@ -399,7 +464,7 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
     }
 
     public void TraverseForwards(
-        Func<INodeHandler, INodeHandler, INodePropertyHandler, INodePropertyHandler, bool> func)
+        Func<INodeHandler, INodeHandler, INodePropertyHandler, INodePropertyHandler, Traverse> func)
     {
         var visited = new HashSet<INodeHandler>();
         var queueNodes = new Queue<(INodeHandler, INodeHandler, INodePropertyHandler, INodePropertyHandler)>();
@@ -414,9 +479,18 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
                 continue;
             }
 
-            if (!func(node.Item1, node.Item2, node.Item3, node.Item4))
+            var result = func(node.Item1, node.Item2, node.Item3, node.Item4);
+            switch (result)
             {
-                return;
+                case Traverse.NoFurther:
+                    continue;
+                case Traverse.Exit:
+                    return;
+                case Traverse.Further:
+                    break;
+                default:
+                    ThrowInvalidTraverseResult(result);
+                    break;
             }
 
             foreach (var outputProperty in node.Item1.Outputs)
@@ -429,6 +503,7 @@ internal abstract class NodeViewModel : ObservableObject, INodeHandler
         }
     }
 
+    public HashSet<NodeFrameViewModelBase> Frames { get; } = [];
 
     public virtual void Dispose()
     {

+ 222 - 35
src/PixiEditor/ViewModels/Nodes/NodeZoneViewModel.cs

@@ -1,20 +1,26 @@
-using PixiEditor.Models.Handlers;
+using System.Buffers;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media;
+using PixiEditor.Models.Handlers;
 using Drawie.Numerics;
 
 namespace PixiEditor.ViewModels.Nodes;
 
 public sealed class NodeZoneViewModel : NodeFrameViewModelBase
 {
-    private INodeHandler start;
-    private INodeHandler end;
+    public INodeHandler Start { get; }
     
-    public NodeZoneViewModel(Guid id, string internalName, INodeHandler start, INodeHandler end) : base(id, [start, end])
+    public INodeHandler End { get; }
+
+    public NodeZoneViewModel(Guid id, string internalName, INodeHandler start, INodeHandler end) : base(id,
+        [start, end])
     {
         InternalName = internalName;
-        
-        this.start = start.Metadata.IsPairNodeStart ? start : end;
-        this.end = start.Metadata.IsPairNodeStart ? end : start;
-        
+
+        this.Start = start.Metadata.IsPairNodeStart ? start : end;
+        this.End = start.Metadata.IsPairNodeStart ? end : start;
+
         CalculateBounds();
     }
 
@@ -22,53 +28,234 @@ public sealed class NodeZoneViewModel : NodeFrameViewModelBase
     {
         if (Nodes.Count == 0)
         {
-            if (TopLeft == BottomRight)
+            return;
+        }
+
+        var points = GetBoundPoints();
+
+        Geometry = BuildRoundedHullGeometry(points, 25);
+    }
+
+    private static StreamGeometry BuildRoundedHullGeometry(List<VecD> points, double cornerRadius)
+    {
+        const double startBoostDeg = 100;
+        const double maxBoost = 2.5;
+
+        var span = CollectionsMarshal.AsSpan(points);
+
+        var pool = ArrayPool<VecD>.Shared;
+        var hullBuf = pool.Rent(Math.Max(3, span.Length));
+
+        try
+        {
+            var hullCount = ConvexHull(span, hullBuf.AsSpan());
+            var hull = hullBuf.AsSpan(0, hullCount);
+
+            var geometry = new StreamGeometry();
+            if (hull.IsEmpty) return geometry;
+
+            using var ctx = geometry.Open();
+            
+            if (hull.Length <= 2 || cornerRadius <= 0)
             {
-                BottomRight = TopLeft + new VecD(100, 100);
+                ctx.BeginFigure(new Point(hull[0].X, hull[0].Y), isFilled: true);
+                for (var i = 1; i < hull.Length; i++)
+                    ctx.LineTo(new Point(hull[i].X, hull[i].Y));
+                ctx.EndFigure(isClosed: true);
+                return geometry;
             }
-            
-            return;
+
+            var n = hull.Length;
+
+            var enter = n <= 256 ? stackalloc VecD[n] : pool.Rent(n).AsSpan(0, n);
+            var exit = n <= 256 ? stackalloc VecD[n] : pool.Rent(n).AsSpan(0, n);
+            var rented = n > 256;
+
+            try
+            {
+                for (var i = 0; i < n; i++)
+                {
+                    var prev = hull[(i - 1 + n) % n];
+                    var current = hull[i];
+                    var next = hull[(i + 1) % n];
+
+                    var directionIn = (current - prev).Normalize();
+                    var directionOut = (next - current).Normalize();
+                    var lenIn = (prev - current).Length;
+                    var lenOut = (current - next).Length;
+
+                    var a = (prev - current).Normalize();
+                    var b = (next - current).Normalize();
+                    var dot = Math.Clamp(a.X * b.X + a.Y * b.Y, -1, 1);
+                    var theta = Math.Acos(dot); // radians
+
+                    // Boost wide angles a bit (same curve, fewer ops)
+                    var thetaDeg = theta * (180.0 / Math.PI);
+                    var tNorm = Math.Clamp((thetaDeg - startBoostDeg) / (180.0 - startBoostDeg), 0, 1);
+                    var s = tNorm * tNorm * (3 - 2 * tNorm); // smoothstep
+                    var radiusHere = cornerRadius * (1 + (maxBoost - 1) * s);
+
+                    var t = (theta > 1e-6) ? radiusHere / Math.Tan(theta / 2.0) : 0;
+                    var tMax = Math.Min(lenIn, lenOut) * 0.5;
+                    t = Math.Min(t, tMax);
+
+                    if (t <= 1e-6)
+                    {
+                        enter[i] = current;
+                        exit[i] = current;
+                    }
+                    else
+                    {
+                        enter[i] = current + directionIn * -t;
+                        exit[i] = current + directionOut * t;
+                    }
+                }
+
+                ctx.BeginFigure(new Point(enter[0].X, enter[0].Y), isFilled: true);
+
+                for (var i = 0; i < n; i++)
+                {
+                    ctx.QuadraticBezierTo(
+                        new Point(hull[i].X, hull[i].Y),
+                        new Point(exit[i].X, exit[i].Y));
+
+                    var nextEnter = enter[(i + 1) % n];
+                    ctx.LineTo(new Point(nextEnter.X, nextEnter.Y));
+                }
+
+                ctx.EndFigure(isClosed: true);
+            }
+            finally
+            {
+                if (rented)
+                {
+                    pool.Return(
+                        MemoryMarshal.CreateReadOnlySpan(ref MemoryMarshal.GetReference(enter), n).ToArray());
+
+                    pool.Return(MemoryMarshal.CreateReadOnlySpan(ref MemoryMarshal.GetReference(exit), n)
+                        .ToArray());
+                }
+            }
+
+            return geometry;
+        }
+        finally
+        {
+            pool.Return(hullBuf);
         }
+    }
+
+    private static int ConvexHull(ReadOnlySpan<VecD> input, Span<VecD> hull)
+    {
+        var n = input.Length;
+        if (n <= 1)
+        {
+            if (n == 1) hull[0] = input[0];
+            return n;
+        }
+
+        var pool = ArrayPool<VecD>.Shared;
+        var pts = pool.Rent(n);
+
+        try
+        {
+            input.CopyTo(pts);
+            Array.Sort(pts, 0, n, VecDComparer.Instance);
+
+            var m = 0;
+            for (var i = 0; i < n; i++)
+            {
+                if (m == 0 || !pts[i].Equals(pts[m - 1]))
+                    pts[m++] = pts[i];
+            }
+
+            if (m <= 1)
+            {
+                if (m == 1) hull[0] = pts[0];
+                return m;
+            }
+
+            var k = 0;
+
+            for (var i = 0; i < m; i++)
+            {
+                while (k >= 2 && (hull[k - 1] - hull[k - 2]).Cross(pts[i] - hull[k - 2]) <= 0)
+                    k--;
+                hull[k++] = pts[i];
+            }
+
+            var t = k + 1;
+            for (var i = m - 2; i >= 0; i--)
+            {
+                while (k >= t && (hull[k - 1] - hull[k - 2]).Cross(pts[i] - hull[k - 2]) <= 0)
+                    k--;
+                hull[k++] = pts[i];
+            }
 
-        var bounds = GetBounds();
-        
-        var minX = bounds.Min(n => n.X);
-        var minY = bounds.Min(n => n.Y);
-        
-        var maxX = bounds.Max(n => n.Right);
-        var maxY = bounds.Max(n => n.Bottom);
+            return k - 1;
+        }
+        finally
+        {
+            pool.Return(pts);
+        }
+    }
 
-        TopLeft = new VecD(minX, minY);
-        BottomRight = new VecD(maxX, maxY);
+    private sealed class VecDComparer : IComparer<VecD>
+    {
+        public static readonly VecDComparer Instance = new();
 
-        Size = BottomRight - TopLeft;
+        public int Compare(VecD a, VecD b)
+        {
+            var cx = a.X.CompareTo(b.X);
+            return cx != 0 ? cx : a.Y.CompareTo(b.Y);
+        }
     }
 
-    private List<RectD> GetBounds()
+    private List<VecD> GetBoundPoints()
     {
-        var list = new List<RectD>();
+        var list = new List<VecD>(Nodes.Count * 4);
+
+        const int defaultXOffset = 30;
+        const int defaultYOffset = 45;
 
-        const int defaultXOffset = -30;
-        const int defaultYOffset = -45;
-        
-        // TODO: Use the actual node height
         foreach (var node in Nodes)
         {
-            if (node == start)
+            var pos = node.PositionBindable;
+            var size = new VecD(node.UiSize.Size.Width, node.UiSize.Size.Height);
+
+            if (node == Start)
             {
-                list.Add(new RectD(node.PositionBindable + new VecD(100, defaultYOffset), new VecD(100, 400)));
+                var twoThirdsX = size.X * (2.0 / 3.0);
+
+                list.Add(pos + new VecD(twoThirdsX, -defaultYOffset));
+                list.Add(pos + new VecD(twoThirdsX, defaultYOffset + size.Y));
+
+                list.Add(pos + new VecD(size.X + defaultXOffset, -defaultYOffset));
+                list.Add(pos + new VecD(size.X + defaultXOffset, defaultYOffset + size.Y));
                 continue;
             }
 
-            if (node == end)
+            if (node == End)
             {
-                list.Add(new RectD(node.PositionBindable + new VecD(defaultXOffset, defaultYOffset), new VecD(100, 400)));
+                var oneThirdX = size.X / 3.0;
+
+                list.Add(pos + new VecD(oneThirdX, -defaultYOffset));
+                list.Add(pos + new VecD(oneThirdX, defaultYOffset + size.Y));
+
+                list.Add(pos + new VecD(-defaultXOffset, -defaultYOffset));
+                list.Add(pos + new VecD(-defaultXOffset, defaultYOffset + size.Y));
                 continue;
             }
-            
-            list.Add(new RectD(node.PositionBindable + new VecD(defaultXOffset, defaultYOffset), new VecD(200, 400)));
+
+            var right = defaultXOffset + size.X;
+            var bottom = defaultYOffset + size.Y;
+
+            list.Add(pos + new VecD(-defaultXOffset, -defaultYOffset));
+            list.Add(pos + new VecD(right, -defaultYOffset));
+            list.Add(pos + new VecD(-defaultXOffset, bottom));
+            list.Add(pos + new VecD(right, bottom));
         }
-        
+
         return list;
     }
 }

+ 19 - 0
src/PixiEditor/ViewModels/Nodes/Traverse.cs

@@ -0,0 +1,19 @@
+namespace PixiEditor.ViewModels.Nodes;
+
+public enum Traverse
+{
+    /// <summary>
+    /// Go further in this direction, meaning any further child connections will not be enqueued.
+    /// </summary>
+    Further,
+    
+    /// <summary>
+    /// Don't go further in this direction, meaning all further child connections will be enqueued.
+    /// </summary>
+    NoFurther,
+    
+    /// <summary>
+    /// Completely stop traversing in any direction, meaning this will drop all enqueued child connections.
+    /// </summary>
+    Exit
+}

+ 4 - 0
src/PixiEditor/ViewModels/PixiObservableObject.cs

@@ -1,5 +1,6 @@
 using System.ComponentModel;
 using CommunityToolkit.Mvvm.ComponentModel;
+using PixiEditor.Extensions.CommonApi.UserPreferences.Settings;
 using PixiEditor.Models.Commands;
 
 namespace PixiEditor.ViewModels;
@@ -11,4 +12,7 @@ public class PixiObservableObject : ObservableObject
         base.OnPropertyChanged(e);
         CommandController.Current.NotifyPropertyChanged(e.PropertyName);
     }
+
+    protected void SubscribeSettingsValueChanged<T>(Setting<T> settingStore, string propertyName) =>
+        settingStore.ValueChanged += (_, _) => OnPropertyChanged(propertyName);
 }

+ 16 - 3
src/PixiEditor/ViewModels/SettingsWindowViewModel.cs

@@ -170,11 +170,23 @@ internal partial class SettingsWindowViewModel : ViewModelBase
         {
             Patterns = fileTypes.SelectMany(a => a.Patterns).ToList(),
         });
-        
+
+        IStorageFolder? suggestedLocation = null;
+        try
+        {
+            suggestedLocation =
+                await MainWindow.Current!.StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents);
+        }
+        catch (Exception)
+        {
+            // If we can't get the documents folder, we will just use the default location
+            // This is not a critical error, so we can ignore it
+        }
+
         IReadOnlyList<IStorageFile> files = await MainWindow.Current!.StorageProvider.OpenFilePickerAsync(new()
         {
             AllowMultiple = false,
-            SuggestedStartLocation = await MainWindow.Current!.StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents),
+            SuggestedStartLocation = suggestedLocation,
             FileTypeFilter = fileTypes,
         });
         
@@ -272,7 +284,8 @@ internal partial class SettingsWindowViewModel : ViewModelBase
             new("KEY_BINDINGS"),
             new SettingsPage("UPDATES"),
             new("EXPORT"),
-            new SettingsPage("SCENE")
+            new SettingsPage("SCENE"),
+            new("PERFORMANCE")
         };
 
         ILocalizationProvider.Current.OnLanguageChanged += OnLanguageChanged;

+ 17 - 0
src/PixiEditor/ViewModels/SubViewModels/ToolsViewModel.cs

@@ -63,6 +63,19 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
         }
     }
 
+    public bool SelectionTintingEnabled
+    {
+        get => PixiEditorSettings.Tools.SelectionTintingEnabled.Value;
+        set
+        {
+            if (SelectionTintingEnabled == value)
+                return;
+
+            PixiEditorSettings.Tools.SelectionTintingEnabled.Value = value;
+            OnPropertyChanged();
+        }
+    }
+
     private Cursor? toolCursor;
 
     public Cursor? ToolCursor
@@ -118,6 +131,7 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
     {
         owner.DocumentManagerSubViewModel.ActiveDocumentChanged += ActiveDocumentChanged;
         PixiEditorSettings.Tools.PrimaryToolset.ValueChanged += PrimaryToolsetOnValueChanged;
+        SubscribeSettingsValueChanged(PixiEditorSettings.Tools.SelectionTintingEnabled, nameof(SelectionTintingEnabled));
     }
 
     private void PrimaryToolsetOnValueChanged(Setting<string> setting, string? newPrimaryToolset)
@@ -174,6 +188,9 @@ internal class ToolsViewModel : SubViewModel<ViewModelMain>, IToolsHandler
         OnPropertyChanged(nameof(NonSelectedToolSets));
     }
 
+    [Command.Basic("PixiEditor.Tools.ToggleSelectionTinting", "TOGGLE_TINTING_SELECTION", "TOGGLE_TINTING_SELECTION_DESCRIPTIVE", AnalyticsTrack = true)]
+    public void ToggleTintSelection() => SelectionTintingEnabled = !SelectionTintingEnabled;
+
     public void SetupToolsTooltipShortcuts()
     {
         foreach (IToolHandler tool in allTools)

+ 19 - 0
src/PixiEditor/ViewModels/SubViewModels/ViewOptionsViewModel.cs

@@ -1,6 +1,9 @@
 using Avalonia.Input;
+using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
 using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Models.Preferences;
 using PixiEditor.UI.Common.Fonts;
+using PixiEditor.ViewModels.UserPreferences.Settings;
 
 namespace PixiEditor.ViewModels.SubViewModels;
 #nullable enable
@@ -36,9 +39,25 @@ internal class ViewOptionsViewModel : SubViewModel<ViewModelMain>
         }
     }
 
+    private int maxBilinearSampleSize = 4096;
+    public int MaxBilinearSampleSize
+    {
+        get => maxBilinearSampleSize;
+        set
+        {
+            SetProperty(ref maxBilinearSampleSize, value);
+        }
+    }
+
     public ViewOptionsViewModel(ViewModelMain owner)
         : base(owner)
     {
+        MaxBilinearSampleSize = PixiEditorSettings.Performance.MaxBilinearSampleSize.Value;
+
+        PixiEditorSettings.Performance.MaxBilinearSampleSize.ValueChanged += (s, e) =>
+        {
+            MaxBilinearSampleSize = PixiEditorSettings.Performance.MaxBilinearSampleSize.Value;
+        };
     }
 
     [Command.Basic("PixiEditor.View.ToggleGrid", "TOGGLE_GRIDLINES", "TOGGLE_GRIDLINES", Key = Key.OemTilde,

+ 4 - 3
src/PixiEditor/ViewModels/SubViewModels/ViewportWindowViewModel.cs

@@ -175,7 +175,8 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         PixiEditorSettings.Scene.PrimaryBackgroundColor.ValueChanged += UpdateBackgroundBitmap;
         PixiEditorSettings.Scene.SecondaryBackgroundColor.ValueChanged += UpdateBackgroundBitmap;
 
-        previewPainterControl = new PreviewPainterControl(Document.PreviewPainter,
+        previewPainterControl = new PreviewPainterControl(
+            Document.MiniPreviewPainter,
             Document.AnimationDataViewModel.ActiveFrameTime.Frame);
         TabCustomizationSettings.Icon = previewPainterControl;
     }
@@ -187,9 +188,9 @@ internal class ViewportWindowViewModel : SubViewModel<WindowViewModel>, IDockabl
         {
             OnPropertyChanged(nameof(Title));
         }
-        else if (e.PropertyName == nameof(DocumentViewModel.PreviewPainter))
+        else if (e.PropertyName == nameof(DocumentViewModel.MiniPreviewPainter))
         {
-            previewPainterControl.PreviewPainter = Document.PreviewPainter;
+            previewPainterControl.PreviewPainter = Document.MiniPreviewPainter;
             previewPainterControl.FrameToRender = Document.AnimationDataViewModel.ActiveFrameTime.Frame;
         }
         else if (e.PropertyName == nameof(DocumentViewModel.AllChangesSaved))

+ 21 - 0
src/PixiEditor/ViewModels/UserPreferences/Settings/PerformanceSettings.cs

@@ -0,0 +1,21 @@
+using PixiEditor.Extensions.CommonApi.UserPreferences;
+
+namespace PixiEditor.ViewModels.UserPreferences.Settings;
+
+internal class PerformanceSettings : SettingsGroup
+{
+    private bool disablePreviews = GetPreference(PreferencesConstants.DisablePreviews, PreferencesConstants.DisablePreviewsDefault);
+    private int maxBilinearSize = GetPreference(PreferencesConstants.MaxBilinearSampleSize, PreferencesConstants.MaxBilinearSampleSizeDefault);
+
+    public bool DisablePreviews
+    {
+        get => disablePreviews;
+        set => RaiseAndUpdatePreference(ref disablePreviews, value, PreferencesConstants.DisablePreviews);
+    }
+
+    public int MaxBilinearSize
+    {
+        get => maxBilinearSize;
+        set => RaiseAndUpdatePreference(ref maxBilinearSize, value, PreferencesConstants.MaxBilinearSampleSize);
+    }
+}

+ 9 - 0
src/PixiEditor/ViewModels/UserPreferences/Settings/SceneSettings.cs

@@ -3,6 +3,7 @@ using Avalonia.Media;
 using CommunityToolkit.Mvvm.Input;
 using Drawie.Numerics;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
+using PixiEditor.Extensions.CommonApi.UserPreferences.Settings.PixiEditor;
 using PixiEditor.Helpers.Extensions;
 
 namespace PixiEditor.ViewModels.UserPreferences.Settings;
@@ -44,6 +45,12 @@ internal class SceneSettings : SettingsGroup
         set => RaiseAndUpdatePreference(ref _secondaryBackgroundColorHex, value, PreferencesConstants.SecondaryBackgroundColor);
     }
 
+    public bool SelectionTintingEnabled
+    {
+        get => PixiEditorSettings.Tools.SelectionTintingEnabled.Value;
+        set => RaiseAndUpdatePreference(PixiEditorSettings.Tools.SelectionTintingEnabled, value);
+    }
+
     public Color PrimaryBackgroundColor
     {
         get => Color.Parse(PrimaryBackgroundColorHex);
@@ -65,5 +72,7 @@ internal class SceneSettings : SettingsGroup
             PrimaryBackgroundColorHex = PreferencesConstants.PrimaryBackgroundColorDefault;
             SecondaryBackgroundColorHex = PreferencesConstants.SecondaryBackgroundColorDefault;
         });
+        
+        SubscribeValueChanged(PixiEditorSettings.Tools.SelectionTintingEnabled, nameof(SelectionTintingEnabled));
     }
 }

+ 16 - 1
src/PixiEditor/ViewModels/UserPreferences/SettingsGroup.cs

@@ -1,10 +1,11 @@
 using System.Runtime.CompilerServices;
 using CommunityToolkit.Mvvm.ComponentModel;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
+using PixiEditor.Extensions.CommonApi.UserPreferences.Settings;
 
 namespace PixiEditor.ViewModels.UserPreferences;
 
-internal class SettingsGroup : ObservableObject
+internal class SettingsGroup : PixiObservableObject
 {
     protected static T GetPreference<T>(string name)
     {
@@ -27,4 +28,18 @@ internal class SettingsGroup : ObservableObject
         SetProperty(ref backingStore, value, propertyName: name);
         IPreferences.Current.UpdatePreference(name, value);
     }
+    
+    protected void RaiseAndUpdatePreference<T>(Setting<T> settingStore, T value, [CallerMemberName] string name = "")
+    {
+        if (EqualityComparer<T>.Default.Equals(settingStore.Value, value))
+            return;
+
+        settingStore.Value = value;
+        OnPropertyChanged(name);
+    }
+
+    protected void SubscribeValueChanged<T>(Setting<T> settingStore, string propertyName)
+    {
+        settingStore.ValueChanged += (_, _) => OnPropertyChanged(propertyName);
+    }
 }

+ 2 - 0
src/PixiEditor/ViewModels/UserPreferences/SettingsViewModel.cs

@@ -17,6 +17,8 @@ internal class SettingsViewModel : SubViewModel<SettingsWindowViewModel>
 
     public SceneSettings Scene { get; set; } = new();
 
+    public PerformanceSettings Performance { get; set; } = new();
+
     public SettingsViewModel(SettingsWindowViewModel owner)
         : base(owner)
     {

+ 1 - 0
src/PixiEditor/Views/Dock/DocumentTemplate.axaml

@@ -45,6 +45,7 @@
         CustomBackgroundScaleY="{Binding CustomBackgroundScaleY, Mode=OneWay}"
         BackgroundBitmap="{Binding BackgroundBitmap, Mode=OneWay}"
         HudVisible="{Binding HudVisible}"
+        MaxBilinearSamplingSize="{Binding ViewportSubViewModel.MaxBilinearSampleSize, Source={viewModels1:MainVM}}"
         Document="{Binding Document}">
     </viewportControls:Viewport>
 </UserControl>

+ 1 - 0
src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml

@@ -257,6 +257,7 @@
             CustomBackgroundScaleX="{Binding CustomBackgroundScaleX, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=OneWay}"
             CustomBackgroundScaleY="{Binding CustomBackgroundScaleY, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=OneWay}"
             BackgroundBitmap="{Binding BackgroundBitmap, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=OneWay}"
+            MaxBilinearSamplingSize="{Binding MaxBilinearSamplingSize, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=viewportControls:Viewport}, Mode=OneWay}"
             PointerPressed="Scene_OnContextMenuOpening"
             ui1:RenderOptionsBindable.BitmapInterpolationMode="{Binding Scale, Converter={converters:ScaleToBitmapScalingModeConverter}, RelativeSource={RelativeSource Self}}">
             <rendering:Scene.ContextFlyout>

+ 8 - 0
src/PixiEditor/Views/Main/ViewportControls/Viewport.axaml.cs

@@ -382,6 +382,8 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
 
     private MouseUpdateController? mouseUpdateController;
     private ViewportOverlays builtInOverlays = new();
+    public static readonly StyledProperty<int> MaxBilinearSamplingSizeProperty
+        = AvaloniaProperty.Register<Viewport, int>("MaxBilinearSamplingSize", 4096);
 
     public static readonly StyledProperty<bool> SnappingEnabledProperty =
         AvaloniaProperty.Register<Viewport, bool>("SnappingEnabled");
@@ -445,6 +447,12 @@ internal partial class Viewport : UserControl, INotifyPropertyChanged
         set { SetValue(AvailableRenderOutputsProperty, value); }
     }
 
+    public int MaxBilinearSamplingSize
+    {
+        get { return (int)GetValue(MaxBilinearSamplingSizeProperty); }
+        set { SetValue(MaxBilinearSamplingSizeProperty, value); }
+    }
+
     private void ForceRefreshFinalImage()
     {
         Scene.InvalidateVisual();

+ 9 - 1
src/PixiEditor/Views/Main/ViewportControls/ViewportOverlays.cs

@@ -155,6 +155,13 @@ internal class ViewportOverlays
             Mode = BindingMode.OneWay
         };
 
+        Binding selectionTintingEnabled = new()
+        {
+            Source = ViewModelMain.Current,
+            Path = "ToolsSubViewModel.SelectionTintingEnabled",
+            Mode = BindingMode.OneWay
+        };
+
         MultiBinding showFillBinding = new()
         {
             Converter = new AllTrueConverter(),
@@ -162,7 +169,8 @@ internal class ViewportOverlays
             Bindings = new List<IBinding>()
             {
                 toolIsSelectionBinding,
-                isTransformingBinding
+                isTransformingBinding,
+                selectionTintingEnabled
             }
         };
 

+ 6 - 21
src/PixiEditor/Views/Nodes/NodeFrameView.cs

@@ -1,32 +1,17 @@
 using Avalonia;
 using Avalonia.Controls.Primitives;
+using Avalonia.Media;
 using Drawie.Numerics;
 
 namespace PixiEditor.Views.Nodes;
 
 public class NodeFrameView : TemplatedControl
 {
-    public static readonly StyledProperty<VecD> TopLeftProperty = AvaloniaProperty.Register<ConnectionLine, VecD>(nameof(TopLeft));
-    
-    public VecD TopLeft
-    {
-        get => GetValue(TopLeftProperty);
-        set => SetValue(TopLeftProperty, value);
-    }
-    
-    public static readonly StyledProperty<VecD> BottomRightProperty = AvaloniaProperty.Register<ConnectionLine, VecD>(nameof(BottomRight));
-    
-    public VecD BottomRight
-    {
-        get => GetValue(BottomRightProperty);
-        set => SetValue(BottomRightProperty, value);
-    }
-    
-    public static readonly StyledProperty<VecD> SizeProperty = AvaloniaProperty.Register<ConnectionLine, VecD>(nameof(Size));
-    
-    public VecD Size
+    public static readonly StyledProperty<StreamGeometry> GeometryProperty = AvaloniaProperty.Register<NodeFrameView, StreamGeometry>(nameof(Geometry));
+
+    public StreamGeometry Geometry
     {
-        get => GetValue(SizeProperty);
-        set => SetValue(SizeProperty, value);
+        get => GetValue(GeometryProperty);
+        set => SetValue(GeometryProperty, value);
     }
 }

+ 15 - 2
src/PixiEditor/Views/Nodes/Properties/StringPropertyView.axaml.cs

@@ -17,14 +17,16 @@ namespace PixiEditor.Views.Nodes.Properties;
 
 public partial class StringPropertyView : NodePropertyView
 {
-    public static readonly StyledProperty<ICommand> OpenInDefaultAppCommandProperty = AvaloniaProperty.Register<StringPropertyView, ICommand>(
-        nameof(OpenInDefaultAppCommand));
+    public static readonly StyledProperty<ICommand> OpenInDefaultAppCommandProperty =
+        AvaloniaProperty.Register<StringPropertyView, ICommand>(
+            nameof(OpenInDefaultAppCommand));
 
     public ICommand OpenInDefaultAppCommand
     {
         get => GetValue(OpenInDefaultAppCommandProperty);
         set => SetValue(OpenInDefaultAppCommandProperty, value);
     }
+
     public StringPropertyView()
     {
         InitializeComponent();
@@ -33,7 +35,18 @@ public partial class StringPropertyView : NodePropertyView
     protected override void OnLoaded(RoutedEventArgs e)
     {
         base.OnLoaded(e);
+        if (smallTextBox is null)
+        {
+            return;
+        }
+
         ScrollViewer scroll = smallTextBox.FindDescendantOfType<ScrollViewer>();
+
+        if (scroll is null)
+        {
+            return;
+        }
+
         scroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
         scroll.VerticalScrollBarVisibility = ScrollBarVisibility.Disabled;
     }

+ 8 - 1
src/PixiEditor/Views/Overlays/BrushShapeOverlay/BrushShape.cs

@@ -1,9 +1,16 @@
-namespace PixiEditor.Views.Overlays.BrushShapeOverlay;
+using System.ComponentModel;
+
+namespace PixiEditor.Views.Overlays.BrushShapeOverlay;
 internal enum BrushShape
 {
+    [Description("BRUSH_SHAPE_HIDDEN")]
     Hidden,
+    [Description("BRUSH_SHAPE_PIXEL")]
     Pixel,
+    [Description("BRUSH_SHAPE_SQUARE")]
     Square,
+    [Description("BRUSH_SHAPE_CIRCLE_PIXELATED")]
     CirclePixelated,
+    [Description("BRUSH_SHAPE_CIRCLE_SMOOTH")]
     CircleSmooth
 }

+ 2 - 1
src/PixiEditor/Views/Overlays/TextOverlay/TextOverlay.cs

@@ -260,7 +260,8 @@ internal class TextOverlay : Overlay
         caret.GlyphWidths = glyphWidths;
         caret.Offset = Position;
 
-        caret.CaretWidth = 2f / (float)ZoomScale;
+        caret.CaretWidth = (2f / (float)ZoomScale) / Matrix.ScaleX;
+
         caret.Render(context);
     }
 

+ 25 - 1
src/PixiEditor/Views/Rendering/Scene.cs

@@ -148,6 +148,15 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         set { SetValue(RenderOutputProperty, value); }
     }
 
+    public static readonly StyledProperty<int> MaxBilinearSamplingSizeProperty = AvaloniaProperty.Register<Scene, int>(
+        nameof(MaxBilinearSamplingSize), 4096);
+
+    public int MaxBilinearSamplingSize
+    {
+        get => GetValue(MaxBilinearSamplingSizeProperty);
+        set => SetValue(MaxBilinearSamplingSizeProperty, value);
+    }
+
     private Overlay? capturedOverlay;
 
     private List<Overlay> mouseOverOverlays = new();
@@ -230,6 +239,20 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         };
     }
 
+    private SamplingOptions CalculateSampling()
+    {
+        if (Document.SizeBindable.LongestAxis > MaxBilinearSamplingSize)
+        {
+            return SamplingOptions.Default;
+        }
+
+        VecD densityVec = Dimensions.Divide(RealDimensions);
+        double density = Math.Min(densityVec.X, densityVec.Y);
+        return density > 1
+            ? SamplingOptions.Bilinear
+            : SamplingOptions.Default;
+    }
+
     protected override void OnLoaded(RoutedEventArgs e)
     {
         base.OnLoaded(e);
@@ -312,7 +335,8 @@ internal class Scene : Zoombox.Zoombox, ICustomHitTest
         DrawOverlays(renderTexture.DrawingSurface, bounds, OverlayRenderSorting.Background);
         try
         {
-            SceneRenderer.RenderScene(renderTexture.DrawingSurface, CalculateResolution(), renderOutput);
+            SceneRenderer.RenderScene(renderTexture.DrawingSurface, CalculateResolution(), CalculateSampling(),
+                renderOutput);
         }
         catch (Exception e)
         {

+ 3 - 4
src/PixiEditor/Views/Tools/ToolSettings/Settings/EnumSettingView.axaml

@@ -4,9 +4,8 @@
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              xmlns:settings="clr-namespace:PixiEditor.ViewModels.Tools.ToolSettings.Settings"
              xmlns:enums="clr-namespace:PixiEditor.ChangeableDocument.Enums;assembly=PixiEditor.ChangeableDocument"
-             xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
-             xmlns:helpers="clr-namespace:PixiEditor.Helpers"
              xmlns:localization="clr-namespace:PixiEditor.UI.Common.Localization;assembly=PixiEditor.UI.Common"
+             xmlns:converters="clr-namespace:PixiEditor.Helpers.Converters"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.Views.Tools.ToolSettings.Settings.EnumSettingView">
     <Design.DataContext>
@@ -20,12 +19,12 @@
         <ComboBox.ItemContainerTheme>
             <ControlTheme TargetType="ComboBoxItem" BasedOn="{StaticResource {x:Type ComboBoxItem}}">
                 <Setter Property="Tag" Value="{Binding .}"/>
-                <Setter Property="(localization:Translator.Key)" Value="{Binding ., Converter={helpers:EnumDescriptionConverter}}"/>
+                <Setter Property="(localization:Translator.Key)" Value="{Binding ., Converter={converters:EnumToLocalizedStringConverter}}"/>
             </ControlTheme>
         </ComboBox.ItemContainerTheme>
        <ComboBox.ItemTemplate>
            <DataTemplate>
-               <TextBlock localization:Translator.Key="{Binding ., Converter={helpers:EnumDescriptionConverter}}"/>
+               <TextBlock localization:Translator.Key="{Binding ., Converter={converters:EnumToLocalizedStringConverter}}"/>
            </DataTemplate>
        </ComboBox.ItemTemplate>
     </ComboBox>

+ 8 - 0
src/PixiEditor/Views/Visuals/PreviewPainterControl.cs

@@ -68,6 +68,7 @@ public class PreviewPainterControl : DrawieControl
             PreviewPainter.RemovePainterInstance(painterInstance.RequestId);
             painterInstance.RequestMatrix = null;
             painterInstance.RequestRepaint = null;
+            painterInstance.RequestRenderBounds = null;
             painterInstance = null;
         }
     }
@@ -86,6 +87,7 @@ public class PreviewPainterControl : DrawieControl
 
             painterInstance.RequestMatrix = OnPainterRequestMatrix;
             painterInstance.RequestRepaint = OnPainterRenderRequest;
+            painterInstance.RequestRenderBounds = OnPainterRequestBounds;
 
             PreviewPainter.RepaintFor(painterInstance.RequestId);
         }
@@ -116,6 +118,7 @@ public class PreviewPainterControl : DrawieControl
 
             sender.painterInstance.RequestMatrix = sender.OnPainterRequestMatrix;
             sender.painterInstance.RequestRepaint = sender.OnPainterRenderRequest;
+            sender.painterInstance.RequestRenderBounds = sender.OnPainterRequestBounds;
 
             args.NewValue.Value.RepaintFor(sender.painterInstance.RequestId);
         }
@@ -216,4 +219,9 @@ public class PreviewPainterControl : DrawieControl
 
         return UniformScale(x, y, previewBounds.Value);
     }
+
+    private VecI OnPainterRequestBounds()
+    {
+        return GetFinalSize();
+    }
 }

+ 46 - 2
src/PixiEditor/Views/Windows/Settings/SettingsWindow.axaml

@@ -369,7 +369,7 @@
                         <!--Background="{StaticResource AccentColor}"-->
                         <controls:FixedSizeStackPanel Orientation="Vertical" ChildSize="32"
                                                       VerticalChildrenAlignment="Center" Margin="12">
-
+                            <TextBlock ui:Translator.Key="EXPORT" Classes="h5" />
                             <CheckBox Classes="leftOffset" Width="200" HorizontalAlignment="Left"
                                       ui:Translator.Key="OPEN_DIRECTORY_ON_EXPORT"
                                       IsChecked="{Binding SettingsSubViewModel.File.OpenDirectoryOnExport}" />
@@ -387,7 +387,7 @@
                         <controls:FixedSizeStackPanel Orientation="Vertical" ChildSize="32"
                                                       VerticalChildrenAlignment="Center" Margin="12">
 
-                            <TextBlock ui:Translator.Key="BACKGROUND" Classes="h4" />
+                            <TextBlock ui:Translator.Key="BACKGROUND" Classes="h5" />
 
                             <CheckBox Classes="leftOffset" Width="200" HorizontalAlignment="Left"
                                       ui:Translator.Key="AUTO_SCALE_BACKGROUND"
@@ -421,6 +421,50 @@
                                 d:Content="Reset"
                                 Background="{DynamicResource ThemeAccentBrush}"
                                 ui:Translator.Key="RESET" />
+                            
+                            <TextBlock ui:Translator.Key="SELECTION" Classes="h5" />
+
+                            <CheckBox Classes="leftOffset" Width="200" HorizontalAlignment="Left"
+                                      ui:Translator.Key="TINT_SELECTION"
+                                      IsChecked="{Binding SettingsSubViewModel.Scene.SelectionTintingEnabled}" />
+
+                        </controls:FixedSizeStackPanel>
+                    </ScrollViewer>
+                    <ScrollViewer>
+                        <ScrollViewer.IsVisible>
+                            <Binding Path="CurrentPage" Converter="{converters:IsEqualConverter}">
+                                <Binding.ConverterParameter>
+                                    <sys:Int32>6</sys:Int32>
+                                </Binding.ConverterParameter>
+                            </Binding>
+                        </ScrollViewer.IsVisible>
+                        <!--Background="{StaticResource AccentColor}"-->
+                        <controls:FixedSizeStackPanel Orientation="Vertical" ChildSize="32"
+                                                      VerticalChildrenAlignment="Center" Margin="12">
+                            <TextBlock ui:Translator.Key="PERFORMANCE" Classes="h5" />
+
+                            <CheckBox Classes="leftOffset" Width="200" HorizontalAlignment="Left"
+                                      ui:Translator.Key="DISABLE_PREVIEWS"
+                                      IsChecked="{Binding SettingsSubViewModel.Performance.DisablePreviews}" />
+
+                            <StackPanel Orientation="Horizontal" Classes="leftOffset">
+                                <Label d:Content="Height" ui:Translator.Key="MAX_BILINEAR_CANVAS_SIZE" />
+                                <controls:SizeInput
+                                    Size="{Binding SettingsSubViewModel.Performance.MaxBilinearSize, Mode=TwoWay}"
+                                    MinSize="0"
+                                    MaxSize="99999" HorizontalAlignment="Left" />
+                                <TextBlock Cursor="Help"
+                                           Classes="pixi-icon"
+                                           Text="{DynamicResource icon-help}"
+                                           FontSize="24"
+                                           Margin="5, 0, 0, 0"
+                                           VerticalAlignment="Center"
+                                           ToolTip.ShowDelay="0">
+                                    <ToolTip.Tip>
+                                        <TextBlock TextWrapping="Wrap" MaxWidth="200" ui:Translator.Key="MAX_BILINEAR_CANVAS_SIZE_DESC" />
+                                    </ToolTip.Tip>
+                                </TextBlock>
+                            </StackPanel>
                         </controls:FixedSizeStackPanel>
                     </ScrollViewer>
                 </Grid>

+ 0 - 20
src/stylecop.json

@@ -1,20 +0,0 @@
-{
-  "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
-  "settings": {
-    "indentation": {
-      "indentationSize": 4
-    },
-    "maintainabilityRules": {
-      "topLevelTypes": [ "class", "interface", "enum", "struct" ]
-    },
-    "readabilityRules": {
-      "allowBuiltInTypeAliases": false
-    },
-    "documentationRules": {
-      "xmlHeader": false,
-      "documentInterfaces": false,
-      "documentExposedElements": false,
-      "documentInternalElements": false
-    }
-  }
-}

+ 3 - 2
tests/PixiEditor.Tests/BlendingTests.cs

@@ -1,12 +1,13 @@
 using ChunkyImageLib.DataHolders;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
-using PixiEditor.ChangeableDocument.Enums;
 using PixiEditor.ChangeableDocument.Rendering;
+using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
 
 namespace PixiEditor.Tests;
 
@@ -58,7 +59,7 @@ public class BlendingTests : PixiEditorTest
         secondImageLayer.BlendMode.NonOverridenValue = blendMode;
 
         Surface output = Surface.ForProcessing(VecI.One, ColorSpace.CreateSrgbLinear());
-        graph.Execute(new RenderContext(output.DrawingSurface, 0, ChunkResolution.Full, VecI.One, VecI.One, ColorSpace.CreateSrgbLinear(), 1));
+        graph.Execute(new RenderContext(output.DrawingSurface, 0, ChunkResolution.Full, VecI.One, VecI.One, ColorSpace.CreateSrgbLinear(), SamplingOptions.Default, 1));
 
         Color result = output.GetSrgbPixel(VecI.Zero);
         Assert.Equal(expectedColor, result.ToRgbHex());

+ 2 - 1
tests/PixiEditor.Tests/RenderTests.cs

@@ -4,6 +4,7 @@ using ChunkyImageLib.DataHolders;
 using Drawie.Backend.Core;
 using Drawie.Backend.Core.Bridge;
 using Drawie.Backend.Core.ColorsImpl;
+using Drawie.Backend.Core.Surfaces;
 using Drawie.Numerics;
 using PixiEditor.Models.IO;
 using Xunit.Abstractions;
@@ -89,7 +90,7 @@ public class RenderTests : FullPixiEditorTest
 
         var document = Importer.ImportDocument(pixiFile);
         using Surface output = Surface.ForDisplay(document.SizeBindable);
-        document.SceneRenderer.RenderScene(output.DrawingSurface, ChunkResolution.Half);
+        document.SceneRenderer.RenderScene(output.DrawingSurface, ChunkResolution.Half, SamplingOptions.Default);
 
         Color expectedColor = Colors.Yellow;