Browse Source

Refactor ChunkRenderer, Render previews

Equbuxu 3 years ago
parent
commit
047c9be13f
28 changed files with 780 additions and 315 deletions
  1. 8 12
      src/ChunkyImageLib/ChunkyImage.cs
  2. 4 0
      src/ChunkyImageLib/DataHolders/EmptyChunk.cs
  3. 4 0
      src/ChunkyImageLib/DataHolders/FilledChunk.cs
  4. 0 0
      src/ChunkyImageLib/DataHolders/VecD.cs
  5. 0 0
      src/ChunkyImageLib/DataHolders/VecI.cs
  6. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/LayerImageChunks_ChangeInfo.cs
  7. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/MaskChunks_ChangeInfo.cs
  8. 1 1
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberClipToMemberBelow_ChangeInfo.cs
  9. 1 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/DeleteStructureMember_ChangeInfo.cs
  10. 2 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/MoveStructureMember_ChangeInfo.cs
  11. 9 4
      src/PixiEditor.ChangeableDocument/Changes/Drawing/CombineStructureMembersOnto_Change.cs
  12. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Drawing/DrawingChangeHelper.cs
  13. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberClipToMemberBelow_Change.cs
  14. 1 1
      src/PixiEditor.ChangeableDocument/Changes/Structure/CreateStructureMember_Change.cs
  15. 1 1
      src/PixiEditor.ChangeableDocument/Changes/Structure/DeleteStructureMember_Change.cs
  16. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Structure/MoveStructureMember_Change.cs
  17. 3 3
      src/PixiEditor.ChangeableDocument/DocumentChangeTracker.cs
  18. 160 160
      src/PixiEditor.ChangeableDocument/Rendering/ChunkRenderer.cs
  19. 54 0
      src/PixiEditor.ChangeableDocument/Rendering/RenderingContext.cs
  20. 77 14
      src/PixiEditorPrototype/Models/ActionAccumulator.cs
  21. 40 1
      src/PixiEditorPrototype/Models/DocumentUpdater.cs
  22. 217 0
      src/PixiEditorPrototype/Models/Rendering/AffectedChunkGatherer.cs
  23. 5 0
      src/PixiEditorPrototype/Models/Rendering/RenderInfos/MaskPreviewDirty_RenderInfo.cs
  24. 5 0
      src/PixiEditorPrototype/Models/Rendering/RenderInfos/PreviewDirty_RenderInfo.cs
  25. 125 92
      src/PixiEditorPrototype/Models/Rendering/WriteableBitmapUpdater.cs
  26. 2 3
      src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs
  27. 31 12
      src/PixiEditorPrototype/ViewModels/StructureMemberViewModel.cs
  28. 22 3
      src/PixiEditorPrototype/Views/MainWindow.xaml

+ 8 - 12
src/ChunkyImageLib/ChunkyImage.cs

@@ -2,7 +2,6 @@
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.Operations;
 using OneOf;
-using OneOf.Types;
 using SkiaSharp;
 
 [assembly: InternalsVisibleTo("ChunkyImageLibTest")]
@@ -582,7 +581,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             return;
 
         Chunk? targetChunk = null;
-        OneOf<All, None, Chunk> combinedClips = new All();
+        OneOf<FilledChunk, EmptyChunk, Chunk> combinedClips = new FilledChunk();
 
         bool initialized = false;
 
@@ -619,18 +618,15 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             value.Dispose();
     }
 
-    /// <summary>
-    /// (All) -> All is visible, (None) -> None is visible, (Chunk) -> Combined clip
-    /// </summary>
-    private OneOf<All, None, Chunk> CombineClipsForChunk(VecI chunkPos, ChunkResolution resolution)
+    private OneOf<FilledChunk, EmptyChunk, Chunk> CombineClipsForChunk(VecI chunkPos, ChunkResolution resolution)
     {
         if (lockTransparency && MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is null)
         {
-            return new None();
+            return new EmptyChunk();
         }
         if (activeClips.Count == 0)
         {
-            return new All();
+            return new FilledChunk();
         }
 
         var intersection = Chunk.Create(resolution);
@@ -645,7 +641,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             else
             {
                 intersection.Dispose();
-                return new None();
+                return new EmptyChunk();
             }
         }
         return intersection;
@@ -656,7 +652,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// </returns>
     private bool ApplyOperationToChunk(
         IOperation operation,
-        OneOf<All, None, Chunk> combinedClips,
+        OneOf<FilledChunk, EmptyChunk, Chunk> combinedClips,
         Chunk targetChunk,
         VecI chunkPos,
         ChunkResolution resolution,
@@ -667,14 +663,14 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
 
         if (operation is IDrawOperation chunkOperation)
         {
-            if (combinedClips.IsT1) //None is visible
+            if (combinedClips.IsT1) //Nothing is visible
                 return chunkData.IsDeleted;
 
             if (chunkData.IsDeleted)
                 targetChunk.Surface.SkiaSurface.Canvas.Clear();
 
             // just regular drawing
-            if (combinedClips.IsT0) //All is visible
+            if (combinedClips.IsT0) //Everything is visible
             {
                 chunkOperation.DrawOnChunk(targetChunk, chunkPos);
                 return false;

+ 4 - 0
src/ChunkyImageLib/DataHolders/EmptyChunk.cs

@@ -0,0 +1,4 @@
+namespace ChunkyImageLib.DataHolders;
+public struct EmptyChunk
+{
+}

+ 4 - 0
src/ChunkyImageLib/DataHolders/FilledChunk.cs

@@ -0,0 +1,4 @@
+namespace ChunkyImageLib.DataHolders;
+public struct FilledChunk
+{
+}

+ 0 - 0
src/ChunkyImageLib/DataHolders/Vector2d.cs → src/ChunkyImageLib/DataHolders/VecD.cs


+ 0 - 0
src/ChunkyImageLib/DataHolders/Vector2i.cs → src/ChunkyImageLib/DataHolders/VecI.cs


+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/LayerImageChunks_ChangeInfo.cs

@@ -4,6 +4,6 @@ namespace PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
 
 public record class LayerImageChunks_ChangeInfo : IChangeInfo
 {
-    public Guid LayerGuid { get; init; }
+    public Guid GuidValue { get; init; }
     public HashSet<VecI>? Chunks { get; init; }
 }

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/MaskChunks_ChangeInfo.cs

@@ -4,6 +4,6 @@ namespace PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
 
 public record class MaskChunks_ChangeInfo : IChangeInfo
 {
-    public Guid MemberGuid { get; init; }
+    public Guid GuidValue { get; init; }
     public HashSet<VecI>? Chunks { get; init; }
 }

+ 1 - 1
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberClipToMemberBelow_ChangeInfo.cs

@@ -1,5 +1,5 @@
 namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
 public record class StructureMemberClipToMemberBelow_ChangeInfo : IChangeInfo
 {
-    public Guid MemberGuid { get; init; }
+    public Guid GuidValue { get; init; }
 }

+ 1 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/DeleteStructureMember_ChangeInfo.cs

@@ -3,4 +3,5 @@
 public record class DeleteStructureMember_ChangeInfo : IChangeInfo
 {
     public Guid GuidValue { get; init; }
+    public Guid ParentGuid { get; init; }
 }

+ 2 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/MoveStructureMember_ChangeInfo.cs

@@ -3,4 +3,6 @@
 public record class MoveStructureMember_ChangeInfo : IChangeInfo
 {
     public Guid GuidValue { get; init; }
+    public Guid ParentFromGuid { get; init; }
+    public Guid ParentToGuid { get; init; }
 }

+ 9 - 4
src/PixiEditor.ChangeableDocument/Changes/Drawing/CombineStructureMembersOnto_Change.cs

@@ -1,5 +1,6 @@
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
+using OneOf;
 using PixiEditor.ChangeableDocument.Changeables;
 using PixiEditor.ChangeableDocument.ChangeInfos;
 using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
@@ -59,8 +60,12 @@ internal class CombineStructureMembersOnto_Change : Change
         toDrawOn.LayerImage.EnqueueClear();
         foreach (var chunk in chunksToCombine)
         {
-            using var combined = ChunkRenderer.RenderSpecificLayers(chunk, ChunkResolution.Full, target.StructureRoot, layersToCombine);
-            toDrawOn.LayerImage.EnqueueDrawImage(chunk * ChunkyImage.ChunkSize, combined.Surface);
+            OneOf<Chunk, EmptyChunk> combined = ChunkRenderer.MergeChosenMembers(chunk, ChunkResolution.Full, target.StructureRoot, layersToCombine);
+            if (combined.IsT0)
+            {
+                toDrawOn.LayerImage.EnqueueDrawImage(chunk * ChunkyImage.ChunkSize, combined.AsT0.Surface);
+                combined.AsT0.Surface.Dispose();
+            }
         }
         var affectedChunks = toDrawOn.LayerImage.FindAffectedChunks();
         originalChunks = new CommittedChunkStorage(toDrawOn.LayerImage, affectedChunks);
@@ -69,7 +74,7 @@ internal class CombineStructureMembersOnto_Change : Change
         ignoreInUndo = false;
         return new LayerImageChunks_ChangeInfo()
         {
-            LayerGuid = targetLayer,
+            GuidValue = targetLayer,
             Chunks = affectedChunks
         };
     }
@@ -87,7 +92,7 @@ internal class CombineStructureMembersOnto_Change : Change
 
         return new LayerImageChunks_ChangeInfo()
         {
-            LayerGuid = targetLayer,
+            GuidValue = targetLayer,
             Chunks = affectedChunks
         };
     }

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changes/Drawing/DrawingChangeHelper.cs

@@ -46,12 +46,12 @@ internal static class DrawingChangeHelper
             false => new LayerImageChunks_ChangeInfo()
             {
                 Chunks = affectedChunks,
-                LayerGuid = memberGuid
+                GuidValue = memberGuid
             },
             true => new MaskChunks_ChangeInfo()
             {
                 Chunks = affectedChunks,
-                MemberGuid = memberGuid
+                GuidValue = memberGuid
             },
         };
     }

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberClipToMemberBelow_Change.cs

@@ -31,7 +31,7 @@ internal class StructureMemberClipToMemberBelow_Change : Change
         var member = target.FindMemberOrThrow(memberGuid);
         member.ClipToMemberBelow = newValue;
         ignoreInUndo = false;
-        return new StructureMemberClipToMemberBelow_ChangeInfo() { MemberGuid = memberGuid };
+        return new StructureMemberClipToMemberBelow_ChangeInfo() { GuidValue = memberGuid };
     }
 
     public override IChangeInfo? Revert(Document target)
@@ -40,7 +40,7 @@ internal class StructureMemberClipToMemberBelow_Change : Change
             return null;
         var member = target.FindMemberOrThrow(memberGuid);
         member.ClipToMemberBelow = originalValue;
-        return new StructureMemberClipToMemberBelow_ChangeInfo() { MemberGuid = memberGuid };
+        return new StructureMemberClipToMemberBelow_ChangeInfo() { GuidValue = memberGuid };
     }
 
     public override bool IsMergeableWith(Change other)

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changes/Structure/CreateStructureMember_Change.cs

@@ -47,6 +47,6 @@ internal class CreateStructureMember_Change : Change
         child.Dispose();
         folder.Children.RemoveAt(folder.Children.FindIndex(child => child.GuidValue == newMemberGuid));
 
-        return new DeleteStructureMember_ChangeInfo() { GuidValue = newMemberGuid };
+        return new DeleteStructureMember_ChangeInfo() { GuidValue = newMemberGuid, ParentGuid = parentFolderGuid };
     }
 }

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changes/Structure/DeleteStructureMember_Change.cs

@@ -30,7 +30,7 @@ internal class DeleteStructureMember_Change : Change
         parent.Children.Remove(member);
         member.Dispose();
         ignoreInUndo = false;
-        return new DeleteStructureMember_ChangeInfo() { GuidValue = memberGuid };
+        return new DeleteStructureMember_ChangeInfo() { GuidValue = memberGuid, ParentGuid = parentGuid };
     }
 
     public override IChangeInfo Revert(Document doc)

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changes/Structure/MoveStructureMember_Change.cs

@@ -41,12 +41,12 @@ internal class MoveStructureMember_Change : Change
     {
         Move(target, memberGuid, targetFolderGuid, targetFolderIndex);
         ignoreInUndo = false;
-        return new MoveStructureMember_ChangeInfo() { GuidValue = memberGuid };
+        return new MoveStructureMember_ChangeInfo() { GuidValue = memberGuid, ParentFromGuid = originalFolderGuid, ParentToGuid = targetFolderGuid };
     }
 
     public override IChangeInfo? Revert(Document target)
     {
         Move(target, memberGuid, originalFolderGuid, originalFolderIndex);
-        return new MoveStructureMember_ChangeInfo() { GuidValue = memberGuid };
+        return new MoveStructureMember_ChangeInfo() { GuidValue = memberGuid, ParentFromGuid = targetFolderGuid, ParentToGuid = originalFolderGuid };
     }
 }

+ 3 - 3
src/PixiEditor.ChangeableDocument/DocumentChangeTracker.cs

@@ -180,7 +180,7 @@ public class DocumentChangeTracker : IDisposable
         return info;
     }
 
-    private List<IChangeInfo?> ProcessActionList(List<IAction> actions)
+    private List<IChangeInfo?> ProcessActionList(IReadOnlyList<IAction> actions)
     {
         List<IChangeInfo?> changeInfos = new();
         foreach (var action in actions)
@@ -217,7 +217,7 @@ public class DocumentChangeTracker : IDisposable
         return changeInfos;
     }
 
-    public async Task<List<IChangeInfo?>> ProcessActions(List<IAction> actions)
+    public async Task<List<IChangeInfo?>> ProcessActions(IReadOnlyList<IAction> actions)
     {
         if (disposed)
             throw new ObjectDisposedException(nameof(DocumentChangeTracker));
@@ -229,7 +229,7 @@ public class DocumentChangeTracker : IDisposable
         return result;
     }
 
-    public List<IChangeInfo?> ProcessActionsSync(List<IAction> actions)
+    public List<IChangeInfo?> ProcessActionsSync(IReadOnlyList<IAction> actions)
     {
         if (disposed)
             throw new ObjectDisposedException(nameof(DocumentChangeTracker));

+ 160 - 160
src/PixiEditor.ChangeableDocument/Rendering/ChunkRenderer.cs

@@ -4,174 +4,197 @@ using ChunkyImageLib.Operations;
 using OneOf;
 using OneOf.Types;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
-using PixiEditor.ChangeableDocument.Enums;
 using SkiaSharp;
 
 namespace PixiEditor.ChangeableDocument.Rendering;
 
 public static class ChunkRenderer
 {
-    private static SKPaint PaintToDrawChunksWith = new SKPaint() { BlendMode = SKBlendMode.SrcOver };
     private static SKPaint ReplacingPaint = new SKPaint() { BlendMode = SKBlendMode.Src };
     private static SKPaint ClippingPaint = new SKPaint() { BlendMode = SKBlendMode.DstIn };
-    public static Chunk RenderWholeStructure(VecI pos, ChunkResolution resolution, IReadOnlyFolder root)
+
+    public static OneOf<Chunk, EmptyChunk> MergeWholeStructure(VecI pos, ChunkResolution resolution, IReadOnlyFolder root)
     {
-        return RenderChunkRecursively(pos, resolution, 0, root, null);
+        using (RenderingContext context = new())
+            return MergeFolderContents(context, pos, resolution, root, new All());
     }
 
-    public static Chunk RenderSpecificLayers(VecI pos, ChunkResolution resolution, IReadOnlyFolder root, HashSet<Guid> layers)
+    public static OneOf<Chunk, EmptyChunk> MergeChosenMembers(VecI pos, ChunkResolution resolution, IReadOnlyFolder root, HashSet<Guid> members)
     {
-        return RenderChunkRecursively(pos, resolution, 0, root, layers);
+        using (RenderingContext context = new())
+            return MergeFolderContents(context, pos, resolution, root, members);
     }
 
-    private static SKBlendMode GetSKBlendMode(BlendMode blendMode)
+    private static OneOf<EmptyChunk, Chunk> RenderLayerWithMask
+        (RenderingContext context, Chunk targetChunk, VecI chunkPos, ChunkResolution resolution, IReadOnlyLayer layer, OneOf<FilledChunk, EmptyChunk, Chunk> clippingChunk)
     {
-        return blendMode switch
+        if (
+            clippingChunk.IsT1 ||
+            !layer.IsVisible ||
+            layer.Opacity == 0 ||
+            (layer.ReadOnlyMask is not null && !layer.ReadOnlyMask.LatestOrCommittedChunkExists(chunkPos))
+            )
+            return new EmptyChunk();
+
+        context.UpdateFromMember(layer);
+
+        Chunk renderingResult = Chunk.Create(resolution);
+        if (!layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, new(0, 0), context.ReplacingPaintWithOpacity))
         {
-            BlendMode.Normal => SKBlendMode.SrcOver,
-            BlendMode.Darken => SKBlendMode.Darken,
-            BlendMode.Multiply => SKBlendMode.Multiply,
-            BlendMode.ColorBurn => SKBlendMode.ColorBurn,
-            BlendMode.Lighten => SKBlendMode.Lighten,
-            BlendMode.Screen => SKBlendMode.Screen,
-            BlendMode.ColorDodge => SKBlendMode.ColorDodge,
-            BlendMode.LinearDodge => SKBlendMode.Plus,
-            BlendMode.Overlay => SKBlendMode.Overlay,
-            BlendMode.SoftLight => SKBlendMode.SoftLight,
-            BlendMode.HardLight => SKBlendMode.HardLight,
-            BlendMode.Difference => SKBlendMode.Difference,
-            BlendMode.Exclusion => SKBlendMode.Exclusion,
-            BlendMode.Hue => SKBlendMode.Hue,
-            BlendMode.Saturation => SKBlendMode.Saturation,
-            BlendMode.Luminosity => SKBlendMode.Luminosity,
-            BlendMode.Color => SKBlendMode.Color,
-            _ => SKBlendMode.SrcOver,
-        };
+            renderingResult.Dispose();
+            return new EmptyChunk();
+        }
+
+        if (!layer.ReadOnlyMask!.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, new(0, 0), ClippingPaint))
+        {
+            // should pretty much never happen due to the check above, but you can never be sure with many threads
+            renderingResult.Dispose();
+            return new EmptyChunk();
+        }
+
+        if (clippingChunk.IsT2)
+            OperationHelper.ClampAlpha(renderingResult.Surface.SkiaSurface, clippingChunk.AsT2.Surface.SkiaSurface);
+
+        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(renderingResult.Surface.SkiaSurface, 0, 0, context.BlendModePaint);
+        return renderingResult;
     }
 
-    private static Chunk RenderChunkRecursively(VecI chunkPos, ChunkResolution resolution, int depth, IReadOnlyFolder folder, HashSet<Guid>? visibleLayers)
+    private static OneOf<EmptyChunk, Chunk> RenderLayerSaveResult
+        (RenderingContext context, Chunk targetChunk, VecI chunkPos, ChunkResolution resolution, IReadOnlyLayer layer, OneOf<FilledChunk, EmptyChunk, Chunk> clippingChunk)
     {
-        // if you are a skilled programmer any problem can be solved with enough if/else statements
+        if (clippingChunk.IsT1 || !layer.IsVisible || layer.Opacity == 0)
+            return new EmptyChunk();
+
+        if (layer.ReadOnlyMask is not null)
+            return RenderLayerWithMask(context, targetChunk, chunkPos, resolution, layer, clippingChunk);
+
+        context.UpdateFromMember(layer);
+        Chunk renderingResult = Chunk.Create(resolution);
+        if (!layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, new(0, 0), context.ReplacingPaintWithOpacity))
+        {
+            renderingResult.Dispose();
+            return new EmptyChunk();
+        }
+
+        if (clippingChunk.IsT2)
+            OperationHelper.ClampAlpha(renderingResult.Surface.SkiaSurface, clippingChunk.AsT2.Surface.SkiaSurface);
+        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(renderingResult.Surface.SkiaSurface, 0, 0, context.BlendModePaint);
+        return renderingResult;
+    }
+
+    private static void RenderLayer
+        (RenderingContext context, Chunk targetChunk, VecI chunkPos, ChunkResolution resolution, IReadOnlyLayer layer, OneOf<FilledChunk, EmptyChunk, Chunk> clippingChunk)
+    {
+        if (clippingChunk.IsT1 || !layer.IsVisible || layer.Opacity == 0)
+            return;
+        if (layer.ReadOnlyMask is not null)
+        {
+            var result = RenderLayerWithMask(context, targetChunk, chunkPos, resolution, layer, clippingChunk);
+            if (result.IsT1)
+                result.AsT1.Dispose();
+            return;
+        }
+        // clipping chunk requires a temp chunk anyway so we could as well reuse the code from RenderLayerSaveResult
+        if (clippingChunk.IsT2)
+        {
+            var result = RenderLayerSaveResult(context, targetChunk, chunkPos, resolution, layer, clippingChunk);
+            if (result.IsT1)
+                result.AsT1.Dispose();
+            return;
+        }
+        context.UpdateFromMember(layer);
+        layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, targetChunk.Surface.SkiaSurface, new(0, 0), context.BlendModeOpacityPaint);
+    }
+
+    private static OneOf<EmptyChunk, Chunk> RenderFolder(
+        RenderingContext context,
+        Chunk targetChunk,
+        VecI chunkPos,
+        ChunkResolution resolution,
+        IReadOnlyFolder folder,
+        OneOf<FilledChunk, EmptyChunk, Chunk> clippingChunk,
+        OneOf<All, HashSet<Guid>> membersToMerge)
+    {
+        if (
+            clippingChunk.IsT1 ||
+            !folder.IsVisible ||
+            folder.Opacity == 0 ||
+            folder.ReadOnlyChildren.Count == 0 ||
+            (folder.ReadOnlyMask is not null && !folder.ReadOnlyMask.LatestOrCommittedChunkExists(chunkPos))
+            )
+            return new EmptyChunk();
+
+        OneOf<Chunk, EmptyChunk> maybeContents = MergeFolderContents(context, chunkPos, resolution, folder, membersToMerge);
+        if (maybeContents.IsT1)
+            return new EmptyChunk();
+        Chunk contents = maybeContents.AsT0;
+
+        if (folder.ReadOnlyMask is not null)
+        {
+            if (!folder.ReadOnlyMask.DrawMostUpToDateChunkOn(chunkPos, resolution, contents.Surface.SkiaSurface, new(0, 0), ClippingPaint))
+            {
+                // this shouldn't really happen due to the check above, but another thread could edit the mask in the meantime
+                contents.Dispose();
+                return new EmptyChunk();
+            }
+        }
+
+        if (clippingChunk.IsT2)
+            OperationHelper.ClampAlpha(contents.Surface.SkiaSurface, clippingChunk.AsT2.Surface.SkiaSurface);
+        context.UpdateFromMember(folder);
+        contents.Surface.SkiaSurface.Canvas.DrawSurface(contents.Surface.SkiaSurface, 0, 0, context.ReplacingPaintWithOpacity);
+        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(contents.Surface.SkiaSurface, 0, 0, context.BlendModePaint);
+
+        return contents;
+    }
+
+    private static OneOf<Chunk, EmptyChunk> MergeFolderContents(
+        RenderingContext context,
+        VecI chunkPos,
+        ChunkResolution resolution,
+        IReadOnlyFolder folder,
+        OneOf<All, HashSet<Guid>> membersToMerge)
+    {
+        if (folder.ReadOnlyChildren.Count == 0)
+            return new EmptyChunk();
+
+        // clipping to member below doesn't make sense if we are skipping some of them
+        bool ignoreClipToBelow = membersToMerge.IsT1;
+
         Chunk targetChunk = Chunk.Create(resolution);
         targetChunk.Surface.SkiaSurface.Canvas.Clear();
 
-        //<clipping Chunk; None to clip with (fully masked out); No active clip>
-        OneOf<Chunk, None, No> clippingChunk = new No();
+        OneOf<FilledChunk, EmptyChunk, Chunk> clippingChunk = new FilledChunk();
         for (int i = 0; i < folder.ReadOnlyChildren.Count; i++)
         {
             var child = folder.ReadOnlyChildren[i];
 
             // next child might use clip to member below in which case we need to save the clip image
-            bool needToSaveClip =
+            bool needToSaveClippingChunk =
+                !ignoreClipToBelow &&
                 i < folder.ReadOnlyChildren.Count - 1 &&
                 !child.ClipToMemberBelow &&
                 folder.ReadOnlyChildren[i + 1].ClipToMemberBelow;
-            bool clipActiveWithReference = (clippingChunk.IsT0 || clippingChunk.IsT1) && child.ClipToMemberBelow;
-            if (!child.ClipToMemberBelow && !clippingChunk.IsT2)
-            {
-                if (clippingChunk.IsT0)
-                    clippingChunk.AsT0.Dispose();
-                clippingChunk = new No();
-            }
-
-            if (!child.IsVisible)
-            {
-                if (needToSaveClip)
-                    clippingChunk = new None();
-                continue;
-            }
 
-            //// actual drawing
-            // chunk fully masked out
-            if (child.ReadOnlyMask is not null && !child.ReadOnlyMask.LatestOrCommittedChunkExists(chunkPos))
+            // if the current member doesn't need a clip, get rid of it
+            if (!child.ClipToMemberBelow && !clippingChunk.IsT0)
             {
-                if (needToSaveClip)
-                    clippingChunk = new None();
-                continue;
+                if (clippingChunk.IsT2)
+                    clippingChunk.AsT2.Dispose();
+                clippingChunk = new FilledChunk();
             }
 
             // layer
-            if (child is IReadOnlyLayer layer && (visibleLayers is null || visibleLayers.Contains(layer.GuidValue)))
+            if (child is IReadOnlyLayer layer && (membersToMerge.IsT0 || membersToMerge.AsT1.Contains(layer.GuidValue)))
             {
-                // no mask
-                if (layer.ReadOnlyMask is null)
+                if (needToSaveClippingChunk)
                 {
-                    PaintToDrawChunksWith.Color = new SKColor(255, 255, 255, (byte)Math.Round(child.Opacity * 255));
-                    PaintToDrawChunksWith.BlendMode = GetSKBlendMode(layer.BlendMode);
-                    // draw while saving clip for later
-                    if (needToSaveClip)
-                    {
-                        var clip = Chunk.Create(resolution);
-                        if (!layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, clip.Surface.SkiaSurface, new(0, 0), ReplacingPaint))
-                        {
-                            clip.Dispose();
-                            clippingChunk = new None();
-                            continue;
-                        }
-                        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(clip.Surface.SkiaSurface, new(0, 0), PaintToDrawChunksWith);
-                        clippingChunk = clip;
-                    }
-                    // draw using saved clip
-                    else if (clipActiveWithReference)
-                    {
-                        if (clippingChunk.IsT1)
-                            continue;
-                        using var tempChunk = Chunk.Create();
-                        if (!layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn
-                            (chunkPos, resolution, tempChunk.Surface.SkiaSurface, new(0, 0), ReplacingPaint))
-                            continue;
-                        OperationHelper.ClampAlpha(tempChunk.Surface.SkiaSurface, clippingChunk.AsT0.Surface.SkiaSurface);
-                        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(tempChunk.Surface.SkiaSurface, new(0, 0), PaintToDrawChunksWith);
-                    }
-                    // just draw
-                    else
-                    {
-                        layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn
-                            (chunkPos, resolution, targetChunk.Surface.SkiaSurface, new(0, 0), PaintToDrawChunksWith);
-                    }
+                    OneOf<EmptyChunk, Chunk> result = RenderLayerSaveResult(context, targetChunk, chunkPos, resolution, layer, clippingChunk);
+                    clippingChunk = result.IsT0 ? result.AsT0 : result.AsT1;
                 }
-                // with mask
                 else
                 {
-                    PaintToDrawChunksWith.Color = new SKColor(255, 255, 255, (byte)Math.Round(child.Opacity * 255));
-                    PaintToDrawChunksWith.BlendMode = GetSKBlendMode(layer.BlendMode);
-                    // draw while saving clip
-                    if (needToSaveClip)
-                    {
-                        Chunk tempChunk = Chunk.Create(resolution);
-                        // this chunk is empty
-                        if (!layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, tempChunk.Surface.SkiaSurface, new(0, 0), ReplacingPaint))
-                        {
-                            tempChunk.Dispose();
-                            clippingChunk = new None();
-                            continue;
-                        }
-                        // this chunk is not empty
-                        layer.ReadOnlyMask.DrawMostUpToDateChunkOn(chunkPos, resolution, tempChunk.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-                        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(tempChunk.Surface.SkiaSurface, 0, 0, PaintToDrawChunksWith);
-                        clippingChunk = tempChunk;
-                    }
-                    // draw using saved clip
-                    else if (clipActiveWithReference)
-                    {
-                        if (clippingChunk.IsT1)
-                            continue;
-                        using Chunk tempChunk = Chunk.Create(resolution);
-                        if (!layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, tempChunk.Surface.SkiaSurface, new(0, 0), ReplacingPaint))
-                            continue;
-                        layer.ReadOnlyMask.DrawMostUpToDateChunkOn(chunkPos, resolution, tempChunk.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-                        OperationHelper.ClampAlpha(tempChunk.Surface.SkiaSurface, clippingChunk.AsT0.Surface.SkiaSurface);
-                        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(tempChunk.Surface.SkiaSurface, 0, 0, PaintToDrawChunksWith);
-                    }
-                    // just draw
-                    else
-                    {
-                        using Chunk tempChunk = Chunk.Create(resolution);
-                        if (!layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, tempChunk.Surface.SkiaSurface, new(0, 0), ReplacingPaint))
-                            continue;
-                        layer.ReadOnlyMask.DrawMostUpToDateChunkOn(chunkPos, resolution, tempChunk.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-                        targetChunk.Surface.SkiaSurface.Canvas.DrawSurface(tempChunk.Surface.SkiaSurface, 0, 0, PaintToDrawChunksWith);
-                    }
+                    RenderLayer(context, targetChunk, chunkPos, resolution, layer, clippingChunk);
                 }
                 continue;
             }
@@ -179,45 +202,22 @@ public static class ChunkRenderer
             // folder
             if (child is IReadOnlyFolder innerFolder)
             {
-                PaintToDrawChunksWith.Color = new SKColor(255, 255, 255, (byte)Math.Round(child.Opacity * 255));
-                PaintToDrawChunksWith.BlendMode = GetSKBlendMode(innerFolder.BlendMode);
-
-                // draw while saving clip
-                if (needToSaveClip)
-                {
-                    Chunk renderedChunk = RenderChunkRecursively(chunkPos, resolution, depth + 1, innerFolder, visibleLayers);
-                    if (innerFolder.ReadOnlyMask is not null)
-                        innerFolder.ReadOnlyMask.DrawMostUpToDateChunkOn(chunkPos, resolution, renderedChunk.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-
-                    renderedChunk.DrawOnSurface(targetChunk.Surface.SkiaSurface, new(0, 0), PaintToDrawChunksWith);
-                    clippingChunk = renderedChunk;
-                    continue;
-                }
-                // draw using saved clip
-                else if (clipActiveWithReference)
+                bool shouldRenderAllChildren = membersToMerge.IsT0 || membersToMerge.AsT1.Contains(innerFolder.GuidValue);
+                OneOf<All, HashSet<Guid>> innerMembersToMerge = shouldRenderAllChildren ? new All() : membersToMerge;
+                if (needToSaveClippingChunk)
                 {
-                    if (clippingChunk.IsT1)
-                        continue;
-                    using Chunk renderedChunk = RenderChunkRecursively(chunkPos, resolution, depth + 1, innerFolder, visibleLayers);
-                    if (innerFolder.ReadOnlyMask is not null)
-                        innerFolder.ReadOnlyMask.DrawMostUpToDateChunkOn(chunkPos, resolution, renderedChunk.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-                    OperationHelper.ClampAlpha(renderedChunk.Surface.SkiaSurface, clippingChunk.AsT0.Surface.SkiaSurface);
-                    renderedChunk.DrawOnSurface(targetChunk.Surface.SkiaSurface, new(0, 0), PaintToDrawChunksWith);
-                    continue;
+                    OneOf<EmptyChunk, Chunk> result = RenderFolder(context, targetChunk, chunkPos, resolution, innerFolder, clippingChunk, innerMembersToMerge);
+                    clippingChunk = result.IsT0 ? result.AsT0 : result.AsT1;
                 }
-                // just draw
                 else
                 {
-                    using Chunk renderedChunk = RenderChunkRecursively(chunkPos, resolution, depth + 1, innerFolder, visibleLayers);
-                    if (innerFolder.ReadOnlyMask is not null)
-                        innerFolder.ReadOnlyMask.DrawMostUpToDateChunkOn(chunkPos, resolution, renderedChunk.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-                    renderedChunk.DrawOnSurface(targetChunk.Surface.SkiaSurface, new(0, 0), PaintToDrawChunksWith);
-                    continue;
+                    RenderFolder(context, targetChunk, chunkPos, resolution, innerFolder, clippingChunk, innerMembersToMerge);
                 }
+                continue;
             }
         }
-        if (clippingChunk.IsT0)
-            clippingChunk.AsT0.Dispose();
+        if (clippingChunk.IsT2)
+            clippingChunk.AsT2.Dispose();
         return targetChunk;
     }
 }

+ 54 - 0
src/PixiEditor.ChangeableDocument/Rendering/RenderingContext.cs

@@ -0,0 +1,54 @@
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.Enums;
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Rendering;
+internal class RenderingContext : IDisposable
+{
+    public SKPaint BlendModePaint = new SKPaint() { BlendMode = SKBlendMode.SrcOver };
+    public SKPaint BlendModeOpacityPaint = new SKPaint() { BlendMode = SKBlendMode.SrcOver };
+    public SKPaint ReplacingPaintWithOpacity = new SKPaint() { BlendMode = SKBlendMode.Src };
+
+    public void UpdateFromMember(IReadOnlyStructureMember member)
+    {
+        SKColor opacityColor = new(255, 255, 255, (byte)Math.Round(member.Opacity * 255));
+        SKBlendMode blendMode = GetSKBlendMode(member.BlendMode);
+
+        BlendModeOpacityPaint.Color = opacityColor;
+        BlendModeOpacityPaint.BlendMode = blendMode;
+        BlendModePaint.BlendMode = blendMode;
+        ReplacingPaintWithOpacity.Color = opacityColor;
+    }
+
+    private static SKBlendMode GetSKBlendMode(BlendMode blendMode)
+    {
+        return blendMode switch
+        {
+            BlendMode.Normal => SKBlendMode.SrcOver,
+            BlendMode.Darken => SKBlendMode.Darken,
+            BlendMode.Multiply => SKBlendMode.Multiply,
+            BlendMode.ColorBurn => SKBlendMode.ColorBurn,
+            BlendMode.Lighten => SKBlendMode.Lighten,
+            BlendMode.Screen => SKBlendMode.Screen,
+            BlendMode.ColorDodge => SKBlendMode.ColorDodge,
+            BlendMode.LinearDodge => SKBlendMode.Plus,
+            BlendMode.Overlay => SKBlendMode.Overlay,
+            BlendMode.SoftLight => SKBlendMode.SoftLight,
+            BlendMode.HardLight => SKBlendMode.HardLight,
+            BlendMode.Difference => SKBlendMode.Difference,
+            BlendMode.Exclusion => SKBlendMode.Exclusion,
+            BlendMode.Hue => SKBlendMode.Hue,
+            BlendMode.Saturation => SKBlendMode.Saturation,
+            BlendMode.Luminosity => SKBlendMode.Luminosity,
+            BlendMode.Color => SKBlendMode.Color,
+            _ => SKBlendMode.SrcOver,
+        };
+    }
+
+    public void Dispose()
+    {
+        BlendModePaint.Dispose();
+        BlendModeOpacityPaint.Dispose();
+        ReplacingPaintWithOpacity.Dispose();
+    }
+}

+ 77 - 14
src/PixiEditorPrototype/Models/ActionAccumulator.cs

@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using System.Linq;
+using System.Windows;
 using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.ChangeInfos;
@@ -49,31 +50,47 @@ internal class ActionAccumulator
 
         while (queuedActions.Count > 0)
         {
+            // select actions to be processed
             var toExecute = queuedActions;
             queuedActions = new List<IAction>();
 
-            List<IChangeInfo?> result = AreAllPassthrough(toExecute) ?
-                toExecute.Select(a => (IChangeInfo?)a).ToList() :
-                await helpers.Tracker.ProcessActions(toExecute);
+            // pass them to changeabledocument for processing
+            List<IChangeInfo?> changes;
+            if (AreAllPassthrough(toExecute))
+                changes = toExecute.Select(a => (IChangeInfo?)a).ToList();
+            else
+                changes = await helpers.Tracker.ProcessActions(toExecute);
 
-            foreach (IChangeInfo? info in result)
+            // update viewmodels based on changes
+            foreach (IChangeInfo? info in changes)
             {
                 helpers.Updater.ApplyChangeFromChangeInfo(info);
             }
 
+            // lock bitmaps that need to be updated
+            var affectedChunks = new AffectedChunkGatherer(helpers.Tracker, changes);
+
             foreach (var (_, bitmap) in document.Bitmaps)
             {
                 bitmap.Lock();
             }
+            bool refreshPreviews = toExecute.Any(static action => action is ChangeBoundary_Action or Redo_Action or Undo_Action);
+            if (refreshPreviews)
+                LockPreviewBitmaps(document.StructureRoot);
 
-            var renderResult = await renderer.ProcessChanges(result);
+            // update bitmaps
+            var renderResult = await renderer.UpdateGatheredChunks(affectedChunks, refreshPreviews);
             AddDirtyRects(renderResult);
 
+            // unlock bitmaps
             foreach (var (_, bitmap) in document.Bitmaps)
             {
                 bitmap.Unlock();
             }
+            if (refreshPreviews)
+                UnlockPreviewBitmaps(document.StructureRoot);
 
+            // force refresh viewports for better responsiveness
             foreach (var (_, value) in helpers.State.Viewports)
             {
                 value.InvalidateVisual();
@@ -93,18 +110,64 @@ internal class ActionAccumulator
         return true;
     }
 
+    private void LockPreviewBitmaps(FolderViewModel root)
+    {
+        foreach (var child in root.Children)
+        {
+            child.PreviewBitmap.Lock();
+            if (child.MaskPreviewBitmap is not null)
+                child.MaskPreviewBitmap.Lock();
+            if (child is FolderViewModel innerFolder)
+                LockPreviewBitmaps(innerFolder);
+        }
+    }
+
+    private void UnlockPreviewBitmaps(FolderViewModel root)
+    {
+        foreach (var child in root.Children)
+        {
+            child.PreviewBitmap.Unlock();
+            if (child.MaskPreviewBitmap is not null)
+                child.MaskPreviewBitmap.Unlock();
+            if (child is FolderViewModel innerFolder)
+                UnlockPreviewBitmaps(innerFolder);
+        }
+    }
+
     private void AddDirtyRects(List<IRenderInfo> changes)
     {
-        foreach (IRenderInfo info in changes)
+        foreach (IRenderInfo renderInfo in changes)
         {
-            if (info is not DirtyRect_RenderInfo dirtyRectInfo)
-                continue;
-            var bitmap = document.Bitmaps[dirtyRectInfo.Resolution];
-            SKRectI finalRect = SKRectI.Create(0, 0, bitmap.PixelWidth, bitmap.PixelHeight);
-
-            SKRectI dirtyRect = SKRectI.Create(dirtyRectInfo.Pos, dirtyRectInfo.Size);
-            dirtyRect.Intersect(finalRect);
-            bitmap.AddDirtyRect(new(dirtyRect.Left, dirtyRect.Top, dirtyRect.Width, dirtyRect.Height));
+            switch (renderInfo)
+            {
+                case DirtyRect_RenderInfo info:
+                    {
+                        var bitmap = document.Bitmaps[info.Resolution];
+                        SKRectI finalRect = SKRectI.Create(0, 0, bitmap.PixelWidth, bitmap.PixelHeight);
+
+                        SKRectI dirtyRect = SKRectI.Create(info.Pos, info.Size);
+                        dirtyRect.Intersect(finalRect);
+                        bitmap.AddDirtyRect(new(dirtyRect.Left, dirtyRect.Top, dirtyRect.Width, dirtyRect.Height));
+                    }
+                    break;
+                case PreviewDirty_RenderInfo info:
+                    {
+                        var bitmap = helpers.StructureHelper.Find(info.GuidValue)?.PreviewBitmap;
+                        if (bitmap is null)
+                            continue;
+                        bitmap.AddDirtyRect(new Int32Rect(0, 0, bitmap.PixelWidth, bitmap.PixelHeight));
+                    }
+                    break;
+                case MaskPreviewDirty_RenderInfo info:
+                    {
+                        var bitmap = helpers.StructureHelper.Find(info.GuidValue)?.MaskPreviewBitmap;
+                        if (bitmap is null)
+                            continue;
+                        bitmap.AddDirtyRect(new Int32Rect(0, 0, bitmap.PixelWidth, bitmap.PixelHeight));
+                    }
+                    break;
+            }
         }
+
     }
 }

+ 40 - 1
src/PixiEditorPrototype/Models/DocumentUpdater.cs

@@ -85,7 +85,7 @@ internal class DocumentUpdater
 
     private void ProcessClipToMemberBelow(StructureMemberClipToMemberBelow_ChangeInfo info)
     {
-        var member = helper.StructureHelper.FindOrThrow(info.MemberGuid);
+        var member = helper.StructureHelper.FindOrThrow(info.GuidValue);
         member.RaisePropertyChanged(nameof(member.ClipToMemberBelowEnabled));
     }
 
@@ -125,7 +125,17 @@ internal class DocumentUpdater
     private void ProcessStructureMemberMask(StructureMemberMask_ChangeInfo info)
     {
         var memberVm = helper.StructureHelper.FindOrThrow(info.GuidValue);
+        memberVm.MaskPreviewSurface?.Dispose();
+        memberVm.MaskPreviewSurface = null;
+        memberVm.MaskPreviewBitmap = null;
+        var size = StructureMemberViewModel.CalculatePreviewSize(new(doc.Width, doc.Height));
+        if (memberVm.HasMask)
+        {
+            memberVm.MaskPreviewBitmap = CreateBitmap(size);
+            memberVm.MaskPreviewSurface = CreateSKSurface(memberVm.MaskPreviewBitmap);
+        }
         memberVm.RaisePropertyChanged(nameof(memberVm.HasMask));
+        memberVm.RaisePropertyChanged(nameof(memberVm.MaskPreviewBitmap));
     }
 
     private void ProcessRefreshViewport(RefreshViewport_PassthroughAction info)
@@ -138,6 +148,32 @@ internal class DocumentUpdater
         helper.State.Viewports.Remove(info.GuidValue);
     }
 
+    private void UpdateMemberBitmapsRecursively(FolderViewModel folder, VecI newSize)
+    {
+        foreach (var member in folder.Children)
+        {
+            member.PreviewSurface.Dispose();
+            member.PreviewBitmap = CreateBitmap(newSize);
+            member.PreviewSurface = CreateSKSurface(member.PreviewBitmap);
+            member.RaisePropertyChanged(nameof(member.PreviewBitmap));
+
+            member.MaskPreviewSurface?.Dispose();
+            member.MaskPreviewSurface = null;
+            member.MaskPreviewBitmap = null;
+            if (member.HasMask)
+            {
+                member.MaskPreviewBitmap = CreateBitmap(newSize);
+                member.MaskPreviewSurface = CreateSKSurface(member.MaskPreviewBitmap);
+            }
+            member.RaisePropertyChanged(nameof(member.MaskPreviewBitmap));
+
+            if (member is FolderViewModel innerFolder)
+            {
+                UpdateMemberBitmapsRecursively(innerFolder, newSize);
+            }
+        }
+    }
+
     private void ProcessSize(Size_ChangeInfo info)
     {
         var size = helper.Tracker.Document.Size;
@@ -155,6 +191,9 @@ internal class DocumentUpdater
         doc.RaisePropertyChanged(nameof(doc.Height));
         doc.RaisePropertyChanged(nameof(doc.HorizontalSymmetryAxisY));
         doc.RaisePropertyChanged(nameof(doc.VerticalSymmetryAxisX));
+
+        var previewSize = StructureMemberViewModel.CalculatePreviewSize(size);
+        UpdateMemberBitmapsRecursively(doc.StructureRoot, previewSize);
     }
 
     private WriteableBitmap CreateBitmap(VecI size)

+ 217 - 0
src/PixiEditorPrototype/Models/Rendering/AffectedChunkGatherer.cs

@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using ChangeableDocument;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+using PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+using PixiEditor.ChangeableDocument.ChangeInfos.Root;
+using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+
+namespace PixiEditorPrototype.Models.Rendering;
+internal class AffectedChunkGatherer
+{
+    private readonly DocumentChangeTracker tracker;
+
+    public HashSet<VecI> mainImageChunks { get; private set; } = new();
+    public Dictionary<Guid, HashSet<VecI>> imagePreviewChunks { get; private set; } = new();
+    public Dictionary<Guid, HashSet<VecI>> maskPreviewChunks { get; private set; } = new();
+
+    public AffectedChunkGatherer(DocumentChangeTracker tracker, IReadOnlyList<IChangeInfo?> changes)
+    {
+        this.tracker = tracker;
+        ProcessChanges(changes);
+    }
+
+    private void ProcessChanges(IReadOnlyList<IChangeInfo?> changes)
+    {
+        foreach (var change in changes)
+        {
+            switch (change)
+            {
+                case MaskChunks_ChangeInfo info:
+                    if (info.Chunks is null)
+                        throw new InvalidOperationException("Chunks must not be null");
+                    AddToMainImage(info.Chunks);
+                    AddToImagePreviews(info.GuidValue, info.Chunks, true);
+                    AddToMaskPreview(info.GuidValue, info.Chunks);
+                    break;
+                case LayerImageChunks_ChangeInfo info:
+                    if (info.Chunks is null)
+                        throw new InvalidOperationException("Chunks must not be null");
+                    AddToMainImage(info.Chunks);
+                    AddToImagePreviews(info.GuidValue, info.Chunks);
+                    break;
+                case CreateStructureMember_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue);
+                    AddAllToMaskPreview(info.GuidValue);
+                    break;
+                case DeleteStructureMember_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToImagePreviews(info.ParentGuid);
+                    break;
+                case MoveStructureMember_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    if (info.ParentFromGuid != info.ParentToGuid)
+                        AddWholeCanvasToImagePreviews(info.ParentFromGuid);
+                    break;
+                case Size_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryMaskPreview();
+                    break;
+                case StructureMemberMask_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToMaskPreview(info.GuidValue);
+                    AddWholeCanvasToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberBlendMode_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberClipToMemberBelow_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberOpacity_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberIsVisible_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+            }
+        }
+    }
+
+    private void AddAllToImagePreviews(Guid memberGuid, bool ignoreSelf = false)
+    {
+        var member = tracker.Document.FindMemberOrThrow(memberGuid);
+        if (member is IReadOnlyLayer layer)
+        {
+            var chunks = layer.ReadOnlyLayerImage.FindAllChunks();
+            AddToImagePreviews(memberGuid, chunks, ignoreSelf);
+        }
+        else
+        {
+            AddWholeCanvasToImagePreviews(memberGuid, ignoreSelf);
+        }
+    }
+    private void AddAllToMainImage(Guid memberGuid)
+    {
+        var member = tracker.Document.FindMemberOrThrow(memberGuid);
+        if (member is IReadOnlyLayer layer)
+        {
+            var chunks = layer.ReadOnlyLayerImage.FindAllChunks();
+            if (layer.ReadOnlyMask is not null)
+                chunks.IntersectWith(layer.ReadOnlyMask.FindAllChunks());
+            AddToMainImage(chunks);
+        }
+        else
+        {
+            AddWholeCanvasToMainImage();
+        }
+    }
+    private void AddAllToMaskPreview(Guid memberGuid)
+    {
+        var member = tracker.Document.FindMemberOrThrow(memberGuid);
+        if (member.ReadOnlyMask is null)
+            return;
+        var chunks = member.ReadOnlyMask.FindAllChunks();
+        AddToMaskPreview(memberGuid, chunks);
+    }
+
+
+    private void AddToMainImage(HashSet<VecI> chunks)
+    {
+        mainImageChunks.UnionWith(chunks);
+    }
+    private void AddToImagePreviews(Guid memberGuid, HashSet<VecI> chunks, bool ignoreSelf = false)
+    {
+        var path = tracker.Document.FindMemberPath(memberGuid);
+        if (path.Count < 2)
+            throw new ArgumentException($"Member with guid {memberGuid} was not found");
+        for (int i = ignoreSelf ? 1 : 0; i < path.Count - 1; i++)
+        {
+            var member = path[i];
+            if (!imagePreviewChunks.ContainsKey(member.GuidValue))
+                imagePreviewChunks[member.GuidValue] = new HashSet<VecI>(chunks);
+            else
+                imagePreviewChunks[member.GuidValue].UnionWith(chunks);
+        }
+    }
+    private void AddToMaskPreview(Guid memberGuid, HashSet<VecI> chunks)
+    {
+        if (!maskPreviewChunks.ContainsKey(memberGuid))
+            maskPreviewChunks[memberGuid] = new HashSet<VecI>(chunks);
+        else
+            maskPreviewChunks[memberGuid].UnionWith(chunks);
+    }
+
+
+    private void AddWholeCanvasToMainImage()
+    {
+        AddAllChunks(mainImageChunks);
+    }
+    private void AddWholeCanvasToImagePreviews(Guid memberGuid, bool ignoreSelf = false)
+    {
+        var path = tracker.Document.FindMemberPath(memberGuid);
+        if (path.Count < 2)
+            throw new ArgumentException($"Member with guid {memberGuid} was not found");
+        // skip root folder
+        for (int i = ignoreSelf ? 1 : 0; i < path.Count - 1; i++)
+        {
+            var member = path[i];
+            if (!imagePreviewChunks.ContainsKey(member.GuidValue))
+                imagePreviewChunks[member.GuidValue] = new HashSet<VecI>();
+            AddAllChunks(imagePreviewChunks[member.GuidValue]);
+        }
+    }
+    private void AddWholeCanvasToMaskPreview(Guid memberGuid)
+    {
+        if (!maskPreviewChunks.ContainsKey(memberGuid))
+            maskPreviewChunks[memberGuid] = new HashSet<VecI>();
+        AddAllChunks(maskPreviewChunks[memberGuid]);
+    }
+
+
+    private void AddWholeCanvasToEveryImagePreview()
+    {
+        ForEveryMember(tracker.Document.ReadOnlyStructureRoot, (member) => AddWholeCanvasToImagePreviews(member.GuidValue));
+    }
+
+    private void AddWholeCanvasToEveryMaskPreview()
+    {
+        ForEveryMember(tracker.Document.ReadOnlyStructureRoot, (member) => AddWholeCanvasToMaskPreview(member.GuidValue));
+    }
+
+
+    private void ForEveryMember(IReadOnlyFolder folder, Action<IReadOnlyStructureMember> action)
+    {
+        foreach (var child in folder.ReadOnlyChildren)
+        {
+            action(child);
+            if (child is IReadOnlyFolder innerFolder)
+                ForEveryMember(innerFolder, action);
+        }
+    }
+
+    private void AddAllChunks(HashSet<VecI> chunks)
+    {
+        VecI size = new(
+            (int)Math.Ceiling(tracker.Document.Size.X / (float)ChunkyImage.ChunkSize),
+            (int)Math.Ceiling(tracker.Document.Size.Y / (float)ChunkyImage.ChunkSize));
+        for (int i = 0; i < size.X; i++)
+        {
+            for (int j = 0; j < size.Y; j++)
+            {
+                chunks.Add(new(i, j));
+            }
+        }
+    }
+}

+ 5 - 0
src/PixiEditorPrototype/Models/Rendering/RenderInfos/MaskPreviewDirty_RenderInfo.cs

@@ -0,0 +1,5 @@
+using System;
+
+namespace PixiEditorPrototype.Models.Rendering.RenderInfos;
+
+public record class MaskPreviewDirty_RenderInfo(Guid GuidValue) : IRenderInfo;

+ 5 - 0
src/PixiEditorPrototype/Models/Rendering/RenderInfos/PreviewDirty_RenderInfo.cs

@@ -0,0 +1,5 @@
+using System;
+
+namespace PixiEditorPrototype.Models.Rendering.RenderInfos;
+
+public record class PreviewDirty_RenderInfo(Guid GuidValue) : IRenderInfo;

+ 125 - 92
src/PixiEditorPrototype/Models/Rendering/WriteableBitmapUpdater.cs

@@ -4,12 +4,8 @@ using System.Threading.Tasks;
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.Operations;
+using OneOf;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
-using PixiEditor.ChangeableDocument.ChangeInfos;
-using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
-using PixiEditor.ChangeableDocument.ChangeInfos.Properties;
-using PixiEditor.ChangeableDocument.ChangeInfos.Root;
-using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
 using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditorPrototype.Models.Rendering.RenderInfos;
 using PixiEditorPrototype.ViewModels;
@@ -27,7 +23,7 @@ internal class WriteableBitmapUpdater
     private static readonly SKPaint SelectionPaint = new SKPaint() { BlendMode = SKBlendMode.SrcOver, Color = new(0xa0FFFFFF) };
     private static readonly SKPaint ClearPaint = new SKPaint() { BlendMode = SKBlendMode.Src, Color = SKColors.Transparent };
 
-    private readonly Dictionary<ChunkResolution, HashSet<VecI>> postponedChunks = new()
+    private readonly Dictionary<ChunkResolution, HashSet<VecI>> globalPostponedChunks = new()
     {
         [ChunkResolution.Full] = new(),
         [ChunkResolution.Half] = new(),
@@ -35,82 +31,25 @@ internal class WriteableBitmapUpdater
         [ChunkResolution.Eighth] = new()
     };
 
+    private Dictionary<Guid, HashSet<VecI>> previewPostponedChunks = new();
+    private Dictionary<Guid, HashSet<VecI>> maskPostponedChunks = new();
+
     public WriteableBitmapUpdater(DocumentViewModel doc, DocumentHelpers helpers)
     {
         this.doc = doc;
         this.helpers = helpers;
     }
 
-    public async Task<List<IRenderInfo>> ProcessChanges(IReadOnlyList<IChangeInfo?> changes)
-    {
-        return await Task.Run(() => Render(changes)).ConfigureAwait(true);
-    }
-
-    public List<IRenderInfo> ProcessChangesSync(IReadOnlyList<IChangeInfo?> changes)
+    public async Task<List<IRenderInfo>> UpdateGatheredChunks(AffectedChunkGatherer chunkGatherer, bool updatePreviews)
     {
-        return Render(changes);
+        return await Task.Run(() => Render(chunkGatherer, updatePreviews)).ConfigureAwait(true);
     }
 
-    private Dictionary<ChunkResolution, HashSet<VecI>> FindChunksToRerender(IReadOnlyList<IChangeInfo?> changes)
+    private Dictionary<ChunkResolution, HashSet<VecI>> FindGlobalChunksToRerender(AffectedChunkGatherer chunkGatherer)
     {
-        HashSet<VecI> affectedChunks = new();
-        foreach (var change in changes)
-        {
-            switch (change)
-            {
-                case MaskChunks_ChangeInfo maskChunks:
-                    if (maskChunks.Chunks is null)
-                        throw new InvalidOperationException("Chunks must not be null");
-                    affectedChunks.UnionWith(maskChunks.Chunks);
-                    break;
-                case LayerImageChunks_ChangeInfo layerImageChunks:
-                    if (layerImageChunks.Chunks is null)
-                        throw new InvalidOperationException("Chunks must not be null");
-                    affectedChunks.UnionWith(layerImageChunks.Chunks);
-                    break;
-                case Selection_ChangeInfo selection:
-                    if (helpers.Tracker.Document.ReadOnlySelection.ReadOnlyIsEmptyAndInactive)
-                    {
-                        AddAllChunks(affectedChunks);
-                    }
-                    else
-                    {
-                        if (selection.Chunks is null)
-                            throw new InvalidOperationException("Chunks must not be null");
-                        affectedChunks.UnionWith(selection.Chunks);
-                    }
-                    break;
-                case CreateStructureMember_ChangeInfo:
-                case DeleteStructureMember_ChangeInfo:
-                case MoveStructureMember_ChangeInfo:
-                case Size_ChangeInfo:
-                case StructureMemberMask_ChangeInfo:
-                case StructureMemberBlendMode_ChangeInfo:
-                case StructureMemberClipToMemberBelow_ChangeInfo:
-                    AddAllChunks(affectedChunks);
-                    break;
-                case StructureMemberOpacity_ChangeInfo opacityChangeInfo:
-                    var memberWithOpacity = helpers.Tracker.Document.FindMemberOrThrow(opacityChangeInfo.GuidValue);
-                    if (memberWithOpacity is IReadOnlyLayer layerWithOpacity)
-                        affectedChunks.UnionWith(layerWithOpacity.ReadOnlyLayerImage.FindAllChunks());
-                    else
-                        AddAllChunks(affectedChunks);
-                    break;
-                case StructureMemberIsVisible_ChangeInfo visibilityChangeInfo:
-                    var memberWithVisibility = helpers.Tracker.Document.FindMemberOrThrow(visibilityChangeInfo.GuidValue);
-                    if (memberWithVisibility is IReadOnlyLayer layerWithVisibility)
-                        affectedChunks.UnionWith(layerWithVisibility.ReadOnlyLayerImage.FindAllChunks());
-                    else
-                        AddAllChunks(affectedChunks);
-                    break;
-                case RefreshViewport_PassthroughAction moveViewportInfo:
-                    break;
-            }
-        }
-
-        foreach (var (_, postponed) in postponedChunks)
+        foreach (var (_, postponed) in globalPostponedChunks)
         {
-            postponed.UnionWith(affectedChunks);
+            postponed.UnionWith(chunkGatherer.mainImageChunks);
         }
 
         var chunksOnScreen = new Dictionary<ChunkResolution, HashSet<VecI>>()
@@ -131,7 +70,7 @@ internal class WriteableBitmapUpdater
             chunksOnScreen[viewport.Resolution].UnionWith(viewportChunks);
         }
 
-        foreach (var (res, postponed) in postponedChunks)
+        foreach (var (res, postponed) in globalPostponedChunks)
         {
             chunksOnScreen[res].IntersectWith(postponed);
             postponed.ExceptWith(chunksOnScreen[res]);
@@ -140,26 +79,119 @@ internal class WriteableBitmapUpdater
         return chunksOnScreen;
     }
 
-    private void AddAllChunks(HashSet<VecI> chunks)
+
+    private static void AddChunks(Dictionary<Guid, HashSet<VecI>> from, Dictionary<Guid, HashSet<VecI>> to)
     {
-        VecI size = new(
-            (int)Math.Ceiling(helpers.Tracker.Document.Size.X / (float)ChunkyImage.ChunkSize),
-            (int)Math.Ceiling(helpers.Tracker.Document.Size.Y / (float)ChunkyImage.ChunkSize));
-        for (int i = 0; i < size.X; i++)
+        foreach ((Guid guid, HashSet<VecI> chunks) in from)
         {
-            for (int j = 0; j < size.Y; j++)
+            if (!to.ContainsKey(guid))
+                to[guid] = new HashSet<VecI>();
+            to[guid].UnionWith(chunks);
+        }
+    }
+    private (Dictionary<Guid, HashSet<VecI>> image, Dictionary<Guid, HashSet<VecI>> mask) FindPreviewChunksToRerender
+        (AffectedChunkGatherer chunkGatherer, bool postpone)
+    {
+        AddChunks(chunkGatherer.imagePreviewChunks, previewPostponedChunks);
+        AddChunks(chunkGatherer.maskPreviewChunks, maskPostponedChunks);
+        if (postpone)
+            return (new(), new());
+        var result = (previewPostponedChunks, maskPostponedChunks);
+        previewPostponedChunks = new();
+        maskPostponedChunks = new();
+        return result;
+    }
+
+    private List<IRenderInfo> Render(AffectedChunkGatherer chunkGatherer, bool updatePreviews)
+    {
+        Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender = FindGlobalChunksToRerender(chunkGatherer);
+
+        List<IRenderInfo> infos = new();
+        UpdateMainImage(chunksToRerender, infos);
+
+        var (imagePreviewChunksToRerender, maskPreviewChunksToRerender) = FindPreviewChunksToRerender(chunkGatherer, !updatePreviews);
+        var previewSize = StructureMemberViewModel.CalculatePreviewSize(helpers.Tracker.Document.Size);
+        float scaling = (float)previewSize.X / doc.Width;
+        UpdateImagePreviews(imagePreviewChunksToRerender, scaling, infos);
+        UpdateMaskPreviews(maskPreviewChunksToRerender, scaling, infos);
+
+        return infos;
+    }
+
+    private void UpdateImagePreviews(Dictionary<Guid, HashSet<VecI>> imagePreviewChunks, float scaling, List<IRenderInfo> infos)
+    {
+        foreach (var (guid, chunks) in imagePreviewChunks)
+        {
+            var memberVM = helpers.StructureHelper.Find(guid);
+            if (memberVM is null)
+                continue;
+            var member = helpers.Tracker.Document.FindMemberOrThrow(guid);
+
+            memberVM.PreviewSurface.Canvas.Save();
+            memberVM.PreviewSurface.Canvas.Scale(scaling);
+            if (memberVM is LayerViewModel)
             {
-                chunks.Add(new(i, j));
+                var layer = (IReadOnlyLayer)member;
+                foreach (var chunk in chunks)
+                {
+                    var pos = chunk * ChunkResolution.Full.PixelSize();
+                    // the full res chunks are already rendered so drawing them again should be fast
+                    layer.ReadOnlyLayerImage.DrawMostUpToDateChunkOn
+                        (chunk, ChunkResolution.Full, memberVM.PreviewSurface, pos, ReplacingPaint);
+                }
+                infos.Add(new PreviewDirty_RenderInfo(guid));
+            }
+            else if (memberVM is FolderViewModel)
+            {
+                var folder = (IReadOnlyFolder)member;
+                foreach (var chunk in chunks)
+                {
+                    var pos = chunk * ChunkResolution.Full.PixelSize();
+                    // drawing in full res here is kinda slow
+                    // we could switch to a lower resolution based on (canvas size / preview size) to make it run faster
+                    OneOf<Chunk, EmptyChunk> rendered = ChunkRenderer.MergeWholeStructure(chunk, ChunkResolution.Full, folder);
+                    if (rendered.IsT0)
+                    {
+                        memberVM.PreviewSurface.Canvas.DrawSurface(rendered.AsT0.Surface.SkiaSurface, pos, ReplacingPaint);
+                        rendered.AsT0.Dispose();
+                    }
+                    else
+                    {
+                        memberVM.PreviewSurface.Canvas.DrawRect(pos.X, pos.Y, ChunkResolution.Full.PixelSize(), ChunkResolution.Full.PixelSize(), ClearPaint);
+                    }
+                }
+                infos.Add(new PreviewDirty_RenderInfo(guid));
             }
+            memberVM.PreviewSurface.Canvas.Restore();
         }
     }
 
-    private List<IRenderInfo> Render(IReadOnlyList<IChangeInfo?> changes)
+    private void UpdateMaskPreviews(Dictionary<Guid, HashSet<VecI>> maskPreviewChunks, float scaling, List<IRenderInfo> infos)
     {
-        Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender = FindChunksToRerender(changes);
+        foreach (var (guid, chunks) in maskPreviewChunks)
+        {
+            var memberVM = helpers.StructureHelper.Find(guid);
+            if (memberVM is null || !memberVM.HasMask)
+                continue;
 
-        List<IRenderInfo> infos = new();
+            var member = helpers.Tracker.Document.FindMemberOrThrow(guid);
+            memberVM.MaskPreviewSurface!.Canvas.Save();
+            memberVM.MaskPreviewSurface.Canvas.Scale(scaling);
 
+            foreach (var chunk in chunks)
+            {
+                var pos = chunk * ChunkResolution.Full.PixelSize();
+                member.ReadOnlyMask!.DrawMostUpToDateChunkOn
+                    (chunk, ChunkResolution.Full, memberVM.MaskPreviewSurface, pos, ReplacingPaint);
+            }
+
+            memberVM.MaskPreviewSurface.Canvas.Restore();
+            infos.Add(new MaskPreviewDirty_RenderInfo(guid));
+        }
+    }
+
+    private void UpdateMainImage(Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender, List<IRenderInfo> infos)
+    {
         foreach (var (resolution, chunks) in chunksToRerender)
         {
             int chunkSize = resolution.PixelSize();
@@ -174,19 +206,20 @@ internal class WriteableBitmapUpdater
                     ));
             }
         }
-
-        return infos;
     }
 
     private void RenderChunk(VecI chunkPos, SKSurface screenSurface, ChunkResolution resolution)
     {
-        using Chunk renderedChunk = ChunkRenderer.RenderWholeStructure(chunkPos, resolution, helpers.Tracker.Document.ReadOnlyStructureRoot);
-
-        screenSurface.Canvas.DrawSurface(renderedChunk.Surface.SkiaSurface, chunkPos.Multiply(renderedChunk.PixelSize), ReplacingPaint);
-
-        if (helpers.Tracker.Document.ReadOnlySelection.ReadOnlyIsEmptyAndInactive)
-            return;
-
-        helpers.Tracker.Document.ReadOnlySelection.ReadOnlySelectionImage.DrawMostUpToDateChunkOn(chunkPos, resolution, screenSurface, chunkPos * resolution.PixelSize(), SelectionPaint);
+        ChunkRenderer.MergeWholeStructure(chunkPos, resolution, helpers.Tracker.Document.ReadOnlyStructureRoot).Switch(
+            (Chunk chunk) =>
+            {
+                screenSurface.Canvas.DrawSurface(chunk.Surface.SkiaSurface, chunkPos.Multiply(chunk.PixelSize), ReplacingPaint);
+                chunk.Dispose();
+            },
+            (EmptyChunk chunk) =>
+            {
+                var pos = chunkPos * resolution.PixelSize();
+                screenSurface.Canvas.DrawRect(pos.X, pos.Y, resolution.PixelSize(), resolution.PixelSize(), ClearPaint);
+            });
     }
 }

+ 2 - 3
src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs

@@ -45,6 +45,8 @@ internal class DocumentViewModel : INotifyPropertyChanged
         [ChunkResolution.Eighth] = new WriteableBitmap(8, 8, 96, 96, PixelFormats.Pbgra32, null),
     };
 
+    public Dictionary<ChunkResolution, SKSurface> Surfaces { get; set; } = new();
+
     public event PropertyChangedEventHandler? PropertyChanged;
 
     public void RaisePropertyChanged(string name)
@@ -90,9 +92,6 @@ internal class DocumentViewModel : INotifyPropertyChanged
         set => Helpers.ActionAccumulator.AddFinishedActions(new SetSymmetryAxisState_Action(SymmetryAxisDirection.Vertical, value));
     }
 
-    public Dictionary<ChunkResolution, SKSurface> Surfaces { get; set; } = new();
-
-
     public int ResizeWidth { get; set; }
     public int ResizeHeight { get; set; }
 

+ 31 - 12
src/PixiEditorPrototype/ViewModels/StructureMemberViewModel.cs

@@ -1,9 +1,13 @@
 using System;
 using System.ComponentModel;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using ChunkyImageLib.DataHolders;
 using PixiEditor.ChangeableDocument.Actions.Properties;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Enums;
 using PixiEditorPrototype.Models;
+using SkiaSharp;
 
 namespace PixiEditorPrototype.ViewModels;
 
@@ -42,21 +46,18 @@ internal abstract class StructureMemberViewModel : INotifyPropertyChanged
     public bool IsSelected { get; set; }
     public bool ShouldDrawOnMask { get; set; }
 
-    public float Opacity
-    {
-        get => member.Opacity;
-    }
+    public float Opacity => member.Opacity;
 
-    public Guid GuidValue
-    {
-        get => member.GuidValue;
-    }
+    public Guid GuidValue => member.GuidValue;
 
-    public bool HasMask
-    {
-        get => member.ReadOnlyMask is not null;
-    }
+    public bool HasMask => member.ReadOnlyMask is not null;
 
+    public const int PreviewSize = 48;
+    public WriteableBitmap PreviewBitmap { get; set; }
+    public SKSurface PreviewSurface { get; set; }
+
+    public WriteableBitmap? MaskPreviewBitmap { get; set; }
+    public SKSurface? MaskPreviewSurface { get; set; }
     public RelayCommand MoveUpCommand { get; }
     public RelayCommand MoveDownCommand { get; }
 
@@ -69,6 +70,15 @@ internal abstract class StructureMemberViewModel : INotifyPropertyChanged
         PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
     }
 
+    public static VecI CalculatePreviewSize(VecI docSize)
+    {
+        double proportions = docSize.Y / (double)docSize.X;
+        const int prSize = StructureMemberViewModel.PreviewSize;
+        return proportions > 1 ?
+            new VecI((int)Math.Round(prSize / proportions), prSize) :
+            new VecI(prSize, (int)Math.Round(prSize * proportions));
+    }
+
     public StructureMemberViewModel(DocumentViewModel doc, DocumentHelpers helpers, IReadOnlyStructureMember member)
     {
         this.member = member;
@@ -78,6 +88,15 @@ internal abstract class StructureMemberViewModel : INotifyPropertyChanged
         MoveDownCommand = new(_ => Helpers.StructureHelper.MoveStructureMember(GuidValue, true));
         UpdateOpacityCommand = new(UpdateOpacity);
         EndOpacityUpdateCommand = new(EndOpacityUpdate);
+
+        var previewSize = CalculatePreviewSize(new(doc.Width, doc.Height));
+        PreviewBitmap = new WriteableBitmap(previewSize.X, previewSize.Y, 96, 96, PixelFormats.Pbgra32, null);
+        PreviewSurface = SKSurface.Create(new SKImageInfo(previewSize.X, previewSize.Y, SKColorType.Bgra8888), PreviewBitmap.BackBuffer, PreviewBitmap.BackBufferStride);
+        if (member.ReadOnlyMask is not null)
+        {
+            MaskPreviewBitmap = new WriteableBitmap(previewSize.X, previewSize.Y, 96, 96, PixelFormats.Pbgra32, null);
+            MaskPreviewSurface = SKSurface.Create(new SKImageInfo(previewSize.X, previewSize.Y, SKColorType.Bgra8888), PreviewBitmap.BackBuffer, PreviewBitmap.BackBufferStride);
+        }
     }
 
     private void EndOpacityUpdate(object? opacity)

+ 22 - 3
src/PixiEditorPrototype/Views/MainWindow.xaml

@@ -81,7 +81,7 @@
                     </i:Interaction.Triggers>
                     <TreeView.Resources>
                         <HierarchicalDataTemplate DataType="{x:Type vm:FolderViewModel}" ItemsSource="{Binding Children}">
-                            <StackPanel Orientation="Horizontal" MinWidth="200">
+                            <StackPanel Orientation="Horizontal" MinWidth="200" Background="Wheat">
                                 <CheckBox VerticalAlignment="Center" HorizontalAlignment="Center" IsChecked="{Binding IsVisible}"/>
                                 <Rectangle 
                                     Fill="DarkRed" Width="8" Margin="3,0" 
@@ -90,7 +90,17 @@
                                     <Button Width="12" Command="{Binding MoveUpCommand}">^</Button>
                                     <Button Width="12" Command="{Binding MoveDownCommand}">v</Button>
                                 </StackPanel>
-                                <Border BorderBrush="Black" BorderThickness="1" Background="Yellow" Width="25" Height="25" Margin="3,0,0,0"/>
+                                <Border 
+                                    BorderBrush="Black" BorderThickness="1" MaxWidth="30" MaxHeight="30" 
+                                    HorizontalAlignment="Center" VerticalAlignment="Center" Margin="3,0,0,0">
+                                    <Image Source="{Binding PreviewBitmap}"></Image>
+                                </Border>
+                                <Border 
+                                    Visibility="{Binding HasMask, Converter={StaticResource BoolToVisibilityConverter}}"
+                                    BorderBrush="Black" BorderThickness="1" Background="White" MaxWidth="30" MaxHeight="30" 
+                                    HorizontalAlignment="Center" VerticalAlignment="Center" Margin="3,0,0,0">
+                                    <Image Source="{Binding MaskPreviewBitmap}"></Image>
+                                </Border>
                                 <StackPanel VerticalAlignment="Center">
                                     <DockPanel Margin="3, 0, 0, 0">
                                         <TextBlock Text="{Binding Opacity}" Width="25"/>
@@ -119,7 +129,16 @@
                                     <Button Width="12" Command="{Binding MoveUpCommand}">^</Button>
                                     <Button Width="12" Command="{Binding MoveDownCommand}">v</Button>
                                 </StackPanel>
-                                <Border BorderBrush="Black" BorderThickness="1" Background="White" Width="25" Height="25" Margin="3,0,0,0">
+                                <Border 
+                                    BorderBrush="Black" BorderThickness="1" Background="White" MaxWidth="30" MaxHeight="30" 
+                                    HorizontalAlignment="Center" VerticalAlignment="Center" Margin="3,0,0,0">
+                                    <Image Source="{Binding PreviewBitmap}"></Image>
+                                </Border>
+                                <Border 
+                                    Visibility="{Binding HasMask, Converter={StaticResource BoolToVisibilityConverter}}"
+                                    BorderBrush="Black" BorderThickness="1" Background="White" MaxWidth="30" MaxHeight="30" 
+                                    HorizontalAlignment="Center" VerticalAlignment="Center" Margin="3,0,0,0">
+                                    <Image Source="{Binding MaskPreviewBitmap}"></Image>
                                 </Border>
                                 <StackPanel VerticalAlignment="Center">
                                     <DockPanel Margin="3, 0, 0, 0">