Browse Source

Refactoring, ChunkyImage operation, Apply mask change

Equbuxu 3 years ago
parent
commit
227388b208
41 changed files with 406 additions and 144 deletions
  1. 49 9
      src/ChunkyImageLib/ChunkyImage.cs
  2. 1 1
      src/ChunkyImageLib/CommittedChunkStorage.cs
  3. 6 0
      src/ChunkyImageLib/DataHolders/VecD.cs
  4. 12 2
      src/ChunkyImageLib/DataHolders/VecI.cs
  5. 106 0
      src/ChunkyImageLib/Operations/ChunkyImageOperation.cs
  6. 2 0
      src/ChunkyImageLib/Operations/OperationHelper.cs
  7. 2 1
      src/ChunkyImageLib/Operations/RectangleOperation.cs
  8. 9 8
      src/ChunkyImageLibTest/ChunkyImageTests.cs
  9. 3 8
      src/PixiEditor.ChangeableDocument/Changes/Change.cs
  10. 87 0
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ApplyLayerMask_Change.cs
  11. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelection_Change.cs
  12. 3 3
      src/PixiEditor.ChangeableDocument/Changes/Drawing/CombineStructureMembersOnto_Change.cs
  13. 4 4
      src/PixiEditor.ChangeableDocument/Changes/Drawing/DrawRectangle_UpdateableChange.cs
  14. 1 1
      src/PixiEditor.ChangeableDocument/Changes/Drawing/DrawingChangeHelper.cs
  15. 1 1
      src/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFillChunkStorage.cs
  16. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFill_Change.cs
  17. 3 3
      src/PixiEditor.ChangeableDocument/Changes/Drawing/PasteImage_UpdateableChange.cs
  18. 12 6
      src/PixiEditor.ChangeableDocument/Changes/Drawing/SelectRectangle_UpdateableChange.cs
  19. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Properties/CreateStructureMemberMask_Change.cs
  20. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Properties/DeleteStructureMemberMask_Change.cs
  21. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Properties/LayerLockTransparency_Change.cs
  22. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberBlendMode_Change.cs
  23. 2 9
      src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberClipToMemberBelow_Change.cs
  24. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberIsVisible_Change.cs
  25. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberName_Change.cs
  26. 5 5
      src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberOpacity_UpdateableChange.cs
  27. 2 11
      src/PixiEditor.ChangeableDocument/Changes/Root/ResizeCanvas_Change.cs
  28. 4 4
      src/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisPosition_UpdateableChange.cs
  29. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisState_Change.cs
  30. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Structure/CreateStructureMember_Change.cs
  31. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Structure/DeleteStructureMember_Change.cs
  32. 2 2
      src/PixiEditor.ChangeableDocument/Changes/Structure/MoveStructureMember_Change.cs
  33. 2 5
      src/PixiEditor.ChangeableDocument/Changes/UpdateableChange.cs
  34. 40 24
      src/PixiEditor.ChangeableDocument/DocumentChangeTracker.cs
  35. 1 0
      src/PixiEditor.ChangeableDocument/GlobalUsings.cs
  36. 6 10
      src/PixiEditor.ChangeableDocument/Rendering/ChunkRenderer.cs
  37. 3 3
      src/PixiEditor.Zoombox/Zoombox.xaml.cs
  38. 2 2
      src/PixiEditorPrototype/Models/Rendering/AffectedChunkGatherer.cs
  39. 9 0
      src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs
  40. 3 0
      src/PixiEditorPrototype/Views/MainWindow.xaml
  41. 2 0
      src/README.md

+ 49 - 9
src/ChunkyImageLib/ChunkyImage.cs

@@ -49,7 +49,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     private object lockObject = new();
     private int commitCounter = 0;
 
-    public static int ChunkSize => ChunkPool.FullChunkSize;
+    public const int FullChunkSize = ChunkPool.FullChunkSize;
     private static SKPaint ClippingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.DstIn };
     private static SKPaint InverseClippingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.DstOut };
     private static SKPaint ReplacingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.Src };
@@ -101,6 +101,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             ChunkyImage output = new(LatestSize);
             var chunks = FindCommittedChunks();
             foreach (var chunk in chunks)
@@ -108,7 +109,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
                 var image = GetCommittedChunk(chunk, ChunkResolution.Full);
                 if (image is null)
                     continue;
-                output.EnqueueDrawImage(chunk * ChunkSize, image.Surface);
+                output.EnqueueDrawImage(chunk * FullChunkSize, image.Surface);
             }
             output.CommitChanges();
             return output;
@@ -122,6 +123,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             var latestChunk = GetLatestChunk(chunkPos, resolution);
             var committedChunk = GetCommittedChunk(chunkPos, resolution);
 
@@ -159,6 +161,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             if (MaybeGetLatestChunk(chunkPos, ChunkResolution.Full) is not null ||
                 MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is not null)
                 return true;
@@ -175,6 +178,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             var chunk = GetCommittedChunk(chunkPos, resolution);
             if (chunk is null)
                 return false;
@@ -187,6 +191,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             return MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is not null;
         }
     }
@@ -229,6 +234,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             if (queuedOperations.Count > 0)
                 throw new InvalidOperationException("This function can only be executed when there are no queued operations");
             activeClips.Add(clippingMask);
@@ -242,6 +248,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             if (queuedOperations.Count > 0)
                 throw new InvalidOperationException("This function can only be executed when there are no queued operations");
             blendMode = mode;
@@ -252,6 +259,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             if (queuedOperations.Count > 0)
                 throw new InvalidOperationException("This function can only be executed when there are no queued operations");
             horizontalSymmetryAxis = position;
@@ -262,6 +270,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             if (queuedOperations.Count > 0)
                 throw new InvalidOperationException("This function can only be executed when there are no queued operations");
             verticalSymmetryAxis = position;
@@ -272,6 +281,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             lockTransparency = true;
         }
     }
@@ -280,6 +290,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             RectangleOperation operation = new(rect);
             EnqueueOperation(operation);
         }
@@ -295,6 +306,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             ImageOperation operation = new(corners, image, copyImage);
             EnqueueOperation(operation);
         }
@@ -310,15 +322,27 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             ImageOperation operation = new(pos, image, copyImage);
             EnqueueOperation(operation);
         }
     }
 
+    public void EnqueueDrawChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ChunkyImageOperation operation = new(image, pos, flipHor, flipVer);
+            EnqueueOperation(operation);
+        }
+    }
+
     public void EnqueueClearRegion(VecI pos, VecI size)
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             ClearRegionOperation operation = new(pos, size);
             EnqueueOperation(operation);
         }
@@ -328,6 +352,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             ClearOperation operation = new();
             EnqueueOperation(operation, FindAllChunks());
         }
@@ -337,6 +362,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             ResizeOperation operation = new(newSize);
             LatestSize = newSize;
             EnqueueOperation(operation, FindAllChunksOutsideBounds(newSize));
@@ -374,9 +400,10 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             //clear queued operations
             foreach (var operation in queuedOperations)
-                operation.Item1.Dispose();
+                operation.operation.Dispose();
             queuedOperations.Clear();
 
             //clear additional state
@@ -407,6 +434,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             var affectedChunks = FindAffectedChunks();
             foreach (var chunk in affectedChunks)
             {
@@ -543,6 +571,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             var allChunks = committedChunks[ChunkResolution.Full].Select(chunk => chunk.Key).ToHashSet();
             foreach (var (_, opChunks) in queuedOperations)
             {
@@ -556,6 +585,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             return committedChunks[ChunkResolution.Full].Select(chunk => chunk.Key).ToHashSet();
         }
     }
@@ -567,6 +597,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             var chunks = new HashSet<VecI>();
             foreach (var (_, opChunks) in queuedOperations)
             {
@@ -642,7 +673,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         {
             if (mask.CommittedChunkExists(chunkPos))
             {
-                mask.DrawCommittedChunkOn(chunkPos, resolution, intersection.Surface.SkiaSurface, new(0, 0), ClippingPaint);
+                mask.DrawCommittedChunkOn(chunkPos, resolution, intersection.Surface.SkiaSurface, VecI.Zero, ClippingPaint);
             }
             else
             {
@@ -686,14 +717,14 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             var clip = combinedClips.AsT2;
 
             using var tempChunk = Chunk.Create(targetChunk.Resolution);
-            targetChunk.DrawOnSurface(tempChunk.Surface.SkiaSurface, new(0, 0), ReplacingPaint);
+            targetChunk.DrawOnSurface(tempChunk.Surface.SkiaSurface, VecI.Zero, ReplacingPaint);
 
             chunkOperation.DrawOnChunk(tempChunk, chunkPos);
 
-            clip.DrawOnSurface(tempChunk.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-            clip.DrawOnSurface(targetChunk.Surface.SkiaSurface, new(0, 0), InverseClippingPaint);
+            clip.DrawOnSurface(tempChunk.Surface.SkiaSurface, VecI.Zero, ClippingPaint);
+            clip.DrawOnSurface(targetChunk.Surface.SkiaSurface, VecI.Zero, InverseClippingPaint);
 
-            tempChunk.DrawOnSurface(targetChunk.Surface.SkiaSurface, new(0, 0), AddingPaint);
+            tempChunk.DrawOnSurface(targetChunk.Surface.SkiaSurface, VecI.Zero, AddingPaint);
             return false;
         }
 
@@ -712,6 +743,9 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
+            if (queuedOperations.Count > 0)
+                throw new InvalidOperationException("This function can only be used when there are no queued operations");
             FindAndDeleteEmptyCommittedChunks();
             return committedChunks[ChunkResolution.Full].Count == 0;
         }
@@ -726,7 +760,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
 
     private static bool IsOutsideBounds(VecI chunkPos, VecI imageSize)
     {
-        return chunkPos.X < 0 || chunkPos.Y < 0 || chunkPos.X * ChunkSize >= imageSize.X || chunkPos.Y * ChunkSize >= imageSize.Y;
+        return chunkPos.X < 0 || chunkPos.Y < 0 || chunkPos.X * FullChunkSize >= imageSize.X || chunkPos.Y * FullChunkSize >= imageSize.Y;
     }
 
     private void FindAndDeleteEmptyCommittedChunks()
@@ -836,6 +870,12 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         return newLatestChunk;
     }
 
+    private void ThrowIfDisposed()
+    {
+        if (disposed)
+            throw new ObjectDisposedException(nameof(ChunkyImage));
+    }
+
     public void Dispose()
     {
         lock (lockObject)

+ 1 - 1
src/ChunkyImageLib/CommittedChunkStorage.cs

@@ -14,7 +14,7 @@ public class CommittedChunkStorage : IDisposable
         foreach (var chunkPos in committedChunksToSave)
         {
             Chunk copy = Chunk.Create();
-            if (!image.DrawCommittedChunkOn(chunkPos, ChunkResolution.Full, copy.Surface.SkiaSurface, new(0, 0), ReplacingPaint))
+            if (!image.DrawCommittedChunkOn(chunkPos, ChunkResolution.Full, copy.Surface.SkiaSurface, VecI.Zero, ReplacingPaint))
             {
                 copy.Dispose();
                 savedChunks.Add((chunkPos, null));

+ 6 - 0
src/ChunkyImageLib/DataHolders/VecD.cs

@@ -14,6 +14,8 @@ public struct VecD
     public double LongestAxis => (Math.Abs(X) < Math.Abs(Y)) ? Y : X;
     public double ShortestAxis => (Math.Abs(X) < Math.Abs(Y)) ? X : Y;
 
+    public static VecD Zero { get; } = new(0, 0);
+
     public VecD(double x, double y)
     {
         X = x;
@@ -163,6 +165,10 @@ public struct VecD
     {
         return new VecD(a.X / b, a.Y / b);
     }
+    public static VecD operator %(VecD a, double b)
+    {
+        return new(a.X % b, a.Y % b);
+    }
     public static bool operator ==(VecD a, VecD b)
     {
         return a.X == b.X && a.Y == b.Y;

+ 12 - 2
src/ChunkyImageLib/DataHolders/VecI.cs

@@ -13,6 +13,8 @@ public struct VecI
     public int LongestAxis => (Math.Abs(X) < Math.Abs(Y)) ? Y : X;
     public int ShortestAxis => (Math.Abs(X) < Math.Abs(Y)) ? X : Y;
 
+    public static VecI Zero { get; } = new(0, 0);
+
     public VecI(int x, int y)
     {
         X = x;
@@ -28,14 +30,14 @@ public struct VecI
         return new VecI(X * other.X, Y * other.Y);
     }
     /// <summary>
-    /// Reflects the vector across a vertical line with the specified position
+    /// Reflects the vector across a vertical line with the specified x position
     /// </summary>
     public VecI ReflectX(int lineX)
     {
         return new(2 * lineX - X, Y);
     }
     /// <summary>
-    /// Reflects the vector along a horizontal line with the specified position
+    /// Reflects the vector across a horizontal line with the specified y position
     /// </summary>
     public VecI ReflectY(int lineY)
     {
@@ -77,6 +79,14 @@ public struct VecI
     {
         return new VecD(a.X / b, a.Y / b);
     }
+    public static VecI operator %(VecI a, int b)
+    {
+        return new(a.X % b, a.Y % b);
+    }
+    public static VecD operator %(VecI a, double b)
+    {
+        return new(a.X % b, a.Y % b);
+    }
     public static bool operator ==(VecI a, VecI b)
     {
         return a.X == b.X && a.Y == b.Y;

+ 106 - 0
src/ChunkyImageLib/Operations/ChunkyImageOperation.cs

@@ -0,0 +1,106 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class ChunkyImageOperation : IDrawOperation
+{
+    private readonly ChunkyImage imageToDraw;
+    private readonly VecI pos;
+    private readonly bool mirrorHorizontal;
+    private readonly bool mirrorVertical;
+
+    public bool IgnoreEmptyChunks => false;
+
+    public ChunkyImageOperation(ChunkyImage imageToDraw, VecI pos, bool mirrorHorizontal, bool mirrorVertical)
+    {
+        this.imageToDraw = imageToDraw;
+        this.pos = pos;
+        this.mirrorHorizontal = mirrorHorizontal;
+        this.mirrorVertical = mirrorVertical;
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        chunk.Surface.SkiaSurface.Canvas.Save();
+
+        {
+            VecI pixelPos = chunkPos * ChunkyImage.FullChunkSize;
+            VecI topLeft = GetTopLeft();
+            SKRect clippingRect = SKRect.Create(
+                OperationHelper.ConvertForResolution(topLeft - pixelPos, chunk.Resolution),
+                OperationHelper.ConvertForResolution(imageToDraw.CommittedSize, chunk.Resolution));
+            chunk.Surface.SkiaSurface.Canvas.ClipRect(clippingRect);
+        }
+
+        if (mirrorHorizontal)
+        {
+            chunkPos.X = (-((chunkPos.X * ChunkyImage.FullChunkSize) - pos.X) + pos.X) / ChunkyImage.FullChunkSize - 1;
+            chunk.Surface.SkiaSurface.Canvas.Translate(chunk.PixelSize.X, 0);
+            chunk.Surface.SkiaSurface.Canvas.Scale(-1, 0);
+        }
+        if (mirrorVertical)
+        {
+            chunkPos.Y = (-((chunkPos.Y * ChunkyImage.FullChunkSize) - pos.Y) + pos.Y) / ChunkyImage.FullChunkSize - 1;
+            chunk.Surface.SkiaSurface.Canvas.Translate(0, chunk.PixelSize.Y);
+            chunk.Surface.SkiaSurface.Canvas.Scale(0, -1);
+        }
+
+        VecD posOnImage = chunkPos - (pos / (double)ChunkyImage.FullChunkSize);
+        int topY = (int)Math.Floor(posOnImage.Y);
+        int bottomY = (int)Math.Ceiling(posOnImage.Y);
+        int leftX = (int)Math.Floor(posOnImage.X);
+        int rightX = (int)Math.Ceiling(posOnImage.X);
+
+        // this is kinda dumb
+        if (pos % ChunkyImage.FullChunkSize == VecI.Zero)
+        {
+            imageToDraw.DrawCommittedChunkOn((VecI)posOnImage, chunk.Resolution, chunk.Surface.SkiaSurface, VecI.Zero);
+        }
+        else if (pos.X % ChunkyImage.FullChunkSize == 0)
+        {
+            imageToDraw.DrawCommittedChunkOn(new VecI((int)posOnImage.X, topY), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI(0, (int)(topY - posOnImage.Y)));
+            imageToDraw.DrawCommittedChunkOn(new VecI((int)posOnImage.X, bottomY), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI(0, (int)(bottomY - posOnImage.Y)));
+        }
+        else if (pos.Y % ChunkyImage.FullChunkSize == 0)
+        {
+            imageToDraw.DrawCommittedChunkOn(new VecI(leftX, (int)posOnImage.Y), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI((int)(leftX - posOnImage.X), 0));
+            imageToDraw.DrawCommittedChunkOn(new VecI(rightX, (int)posOnImage.Y), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI((int)(rightX - posOnImage.X), 0));
+        }
+        else
+        {
+            imageToDraw.DrawCommittedChunkOn(new VecI(leftX, topY), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI((int)(leftX - posOnImage.X), (int)(topY - posOnImage.Y)));
+            imageToDraw.DrawCommittedChunkOn(new VecI(rightX, topY), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI((int)(rightX - posOnImage.X), (int)(topY - posOnImage.Y)));
+            imageToDraw.DrawCommittedChunkOn(new VecI(leftX, bottomY), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI((int)(leftX - posOnImage.X), (int)(bottomY - posOnImage.Y)));
+            imageToDraw.DrawCommittedChunkOn(new VecI(rightX, bottomY), chunk.Resolution, chunk.Surface.SkiaSurface, new VecI((int)(rightX - posOnImage.X), (int)(bottomY - posOnImage.Y)));
+        }
+
+        chunk.Surface.SkiaSurface.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return OperationHelper.FindChunksFullyInsideRectangle(GetTopLeft(), imageToDraw.CommittedSize, ChunkyImage.FullChunkSize);
+    }
+
+    private VecI GetTopLeft()
+    {
+        VecI topLeft = pos;
+        if (mirrorHorizontal)
+            topLeft.X -= imageToDraw.LatestSize.X;
+        if (mirrorVertical)
+            topLeft.Y -= imageToDraw.LatestSize.Y;
+        return topLeft;
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        var newPos = pos;
+        if (verAxisX is not null)
+            newPos = newPos.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newPos = newPos.ReflectY((int)horAxisY);
+        return new ChunkyImageOperation(imageToDraw, newPos, mirrorHorizontal ^ (verAxisX is not null), mirrorVertical ^ (horAxisY is not null));
+    }
+
+    public void Dispose() { }
+}

+ 2 - 0
src/ChunkyImageLib/Operations/OperationHelper.cs

@@ -77,6 +77,8 @@ public static class OperationHelper
 
     public static SKMatrix CreateMatrixFromPoints(ShapeCorners corners, VecD size)
         => CreateMatrixFromPoints((SKPoint)corners.TopLeft, (SKPoint)corners.TopRight, (SKPoint)corners.BottomRight, (SKPoint)corners.BottomLeft, (float)size.X, (float)size.Y);
+
+    // see https://stackoverflow.com/questions/48416118/perspective-transform-in-skia/72364829#72364829
     public static SKMatrix CreateMatrixFromPoints(SKPoint topLeft, SKPoint topRight, SKPoint botRight, SKPoint botLeft, float width, float height)
     {
         (float x1, float y1) = (topLeft.X, topLeft.Y);

+ 2 - 1
src/ChunkyImageLib/Operations/RectangleOperation.cs

@@ -17,7 +17,6 @@ internal class RectangleOperation : IDrawOperation
     public void DrawOnChunk(Chunk chunk, VecI chunkPos)
     {
         var skiaSurf = chunk.Surface.SkiaSurface;
-        // use a clipping rectangle with 2x stroke width to make sure stroke doesn't stick outside rect bounds
         skiaSurf.Canvas.Save();
 
         var convertedPos = OperationHelper.ConvertForResolution(-Data.Size.Abs() / 2, chunk.Resolution);
@@ -30,6 +29,8 @@ internal class RectangleOperation : IDrawOperation
 
         skiaSurf.Canvas.Translate((SKPoint)convertedCenter);
         skiaSurf.Canvas.RotateRadians((float)Data.Angle);
+
+        // use a clipping rectangle with 2x stroke width to make sure stroke doesn't stick outside rect bounds
         skiaSurf.Canvas.ClipRect(rect);
 
         // draw fill

+ 9 - 8
src/ChunkyImageLibTest/ChunkyImageTests.cs

@@ -1,4 +1,5 @@
 using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
 using SkiaSharp;
 using Xunit;
 
@@ -8,24 +9,24 @@ public class ChunkyImageTests
     [Fact]
     public void ChunkyImage_Dispose_ReturnsAllChunks()
     {
-        ChunkyImage image = new ChunkyImage(new(ChunkyImage.ChunkSize, ChunkyImage.ChunkSize));
+        ChunkyImage image = new ChunkyImage(new(ChunkyImage.FullChunkSize, ChunkyImage.FullChunkSize));
         image.EnqueueDrawRectangle(new(new(5, 5), new(80, 80), 0, 2, SKColors.AliceBlue, SKColors.Snow));
         using (Chunk target = Chunk.Create())
         {
-            image.DrawMostUpToDateChunkOn(new(0, 0), ChunkyImageLib.DataHolders.ChunkResolution.Full, target.Surface.SkiaSurface, new(0, 0));
+            image.DrawMostUpToDateChunkOn(new(0, 0), ChunkyImageLib.DataHolders.ChunkResolution.Full, target.Surface.SkiaSurface, VecI.Zero);
             image.CancelChanges();
-            image.EnqueueResize(new(ChunkyImage.ChunkSize * 4, ChunkyImage.ChunkSize * 4));
-            image.EnqueueDrawRectangle(new(new(0, 0), image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow, SKBlendMode.Multiply));
+            image.EnqueueResize(new(ChunkyImage.FullChunkSize * 4, ChunkyImage.FullChunkSize * 4));
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow, SKBlendMode.Multiply));
             image.CommitChanges();
             image.SetBlendMode(SKBlendMode.Overlay);
-            image.EnqueueDrawRectangle(new(new(0, 0), image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow, SKBlendMode.Multiply));
-            image.EnqueueDrawRectangle(new(new(0, 0), image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow, SKBlendMode.Multiply));
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
             image.CommitChanges();
             image.SetBlendMode(SKBlendMode.Screen);
-            image.EnqueueDrawRectangle(new(new(0, 0), image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
             image.CancelChanges();
             image.SetBlendMode(SKBlendMode.SrcOver);
-            image.EnqueueDrawRectangle(new(new(0, 0), image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
         }
         image.Dispose();
 

+ 3 - 8
src/PixiEditor.ChangeableDocument/Changes/Change.cs

@@ -1,15 +1,10 @@
-using OneOf;
-using OneOf.Types;
-using PixiEditor.ChangeableDocument.Changeables;
-using PixiEditor.ChangeableDocument.ChangeInfos;
-
-namespace PixiEditor.ChangeableDocument.Changes;
+namespace PixiEditor.ChangeableDocument.Changes;
 
 internal abstract class Change : IDisposable
 {
     public virtual bool IsMergeableWith(Change other) => false;
     public abstract OneOf<Success, Error> InitializeAndValidate(Document target);
-    public abstract IChangeInfo? Apply(Document target, out bool ignoreInUndo);
-    public abstract IChangeInfo? Revert(Document target);
+    public abstract OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo);
+    public abstract OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target);
     public virtual void Dispose() { }
 };

+ 87 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/ApplyLayerMask_Change.cs

@@ -0,0 +1,87 @@
+using PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+internal class ApplyLayerMask_Change : Change
+{
+    private readonly Guid layerGuid;
+    private CommittedChunkStorage? savedMask;
+    private CommittedChunkStorage? savedLayer;
+
+    [GenerateMakeChangeAction]
+    public ApplyLayerMask_Change(Guid layerGuid)
+    {
+        this.layerGuid = layerGuid;
+    }
+
+    public override OneOf<Success, Error> InitializeAndValidate(Document target)
+    {
+        var member = target.FindMember(layerGuid);
+        if (member is not Layer layer || layer.Mask is null)
+            return new Error();
+
+        savedLayer = new CommittedChunkStorage(layer.LayerImage, layer.LayerImage.FindCommittedChunks());
+        savedMask = new CommittedChunkStorage(layer.Mask, layer.Mask.FindCommittedChunks());
+        return new Success();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
+    {
+        var layer = (Layer)target.FindMemberOrThrow(layerGuid);
+        if (layer.Mask is null)
+            throw new InvalidOperationException("Cannot apply layer mask, no mask");
+
+        ChunkyImage newLayerImage = new ChunkyImage(target.Size);
+        newLayerImage.AddRasterClip(layer.Mask);
+        newLayerImage.EnqueueDrawChunkyImage(VecI.Zero, layer.LayerImage);
+        newLayerImage.CommitChanges();
+
+        var affectedChunks = layer.LayerImage.FindAllChunks();
+        // use a temp value to ensure that LayerImage always stays in a valid state
+        var toDispose = layer.LayerImage;
+        layer.LayerImage = newLayerImage;
+        toDispose.Dispose();
+
+        var toDisposeMask = layer.Mask;
+        layer.Mask = null;
+        toDisposeMask.Dispose();
+
+        ignoreInUndo = false;
+        return new List<IChangeInfo>
+        {
+            new StructureMemberMask_ChangeInfo() { GuidValue = layerGuid },
+            new LayerImageChunks_ChangeInfo() { GuidValue = layerGuid, Chunks = affectedChunks }
+        };
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        var layer = (Layer)target.FindMemberOrThrow(layerGuid);
+        if (layer.Mask is not null)
+            throw new InvalidOperationException("Cannot restore layer mask, it already has one");
+        if (savedLayer is null || savedMask is null)
+            throw new InvalidOperationException("Cannot restore layer mask, no saved data");
+
+        ChunkyImage newMask = new ChunkyImage(target.Size);
+        savedMask.ApplyChunksToImage(newMask);
+        var affectedChunksMask = newMask.FindAffectedChunks();
+        newMask.CommitChanges();
+        layer.Mask = newMask;
+
+        savedLayer.ApplyChunksToImage(layer.LayerImage);
+        var affectedChunksLayer = layer.LayerImage.FindAffectedChunks();
+        layer.LayerImage.CommitChanges();
+
+        return new List<IChangeInfo>
+        {
+            new StructureMemberMask_ChangeInfo() { GuidValue = layerGuid },
+            new LayerImageChunks_ChangeInfo() { GuidValue = layerGuid, Chunks = affectedChunksLayer },
+            new MaskChunks_ChangeInfo() { GuidValue = layerGuid, Chunks = affectedChunksMask }
+        };
+    }
+
+    public override void Dispose()
+    {
+        savedLayer?.Dispose();
+        savedMask?.Dispose();
+    }
+}

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

@@ -20,7 +20,7 @@ internal class ClearSelection_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         target.Selection.IsEmptyAndInactive = true;
 
@@ -36,7 +36,7 @@ internal class ClearSelection_Change : Change
         return new Selection_ChangeInfo() { Chunks = affChunks };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         target.Selection.IsEmptyAndInactive = false;
 

+ 3 - 3
src/PixiEditor.ChangeableDocument/Changes/Drawing/CombineStructureMembersOnto_Change.cs

@@ -47,7 +47,7 @@ internal class CombineStructureMembersOnto_Change : Change
         }
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         Layer toDrawOn = (Layer)target.FindMemberOrThrow(targetLayer);
 
@@ -64,7 +64,7 @@ internal class CombineStructureMembersOnto_Change : Change
             OneOf<Chunk, EmptyChunk> combined = ChunkRenderer.MergeChosenMembers(chunk, ChunkResolution.Full, target.StructureRoot, layersToCombine);
             if (combined.IsT0)
             {
-                toDrawOn.LayerImage.EnqueueDrawImage(chunk * ChunkyImage.ChunkSize, combined.AsT0.Surface);
+                toDrawOn.LayerImage.EnqueueDrawImage(chunk * ChunkyImage.FullChunkSize, combined.AsT0.Surface);
                 combined.AsT0.Surface.Dispose();
             }
         }
@@ -80,7 +80,7 @@ internal class CombineStructureMembersOnto_Change : Change
         };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         Layer toDrawOn = (Layer)target.FindMemberOrThrow(targetLayer);
 

+ 4 - 4
src/PixiEditor.ChangeableDocument/Changes/Drawing/DrawRectangle_UpdateableChange.cs

@@ -42,14 +42,14 @@ internal class DrawRectangle_UpdateableChange : UpdateableChange
         return affectedChunks;
     }
 
-    public override IChangeInfo? ApplyTemporarily(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
         ChunkyImage targetImage = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
         var chunks = UpdateRectangle(target, targetImage);
         return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, chunks, drawOnMask);
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         ChunkyImage targetImage = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
         var affectedChunks = UpdateRectangle(target, targetImage);
@@ -60,14 +60,14 @@ internal class DrawRectangle_UpdateableChange : UpdateableChange
         return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affectedChunks, drawOnMask);
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         ChunkyImage targetImage = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
         storedChunks!.ApplyChunksToImage(targetImage);
         storedChunks.Dispose();
         storedChunks = null;
 
-        IChangeInfo changes = DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, targetImage.FindAffectedChunks(), drawOnMask);
+        var changes = DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, targetImage.FindAffectedChunks(), drawOnMask);
         targetImage.CommitChanges();
         return changes;
     }

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

@@ -46,7 +46,7 @@ internal static class DrawingChangeHelper
         return true;
     }
 
-    public static IChangeInfo CreateChunkChangeInfo(Guid memberGuid, HashSet<VecI> affectedChunks, bool drawOnMask)
+    public static OneOf<None, IChangeInfo, List<IChangeInfo>> CreateChunkChangeInfo(Guid memberGuid, HashSet<VecI> affectedChunks, bool drawOnMask)
     {
         return drawOnMask switch
         {

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFillChunkStorage.cs

@@ -22,7 +22,7 @@ internal class FloodFillChunkStorage : IDisposable
         if (acquiredChunks.ContainsKey(pos))
             return acquiredChunks[pos];
         Chunk chunk = Chunk.Create(ChunkResolution.Full);
-        if (!image.DrawMostUpToDateChunkOn(pos, ChunkResolution.Full, chunk.Surface.SkiaSurface, new(0, 0), ReplacingPaint))
+        if (!image.DrawMostUpToDateChunkOn(pos, ChunkResolution.Full, chunk.Surface.SkiaSurface, VecI.Zero, ReplacingPaint))
             chunk.Surface.SkiaSurface.Canvas.Clear();
         acquiredChunks[pos] = chunk;
         return chunk;

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFill_Change.cs

@@ -27,7 +27,7 @@ internal class FloodFill_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
 
@@ -38,7 +38,7 @@ internal class FloodFill_Change : Change
         return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affectedChunks, drawOnMask);
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         if (chunkStorage is null)
             throw new InvalidOperationException("No saved chunks to revert to");

+ 3 - 3
src/PixiEditor.ChangeableDocument/Changes/Drawing/PasteImage_UpdateableChange.cs

@@ -45,7 +45,7 @@ internal class PasteImage_UpdateableChange : UpdateableChange
         return affectedChunks;
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         ChunkyImage targetImage = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
         var chunks = DrawImage(target, targetImage);
@@ -57,13 +57,13 @@ internal class PasteImage_UpdateableChange : UpdateableChange
         return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, chunks, drawOnMask);
     }
 
-    public override IChangeInfo? ApplyTemporarily(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
         ChunkyImage targetImage = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
         return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, DrawImage(target, targetImage), drawOnMask);
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         if (savedChunks is null)
             throw new InvalidOperationException("No saved chunks to restore");

+ 12 - 6
src/PixiEditor.ChangeableDocument/Changes/Drawing/SelectRectangle_UpdateableChange.cs

@@ -30,7 +30,7 @@ internal class SelectRectangle_UpdateableChange : UpdateableChange
         this.size = size;
     }
 
-    public override IChangeInfo? ApplyTemporarily(Document target)
+    private (Selection_ChangeInfo info, HashSet<VecI> chunks) CommonApply(Document target)
     {
         var oldChunks = target.Selection.SelectionImage.FindAffectedChunks();
         target.Selection.SelectionImage.CancelChanges();
@@ -48,20 +48,26 @@ internal class SelectRectangle_UpdateableChange : UpdateableChange
         target.Selection.SelectionPath = originalPath!.Op(rect, SKPathOp.Union);
 
         oldChunks.UnionWith(target.Selection.SelectionImage.FindAffectedChunks());
-        return new Selection_ChangeInfo() { Chunks = oldChunks };
+        return (new Selection_ChangeInfo() { Chunks = oldChunks }, oldChunks);
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
-        var changes = ApplyTemporarily(target);
-        originalSelectionState = new CommittedChunkStorage(target.Selection.SelectionImage, ((Selection_ChangeInfo)changes!).Chunks!);
+        var (info, _) = CommonApply(target);
+        return info;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
+    {
+        var (changes, affChunks) = CommonApply(target);
+        originalSelectionState = new CommittedChunkStorage(target.Selection.SelectionImage, affChunks);
         target.Selection.SelectionImage.CommitChanges();
         target.Selection.IsEmptyAndInactive = target.Selection.SelectionImage.CheckIfCommittedIsEmpty();
         ignoreInUndo = false;
         return changes;
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         target.Selection.IsEmptyAndInactive = originalIsEmpty;
         originalSelectionState!.ApplyChunksToImage(target.Selection.SelectionImage);

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

@@ -20,7 +20,7 @@ internal class CreateStructureMemberMask_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         var member = target.FindMemberOrThrow(targetMember);
         if (member.Mask is not null)
@@ -31,7 +31,7 @@ internal class CreateStructureMemberMask_Change : Change
         return new StructureMemberMask_ChangeInfo() { GuidValue = targetMember };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         var member = target.FindMemberOrThrow(targetMember);
         if (member.Mask is null)

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

@@ -22,7 +22,7 @@ internal class DeleteStructureMemberMask_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         var member = target.FindMemberOrThrow(memberGuid);
         if (member.Mask is null)
@@ -34,7 +34,7 @@ internal class DeleteStructureMemberMask_Change : Change
         return new StructureMemberMask_ChangeInfo() { GuidValue = memberGuid };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         var member = target.FindMemberOrThrow(memberGuid);
         if (member.Mask is not null)

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

@@ -25,14 +25,14 @@ internal class LayerLockTransparency_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         ((Layer)target.FindMemberOrThrow(layerGuid)).LockTransparency = newValue;
         ignoreInUndo = false;
         return new LayerLockTransparency_ChangeInfo() { GuidValue = layerGuid };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         ((Layer)target.FindMemberOrThrow(layerGuid)).LockTransparency = originalValue;
         return new LayerLockTransparency_ChangeInfo() { GuidValue = layerGuid };

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

@@ -24,7 +24,7 @@ internal class StructureMemberBlendMode_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         var member = target.FindMemberOrThrow(targetGuid);
         member.BlendMode = newBlendMode;
@@ -32,7 +32,7 @@ internal class StructureMemberBlendMode_Change : Change
         return new StructureMemberBlendMode_ChangeInfo() { GuidValue = targetGuid };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         var member = target.FindMemberOrThrow(targetGuid);
         member.BlendMode = originalBlendMode;

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

@@ -23,23 +23,16 @@ internal class StructureMemberClipToMemberBelow_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
-        if (originalValue == newValue)
-        {
-            ignoreInUndo = true;
-            return null;
-        }
         var member = target.FindMemberOrThrow(memberGuid);
         member.ClipToMemberBelow = newValue;
         ignoreInUndo = false;
         return new StructureMemberClipToMemberBelow_ChangeInfo() { GuidValue = memberGuid };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
-        if (originalValue == newValue)
-            return null;
         var member = target.FindMemberOrThrow(memberGuid);
         member.ClipToMemberBelow = originalValue;
         return new StructureMemberClipToMemberBelow_ChangeInfo() { GuidValue = memberGuid };

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

@@ -23,7 +23,7 @@ internal class StructureMemberIsVisible_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         // don't record layer/folder visibility changes - it's just more convenient this way
         ignoreInUndo = true;
@@ -31,7 +31,7 @@ internal class StructureMemberIsVisible_Change : Change
         return new StructureMemberIsVisible_ChangeInfo() { GuidValue = targetMember };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         target.FindMemberOrThrow(targetMember).IsVisible = originalIsVisible!.Value;
         return new StructureMemberIsVisible_ChangeInfo() { GuidValue = targetMember };

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

@@ -24,7 +24,7 @@ internal class StructureMemberName_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         target.FindMemberOrThrow(targetMember).Name = newName;
 
@@ -32,7 +32,7 @@ internal class StructureMemberName_Change : Change
         return new StructureMemberName_ChangeInfo() { GuidValue = targetMember };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         if (originalName is null)
             throw new InvalidOperationException("No name to revert to");

+ 5 - 5
src/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberOpacity_UpdateableChange.cs

@@ -31,14 +31,14 @@ internal class StructureMemberOpacity_UpdateableChange : UpdateableChange
         return new Success();
     }
 
-    public override IChangeInfo? ApplyTemporarily(Document target) => Apply(target, out _);
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target) => Apply(target, out _);
 
-    public override IChangeInfo? Apply(Document document, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document document, out bool ignoreInUndo)
     {
         if (originalOpacity == newOpacity)
         {
             ignoreInUndo = true;
-            return null;
+            return new None();
         }
 
         var member = document.FindMemberOrThrow(memberGuid);
@@ -48,10 +48,10 @@ internal class StructureMemberOpacity_UpdateableChange : UpdateableChange
         return new StructureMemberOpacity_ChangeInfo() { GuidValue = memberGuid };
     }
 
-    public override IChangeInfo? Revert(Document document)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document document)
     {
         if (originalOpacity == newOpacity)
-            return null;
+            return new None();
 
         var member = document.FindMemberOrThrow(memberGuid);
         member.Opacity = originalOpacity;

+ 2 - 11
src/PixiEditor.ChangeableDocument/Changes/Root/ResizeCanvas_Change.cs

@@ -40,14 +40,8 @@ internal class ResizeCanvas_Change : Change
         }
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
-        if (originalSize == newSize)
-        {
-            ignoreInUndo = true;
-            return null;
-        }
-
         target.Size = newSize;
         target.VerticalSymmetryAxisX = Math.Clamp(originalVerAxisX, 0, target.Size.X);
         target.HorizontalSymmetryAxisY = Math.Clamp(originalHorAxisY, 0, target.Size.Y);
@@ -74,11 +68,8 @@ internal class ResizeCanvas_Change : Change
         return new Size_ChangeInfo();
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
-        if (originalSize == newSize)
-            return null;
-
         target.Size = originalSize;
         ForEachLayer(target.StructureRoot, (layer) =>
         {

+ 4 - 4
src/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisPosition_UpdateableChange.cs

@@ -42,23 +42,23 @@ internal class SymmetryAxisPosition_UpdateableChange : UpdateableChange
             throw new NotImplementedException();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         ignoreInUndo = originalPos == newPos;
         SetPosition(target, newPos);
         return new SymmetryAxisPosition_ChangeInfo() { Direction = direction };
     }
 
-    public override IChangeInfo? ApplyTemporarily(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
     {
         SetPosition(target, newPos);
         return new SymmetryAxisPosition_ChangeInfo() { Direction = direction };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         if (originalPos == newPos)
-            return null;
+            return new None();
         SetPosition(target, originalPos);
         return new SymmetryAxisPosition_ChangeInfo() { Direction = direction };
     }

+ 2 - 2
src/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisState_Change.cs

@@ -38,14 +38,14 @@ internal class SymmetryAxisState_Change : Change
             throw new NotImplementedException();
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         SetState(target, newEnabled);
         ignoreInUndo = false;
         return new SymmetryAxisState_ChangeInfo() { Direction = direction };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         SetState(target, originalEnabled);
         return new SymmetryAxisState_ChangeInfo() { Direction = direction };

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

@@ -27,7 +27,7 @@ internal class CreateStructureMember_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo Apply(Document document, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document document, out bool ignoreInUndo)
     {
         var folder = (Folder)document.FindMemberOrThrow(parentFolderGuid);
 
@@ -44,7 +44,7 @@ internal class CreateStructureMember_Change : Change
         return new CreateStructureMember_ChangeInfo() { GuidValue = newMemberGuid };
     }
 
-    public override IChangeInfo Revert(Document document)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document document)
     {
         var folder = (Folder)document.FindMemberOrThrow(parentFolderGuid);
         var child = document.FindMemberOrThrow(newMemberGuid);

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

@@ -27,7 +27,7 @@ internal class DeleteStructureMember_Change : Change
         return new Success();
     }
 
-    public override IChangeInfo Apply(Document document, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document document, out bool ignoreInUndo)
     {
         var (member, parent) = document.FindChildAndParentOrThrow(memberGuid);
         parent.Children = parent.Children.Remove(member);
@@ -36,7 +36,7 @@ internal class DeleteStructureMember_Change : Change
         return new DeleteStructureMember_ChangeInfo() { GuidValue = memberGuid, ParentGuid = parentGuid };
     }
 
-    public override IChangeInfo Revert(Document doc)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document doc)
     {
         var parent = (Folder)doc.FindMemberOrThrow(parentGuid);
 

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

@@ -40,14 +40,14 @@ internal class MoveStructureMember_Change : Change
         targetFolder.Children = targetFolder.Children.Insert(targetIndex, member);
     }
 
-    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
     {
         Move(target, memberGuid, targetFolderGuid, targetFolderIndex);
         ignoreInUndo = false;
         return new MoveStructureMember_ChangeInfo() { GuidValue = memberGuid, ParentFromGuid = originalFolderGuid, ParentToGuid = targetFolderGuid };
     }
 
-    public override IChangeInfo? Revert(Document target)
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
     {
         Move(target, memberGuid, originalFolderGuid, originalFolderIndex);
         return new MoveStructureMember_ChangeInfo() { GuidValue = memberGuid, ParentFromGuid = targetFolderGuid, ParentToGuid = originalFolderGuid };

+ 2 - 5
src/PixiEditor.ChangeableDocument/Changes/UpdateableChange.cs

@@ -1,9 +1,6 @@
-using PixiEditor.ChangeableDocument.Changeables;
-using PixiEditor.ChangeableDocument.ChangeInfos;
-
-namespace PixiEditor.ChangeableDocument.Changes;
+namespace PixiEditor.ChangeableDocument.Changes;
 
 internal abstract class UpdateableChange : Change
 {
-    public abstract IChangeInfo? ApplyTemporarily(Document target);
+    public abstract OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target);
 }

+ 40 - 24
src/PixiEditor.ChangeableDocument/DocumentChangeTracker.cs

@@ -92,39 +92,49 @@ public class DocumentChangeTracker : IDisposable
         return true;
     }
 
-    private List<IChangeInfo?> Undo()
+    private List<IChangeInfo> Undo()
     {
         if (undoStack.Count == 0)
-            return new List<IChangeInfo?>();
+            return new List<IChangeInfo>();
         if (activePacket is not null || activeUpdateableChange is not null)
         {
             Trace.WriteLine("Attempted to undo while there is an active updateable change or an unfinished undo packet");
-            return new List<IChangeInfo?>();
+            return new List<IChangeInfo>();
         }
-        List<IChangeInfo?> changeInfos = new();
+        List<IChangeInfo> changeInfos = new();
         List<Change> changePacket = undoStack.Pop();
 
         for (int i = changePacket.Count - 1; i >= 0; i--)
-            changeInfos.Add(changePacket[i].Revert(document));
+        {
+            changePacket[i].Revert(document).Switch(
+                (None _) => { },
+                (IChangeInfo info) => changeInfos.Add(info),
+                (List<IChangeInfo> infos) => changeInfos.AddRange(infos));
+        }
 
         redoStack.Push(changePacket);
         return changeInfos;
     }
 
-    private List<IChangeInfo?> Redo()
+    private List<IChangeInfo> Redo()
     {
         if (redoStack.Count == 0)
-            return new List<IChangeInfo?>();
+            return new List<IChangeInfo>();
         if (activePacket is not null || activeUpdateableChange is not null)
         {
             Trace.WriteLine("Attempted to redo while there is an active updateable change or an unfinished undo packet");
-            return new List<IChangeInfo?>();
+            return new List<IChangeInfo>();
         }
-        List<IChangeInfo?> changeInfos = new();
+        List<IChangeInfo> changeInfos = new();
         List<Change> changePacket = redoStack.Pop();
 
         for (int i = 0; i < changePacket.Count; i++)
-            changeInfos.Add(changePacket[i].Apply(document, out _));
+        {
+            changePacket[i].Apply(document, out _).Switch(
+                (None _) => { },
+                (IChangeInfo info) => changeInfos.Add(info),
+                (List<IChangeInfo> infos) => changeInfos.AddRange(infos)); ;
+        }
 
         undoStack.Push(changePacket);
         return changeInfos;
@@ -147,12 +157,12 @@ public class DocumentChangeTracker : IDisposable
         undoStack.Clear();
     }
 
-    private IChangeInfo? ProcessMakeChangeAction(IMakeChangeAction act)
+    private OneOf<None, IChangeInfo, List<IChangeInfo>> ProcessMakeChangeAction(IMakeChangeAction act)
     {
         if (activeUpdateableChange is not null)
         {
             Trace.WriteLine($"Attempted to execute make change action {act} while {activeUpdateableChange} is active");
-            return null;
+            return new None();
         }
         var change = act.CreateCorrespondingChange();
         var validationResult = change.InitializeAndValidate(document);
@@ -160,7 +170,7 @@ public class DocumentChangeTracker : IDisposable
         {
             Trace.WriteLine($"Change {change} failed validation");
             change.Dispose();
-            return null;
+            return new None();
         }
 
         var info = change.Apply(document, out bool ignoreInUndo);
@@ -171,7 +181,7 @@ public class DocumentChangeTracker : IDisposable
         return info;
     }
 
-    private IChangeInfo? ProcessStartOrUpdateChangeAction(IStartOrUpdateChangeAction act)
+    private OneOf<None, IChangeInfo, List<IChangeInfo>> ProcessStartOrUpdateChangeAction(IStartOrUpdateChangeAction act)
     {
         if (activeUpdateableChange is null)
         {
@@ -181,30 +191,30 @@ public class DocumentChangeTracker : IDisposable
             {
                 Trace.WriteLine($"Change {newChange} failed validation");
                 newChange.Dispose();
-                return null;
+                return new None();
             }
             activeUpdateableChange = newChange;
         }
         else if (!act.IsChangeTypeMatching(activeUpdateableChange))
         {
             Trace.WriteLine($"Tried to start or update a change using action {act} while a change of type {activeUpdateableChange} is active");
-            return null;
+            return new None();
         }
         act.UpdateCorrespodingChange(activeUpdateableChange);
         return activeUpdateableChange.ApplyTemporarily(document);
     }
 
-    private IChangeInfo? ProcessEndChangeAction(IEndChangeAction act)
+    private OneOf<None, IChangeInfo, List<IChangeInfo>> ProcessEndChangeAction(IEndChangeAction act)
     {
         if (activeUpdateableChange is null)
         {
             Trace.WriteLine($"Attempted to end a change using action {act} while no changes are active");
-            return null;
+            return new None();
         }
         if (!act.IsChangeTypeMatching(activeUpdateableChange))
         {
             Trace.WriteLine($"Trying to end a change with an action {act} while change {activeUpdateableChange} is active");
-            return null;
+            return new None();
         }
 
         var info = activeUpdateableChange.Apply(document, out bool ignoreInUndo);
@@ -219,24 +229,30 @@ public class DocumentChangeTracker : IDisposable
     private List<IChangeInfo?> ProcessActionList(IReadOnlyList<IAction> actions)
     {
         List<IChangeInfo?> changeInfos = new();
+        void AddInfo(OneOf<None, IChangeInfo, List<IChangeInfo>> info) =>
+            info.Switch(
+                static (None _) => { },
+                (IChangeInfo info) => changeInfos.Add(info),
+                (List<IChangeInfo> infos) => changeInfos.AddRange(infos));
+
         foreach (var action in actions)
         {
             switch (action)
             {
                 case IMakeChangeAction act:
-                    changeInfos.Add(ProcessMakeChangeAction(act));
+                    AddInfo(ProcessMakeChangeAction(act));
                     break;
                 case IStartOrUpdateChangeAction act:
-                    changeInfos.Add(ProcessStartOrUpdateChangeAction(act));
+                    AddInfo(ProcessStartOrUpdateChangeAction(act));
                     break;
                 case IEndChangeAction act:
-                    changeInfos.Add(ProcessEndChangeAction(act));
+                    AddInfo(ProcessEndChangeAction(act));
                     break;
                 case Undo_Action act:
-                    changeInfos.AddRange(Undo());
+                    AddInfo(Undo());
                     break;
                 case Redo_Action act:
-                    changeInfos.AddRange(Redo());
+                    AddInfo(Redo());
                     break;
                 case ChangeBoundary_Action:
                     CompletePacket();

+ 1 - 0
src/PixiEditor.ChangeableDocument/GlobalUsings.cs

@@ -5,3 +5,4 @@ global using OneOf.Types;
 global using PixiEditor.ChangeableDocument.Actions.Attributes;
 global using PixiEditor.ChangeableDocument.Changeables;
 global using PixiEditor.ChangeableDocument.ChangeInfos;
+global using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;

+ 6 - 10
src/PixiEditor.ChangeableDocument/Rendering/ChunkRenderer.cs

@@ -1,8 +1,4 @@
-using ChunkyImageLib;
-using ChunkyImageLib.DataHolders;
-using ChunkyImageLib.Operations;
-using OneOf;
-using OneOf.Types;
+using ChunkyImageLib.Operations;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using SkiaSharp;
 
@@ -39,13 +35,13 @@ public static class ChunkRenderer
         context.UpdateFromMember(layer);
 
         Chunk renderingResult = Chunk.Create(resolution);
-        if (!layer.LayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, new(0, 0), context.ReplacingPaintWithOpacity))
+        if (!layer.LayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, VecI.Zero, context.ReplacingPaintWithOpacity))
         {
             renderingResult.Dispose();
             return new EmptyChunk();
         }
 
-        if (!layer.Mask!.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, new(0, 0), ClippingPaint))
+        if (!layer.Mask!.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, VecI.Zero, ClippingPaint))
         {
             // should pretty much never happen due to the check above, but you can never be sure with many threads
             renderingResult.Dispose();
@@ -70,7 +66,7 @@ public static class ChunkRenderer
 
         context.UpdateFromMember(layer);
         Chunk renderingResult = Chunk.Create(resolution);
-        if (!layer.LayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, new(0, 0), context.ReplacingPaintWithOpacity))
+        if (!layer.LayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, renderingResult.Surface.SkiaSurface, VecI.Zero, context.ReplacingPaintWithOpacity))
         {
             renderingResult.Dispose();
             return new EmptyChunk();
@@ -103,7 +99,7 @@ public static class ChunkRenderer
             return;
         }
         context.UpdateFromMember(layer);
-        layer.LayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, targetChunk.Surface.SkiaSurface, new(0, 0), context.BlendModeOpacityPaint);
+        layer.LayerImage.DrawMostUpToDateChunkOn(chunkPos, resolution, targetChunk.Surface.SkiaSurface, VecI.Zero, context.BlendModeOpacityPaint);
     }
 
     private static OneOf<EmptyChunk, Chunk> RenderFolder(
@@ -131,7 +127,7 @@ public static class ChunkRenderer
 
         if (folder.Mask is not null)
         {
-            if (!folder.Mask.DrawMostUpToDateChunkOn(chunkPos, resolution, contents.Surface.SkiaSurface, new(0, 0), ClippingPaint))
+            if (!folder.Mask.DrawMostUpToDateChunkOn(chunkPos, resolution, contents.Surface.SkiaSurface, VecI.Zero, ClippingPaint))
             {
                 // this shouldn't really happen due to the check above, but another thread could edit the mask in the meantime
                 contents.Dispose();

+ 3 - 3
src/PixiEditor.Zoombox/Zoombox.xaml.cs

@@ -122,8 +122,8 @@ public partial class Zoombox : ContentControl, INotifyPropertyChanged
         remove => RemoveHandler(ViewportMovedEvent, value);
     }
 
-    public double CanvasX => ToScreenSpace(new(0, 0)).X;
-    public double CanvasY => ToScreenSpace(new(0, 0)).Y;
+    public double CanvasX => ToScreenSpace(VecD.Zero).X;
+    public double CanvasY => ToScreenSpace(VecD.Zero).Y;
 
     public double ScaleTransformXY => Scale;
     public double FlipTransformX => FlipX ? -1 : 1;
@@ -356,7 +356,7 @@ public partial class Zoombox : ContentControl, INotifyPropertyChanged
     {
         var zoombox = (Zoombox)obj;
 
-        VecD topLeft = zoombox.ToZoomboxSpace(new(0, 0)).Rotate(zoombox.Angle);
+        VecD topLeft = zoombox.ToZoomboxSpace(VecD.Zero).Rotate(zoombox.Angle);
         VecD bottomRight = zoombox.ToZoomboxSpace(new(zoombox.mainCanvas.ActualWidth, zoombox.mainCanvas.ActualHeight)).Rotate(zoombox.Angle);
 
         zoombox.Dimensions = (bottomRight - topLeft).Abs();

+ 2 - 2
src/PixiEditorPrototype/Models/Rendering/AffectedChunkGatherer.cs

@@ -204,8 +204,8 @@ internal class AffectedChunkGatherer
     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));
+            (int)Math.Ceiling(tracker.Document.Size.X / (float)ChunkyImage.FullChunkSize),
+            (int)Math.Ceiling(tracker.Document.Size.Y / (float)ChunkyImage.FullChunkSize));
         for (int i = 0; i < size.X; i++)
         {
             for (int j = 0; j < size.Y; j++)

+ 9 - 0
src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs

@@ -60,6 +60,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
     public RelayCommand? ClearHistoryCommand { get; }
     public RelayCommand? CreateMaskCommand { get; }
     public RelayCommand? DeleteMaskCommand { get; }
+    public RelayCommand? ApplyMaskCommand { get; }
     public RelayCommand? ToggleLockTransparencyCommand { get; }
     public RelayCommand? ApplyTransformCommand { get; }
     public RelayCommand? PasteImageCommand { get; }
@@ -119,6 +120,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
         DragSymmetryCommand = new RelayCommand(DragSymmetry);
         EndDragSymmetryCommand = new RelayCommand(EndDragSymmetry);
         ClipToMemberBelowCommand = new RelayCommand(ClipToMemberBelow);
+        ApplyMaskCommand = new RelayCommand(ApplyMask);
 
         foreach (var bitmap in Bitmaps)
         {
@@ -132,6 +134,13 @@ internal class DocumentViewModel : INotifyPropertyChanged
             (new CreateStructureMember_Action(StructureRoot.GuidValue, Guid.NewGuid(), 0, StructureMemberType.Layer));
     }
 
+    private void ApplyMask(object? obj)
+    {
+        if (updateableChangeActive || SelectedStructureMember is not LayerViewModel layer || !layer.HasMask)
+            return;
+        Helpers.ActionAccumulator.AddFinishedActions(new ApplyLayerMask_Action(layer.GuidValue));
+    }
+
     private void ClipToMemberBelow(object? obj)
     {
         if (updateableChangeActive || SelectedStructureMember is null)

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

@@ -35,6 +35,9 @@
                 <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,5,0,0">
                     <Button Margin="5,0" Command="{Binding ActiveDocument.CreateMaskCommand}" Width="80">Create Mask</Button>
                     <Button Margin="5,0" Command="{Binding ActiveDocument.DeleteMaskCommand}" Width="80">Delete Mask</Button>
+                    <Button Margin="5,0" Command="{Binding ActiveDocument.ApplyMaskCommand}" Width="80">Apply Mask</Button>
+                </StackPanel>
+                <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,5,0,0">
                     <controls:BlendModeComboBox Margin="5,0" Width="80" SelectedBlendMode="{Binding ActiveDocument.SelectedStructureMember.BlendMode, Mode=TwoWay}"/>
                 </StackPanel>
                 <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,5,0,0">

+ 2 - 0
src/README.md

@@ -21,6 +21,7 @@ Decouples the state of a document from the UI.
         - [ ] Write chunks to the hard drive?
         - [ ] Compress chunks?
     - [x] Linear color space for blending
+    - [ ] Make low res chunks use smooth filtering
     - [ ] Tests for everything related to the operation queueing
     - Operations
         - [x] Support for paints with different blending (replace vs. alpha compose)
@@ -107,6 +108,7 @@ Decouples the state of a document from the UI.
         - [x] Clip to selection
         - [x] Lock transparency
         - [x] Create/Delete mask
+        - [ ] Enable/Disable mask
         - [ ] Apply mask
 - ViewModel
     - [ ] Loading window when background thread is busy