Browse Source

Slow paste image implementation

Equbuxu 3 years ago
parent
commit
a39f2a7eeb

+ 22 - 22
src/ChunkyImageLib/ChunkyImage.cs

@@ -26,6 +26,12 @@ namespace ChunkyImageLib;
 ///         The data includes how many operations from the queue have already been applied to the chunk, as well as chunk deleted state (the clear operation deletes chunks)
 ///     - LatestSize contains the new size if any resize operations were requested, otherwise the commited size
 /// You can check the current state via queuedOperations.Count == 0
+/// 
+/// Depending on the chosen blend mode the latest chunks contain different things:
+///     - SKBlendMode.Src: default mode, the latest chunks are the same as committed ones but with some or all queued operations applied. 
+///         This means that operations can work with the existing pixels.
+///     - Any other blend mode: the latest chunks contain only the things drawn by the queued operations.
+///         They need to be drawn over the committed chunks to obtain the final image. In this case, operations won't have access to the existing pixels.
 /// </summary>
 public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
 {
@@ -171,11 +177,11 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
-    internal bool CommitedChunkExists(Vector2i chunkPos, ChunkResolution resolution)
+    internal bool CommittedChunkExists(Vector2i chunkPos)
     {
         lock (lockObject)
         {
-            return GetCommittedChunk(chunkPos, resolution) is not null;
+            return MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is not null;
         }
     }
 
@@ -222,7 +228,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     }
 
     /// <summary>
-    /// Don't pass in porter duff compositing operators (apart from SrcOver) as they won't have the intended effect.
+    /// Porter duff compositing operators (apart from SrcOver) likely won't have the intended effect.
     /// </summary>
     public void SetBlendMode(SKBlendMode mode)
     {
@@ -251,6 +257,15 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    public void EnqueueDrawImage(ShapeCorners corners, Surface image)
+    {
+        lock (lockObject)
+        {
+            ImageOperation operation = new(corners, image);
+            EnqueueOperation(operation);
+        }
+    }
+
     public void EnqueueDrawImage(Vector2i pos, Surface image)
     {
         lock (lockObject)
@@ -565,34 +580,19 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
             return new All();
         }
 
-
         var intersection = Chunk.Create(resolution);
         intersection.Surface.SkiaSurface.Canvas.Clear(SKColors.White);
 
         foreach (var mask in activeClips)
         {
-            // handle self-clipping as a special case to avoid deadlock
-            if (!ReferenceEquals(this, mask))
+            if (mask.CommittedChunkExists(chunkPos))
             {
-                if (mask.CommitedChunkExists(chunkPos, resolution))
-                {
-                    mask.DrawCommittedChunkOn(chunkPos, resolution, intersection.Surface.SkiaSurface, new(0, 0), ClippingPaint);
-                }
-                else
-                {
-                    intersection.Dispose();
-                    return new None();
-                }
+                mask.DrawCommittedChunkOn(chunkPos, resolution, intersection.Surface.SkiaSurface, new(0, 0), ClippingPaint);
             }
             else
             {
-                var maskChunk = GetCommittedChunk(chunkPos, resolution);
-                if (maskChunk is null)
-                {
-                    intersection.Dispose();
-                    return new None();
-                }
-                maskChunk.DrawOnSurface(intersection.Surface.SkiaSurface, new(0, 0), ClippingPaint);
+                intersection.Dispose();
+                return new None();
             }
         }
         return intersection;

+ 1 - 0
src/ChunkyImageLib/CommittedChunkStorage.cs

@@ -41,6 +41,7 @@ public class CommittedChunkStorage : IDisposable
     {
         if (disposed)
             return;
+        disposed = true;
         foreach (var (_, chunk) in savedChunks)
         {
             if (chunk is not null)

+ 10 - 0
src/ChunkyImageLib/DataHolders/ShapeCorners.cs

@@ -8,6 +8,13 @@ public struct ShapeCorners
         BottomRight = center + size / 2;
         BottomLeft = center + new Vector2d(-size.X / 2, size.Y / 2);
     }
+    public ShapeCorners(Vector2d topLeft, Vector2d size)
+    {
+        TopLeft = topLeft;
+        TopRight = new(topLeft.X + size.X, topLeft.Y);
+        BottomRight = topLeft + size;
+        BottomLeft = new(topLeft.X, topLeft.Y + size.Y);
+    }
     public Vector2d TopLeft { get; set; }
     public Vector2d TopRight { get; set; }
     public Vector2d BottomLeft { get; set; }
@@ -56,6 +63,9 @@ public struct ShapeCorners
         var deltaBottomRight = point - BottomRight;
         var deltaBottomLeft = point - BottomLeft;
 
+        if (deltaTopRight.IsNaNOrInfinity() || deltaTopLeft.IsNaNOrInfinity() || deltaBottomRight.IsNaNOrInfinity() || deltaBottomRight.IsNaNOrInfinity())
+            return false;
+
         var crossTop = Math.Sign(top.Cross(deltaTopLeft));
         var crossRight = Math.Sign(right.Cross(deltaTopRight));
         var crossBottom = Math.Sign(bottom.Cross(deltaBottomRight));

+ 29 - 20
src/ChunkyImageLib/Operations/ImageOperation.cs

@@ -5,7 +5,8 @@ namespace ChunkyImageLib.Operations;
 
 internal record class ImageOperation : IDrawOperation
 {
-    private Vector2i pos;
+    private SKMatrix transformMatrix;
+    private ShapeCorners corners;
     private Surface toPaint;
     private static SKPaint ReplacingPaint = new() { BlendMode = SKBlendMode.Src };
 
@@ -13,36 +14,44 @@ internal record class ImageOperation : IDrawOperation
 
     public ImageOperation(Vector2i pos, Surface image)
     {
-        this.pos = pos;
+        corners = new()
+        {
+            TopLeft = pos,
+            TopRight = new(pos.X + image.Size.X, pos.Y),
+            BottomRight = pos + image.Size,
+            BottomLeft = new Vector2d(pos.X, pos.Y + image.Size.Y)
+        };
+        transformMatrix = SKMatrix.CreateIdentity();
+        transformMatrix.TransX = pos.X;
+        transformMatrix.TransY = pos.Y;
+        // copying is required for thread safety
+        toPaint = new Surface(image);
+    }
+
+    public ImageOperation(ShapeCorners corners, Surface image)
+    {
+        this.corners = corners;
+        transformMatrix = OperationHelper.CreateMatrixFromPoints(corners, image.Size);
         toPaint = new Surface(image);
     }
 
     public void DrawOnChunk(Chunk chunk, Vector2i chunkPos)
     {
-        if (chunk.Resolution == ChunkResolution.Full)
-        {
-            chunk.Surface.SkiaSurface.Canvas.DrawSurface(toPaint.SkiaSurface, pos - chunkPos * ChunkPool.FullChunkSize, ReplacingPaint);
-            return;
-        }
+        float scaleMult = (float)chunk.Resolution.Multiplier();
+        Vector2d trans = -chunkPos * ChunkPool.FullChunkSize;
+
+        var scaleTrans = SKMatrix.CreateScaleTranslation(scaleMult, scaleMult, (float)trans.X * scaleMult, (float)trans.Y * scaleMult);
+        var finalMatrix = SKMatrix.Concat(scaleTrans, transformMatrix);
+
         chunk.Surface.SkiaSurface.Canvas.Save();
-        chunk.Surface.SkiaSurface.Canvas.Scale((float)chunk.Resolution.Multiplier());
-        chunk.Surface.SkiaSurface.Canvas.DrawSurface(toPaint.SkiaSurface, pos - chunkPos * ChunkPool.FullChunkSize, ReplacingPaint);
+        chunk.Surface.SkiaSurface.Canvas.SetMatrix(finalMatrix);
+        chunk.Surface.SkiaSurface.Canvas.DrawSurface(toPaint.SkiaSurface, 0, 0, ReplacingPaint);
         chunk.Surface.SkiaSurface.Canvas.Restore();
     }
 
     public HashSet<Vector2i> FindAffectedChunks()
     {
-        Vector2i start = OperationHelper.GetChunkPos(pos, ChunkPool.FullChunkSize);
-        Vector2i end = OperationHelper.GetChunkPos(new(pos.X + toPaint.Size.X - 1, pos.Y + toPaint.Size.Y - 1), ChunkPool.FullChunkSize);
-        HashSet<Vector2i> output = new();
-        for (int cx = start.X; cx <= end.X; cx++)
-        {
-            for (int cy = start.Y; cy <= end.Y; cy++)
-            {
-                output.Add(new(cx, cy));
-            }
-        }
-        return output;
+        return OperationHelper.FindChunksTouchingQuadrilateral(corners, ChunkPool.FullChunkSize);
     }
 
     public void Dispose()

+ 15 - 3
src/ChunkyImageLib/Surface.cs

@@ -1,7 +1,7 @@
-using ChunkyImageLib.DataHolders;
-using SkiaSharp;
-using System.Runtime.CompilerServices;
+using System.Runtime.CompilerServices;
 using System.Runtime.InteropServices;
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
 
 namespace ChunkyImageLib;
 
@@ -32,6 +32,18 @@ public class Surface : IDisposable
         SkiaSurface.Canvas.DrawSurface(original.SkiaSurface, 0, 0);
     }
 
+    public static Surface Load(string path)
+    {
+        if (!File.Exists(path))
+            throw new FileNotFoundException(null, path);
+        using var bitmap = SKBitmap.Decode(path);
+        if (bitmap is null)
+            throw new ArgumentException($"The image with path {path} couldn't be loaded");
+        var surface = new Surface(new Vector2i(bitmap.Width, bitmap.Height));
+        surface.SkiaSurface.Canvas.DrawBitmap(bitmap, 0, 0);
+        return surface;
+    }
+
     public unsafe void CopyTo(Surface other)
     {
         if (other.Size != Size)

+ 11 - 0
src/PixiEditor.ChangeableDocument/Actions/Drawing/PasteImage/EndPasteImage_Action.cs

@@ -0,0 +1,11 @@
+using PixiEditor.ChangeableDocument.Changes;
+using PixiEditor.ChangeableDocument.Changes.Drawing;
+
+namespace PixiEditor.ChangeableDocument.Actions.Drawing.PasteImage;
+public record class EndPasteImage_Action : IEndChangeAction
+{
+    bool IEndChangeAction.IsChangeTypeMatching(Change change)
+    {
+        return change is PasteImage_UpdateableChange;
+    }
+}

+ 31 - 0
src/PixiEditor.ChangeableDocument/Actions/Drawing/PasteImage/PasteImage_Action.cs

@@ -0,0 +1,31 @@
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Changes;
+using PixiEditor.ChangeableDocument.Changes.Drawing;
+
+namespace PixiEditor.ChangeableDocument.Actions.Drawing.PasteImage;
+public record class PasteImage_Action : IStartOrUpdateChangeAction
+{
+    public PasteImage_Action(Surface image, ShapeCorners corners, Guid layerGuid, bool isDrawingOnMask)
+    {
+        Image = image;
+        Corners = corners;
+        GuidValue = layerGuid;
+        IsDrawingOnMask = isDrawingOnMask;
+    }
+
+    public Surface Image { get; }
+    public ShapeCorners Corners { get; }
+    public Guid GuidValue { get; }
+    public bool IsDrawingOnMask { get; }
+
+    UpdateableChange IStartOrUpdateChangeAction.CreateCorrespondingChange()
+    {
+        return new PasteImage_UpdateableChange(Corners, Image, GuidValue, IsDrawingOnMask);
+    }
+
+    void IStartOrUpdateChangeAction.UpdateCorrespodingChange(UpdateableChange change)
+    {
+        ((PasteImage_UpdateableChange)change).Update(Corners);
+    }
+}

+ 75 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/PasteImage_UpdateableChange.cs

@@ -0,0 +1,75 @@
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Changeables;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+internal class PasteImage_UpdateableChange : UpdateableChange
+{
+    private ShapeCorners corners;
+    private readonly Guid memberGuid;
+    private readonly bool drawOnMask;
+    private readonly Surface imageToPaste;
+    private CommittedChunkStorage? savedChunks;
+
+    public PasteImage_UpdateableChange(ShapeCorners corners, Surface imageToPaste, Guid memberGuid, bool drawOnMask)
+    {
+        this.corners = corners;
+        this.memberGuid = memberGuid;
+        this.drawOnMask = drawOnMask;
+        this.imageToPaste = new Surface(imageToPaste);
+    }
+
+    public void Update(ShapeCorners newCorners)
+    {
+        corners = newCorners;
+    }
+
+    private HashSet<Vector2i> DrawImage(ChunkyImage targetImage)
+    {
+        var prevChunks = targetImage.FindAffectedChunks();
+
+        targetImage.CancelChanges();
+        targetImage.EnqueueDrawImage(corners, imageToPaste);
+
+        var affectedChunks = targetImage.FindAffectedChunks();
+        affectedChunks.UnionWith(prevChunks);
+        return affectedChunks;
+    }
+
+    public override IChangeInfo? Apply(Document target, out bool ignoreInUndo)
+    {
+        ChunkyImage targetImage = DrawingChangeHelper.GetTargetImage(target, memberGuid, drawOnMask);
+        var chunks = DrawImage(targetImage);
+        savedChunks?.Dispose();
+        savedChunks = new(targetImage, targetImage.FindAffectedChunks());
+        targetImage.CommitChanges();
+        ignoreInUndo = false;
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, chunks, drawOnMask);
+    }
+
+    public override IChangeInfo? ApplyTemporarily(Document target)
+    {
+        ChunkyImage targetImage = DrawingChangeHelper.GetTargetImage(target, memberGuid, drawOnMask);
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, DrawImage(targetImage), drawOnMask);
+    }
+
+    public override IChangeInfo? Revert(Document target)
+    {
+        if (savedChunks is null)
+            throw new InvalidOperationException("No saved chunks to restore");
+        ChunkyImage targetImage = DrawingChangeHelper.GetTargetImage(target, memberGuid, drawOnMask);
+        savedChunks.ApplyChunksToImage(targetImage);
+        var chunks = targetImage.FindAffectedChunks();
+        targetImage.CommitChanges();
+        savedChunks.Dispose();
+        savedChunks = null;
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, chunks, drawOnMask);
+    }
+
+    public override void Dispose()
+    {
+        imageToPaste.Dispose();
+        savedChunks?.Dispose();
+    }
+}

+ 1 - 0
src/PixiEditorPrototype/CustomControls/TransformOverlay/TransformOverlay.cs

@@ -202,6 +202,7 @@ internal class TransformOverlay : Control
         {
             originWasManuallyDragged = true;
             origin = originOnStartAnchorDrag + pos - mousePosOnStartAnchorDrag;
+            InvalidateVisual();
         }
     }
 

+ 2 - 2
src/PixiEditorPrototype/ViewModels/DocumentTransformViewModel.cs

@@ -79,8 +79,8 @@ internal class DocumentTransformViewModel : INotifyPropertyChanged
 
     public void ShowFreeTransform(ShapeCorners initPos)
     {
-        CornerFreedom = TransformCornerFreedom.Scale;
-        SideFreedom = TransformSideFreedom.ScaleProportionally;
+        CornerFreedom = TransformCornerFreedom.Free;
+        SideFreedom = TransformSideFreedom.Free;
         RequestedCorners = initPos;
         TransformActive = true;
     }

+ 61 - 14
src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs

@@ -5,8 +5,11 @@ using System.Linq;
 using System.Windows;
 using System.Windows.Media;
 using System.Windows.Media.Imaging;
+using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
+using Microsoft.Win32;
 using PixiEditor.ChangeableDocument.Actions.Drawing;
+using PixiEditor.ChangeableDocument.Actions.Drawing.PasteImage;
 using PixiEditor.ChangeableDocument.Actions.Drawing.Rectangle;
 using PixiEditor.ChangeableDocument.Actions.Drawing.Selection;
 using PixiEditor.ChangeableDocument.Actions.Properties;
@@ -56,6 +59,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
     public RelayCommand? DeleteMaskCommand { get; }
     public RelayCommand? ToggleLockTransparencyCommand { get; }
     public RelayCommand? ApplyTransformCommand { get; }
+    public RelayCommand? PasteImageCommand { get; }
 
     public int Width => Helpers.Tracker.Document.Size.X;
     public int Height => Helpers.Tracker.Document.Size.Y;
@@ -105,6 +109,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
         CreateMaskCommand = new RelayCommand(CreateMask);
         DeleteMaskCommand = new RelayCommand(DeleteMask);
         ToggleLockTransparencyCommand = new RelayCommand(ToggleLockTransparency);
+        PasteImageCommand = new RelayCommand(PasteImage);
         ApplyTransformCommand = new RelayCommand(ApplyTransform);
 
         foreach (var bitmap in Bitmaps)
@@ -119,9 +124,14 @@ internal class DocumentViewModel : INotifyPropertyChanged
             (new CreateStructureMember_Action(StructureRoot.GuidValue, Guid.NewGuid(), 0, StructureMemberType.Layer));
     }
 
+    private bool updateableChangeActive = false;
+
     private bool drawingRectangle = false;
     private bool transformingRectangle = false;
 
+    private bool pastingImage = false;
+    private Surface? pastedImage;
+
     private ShapeCorners lastShape = new ShapeCorners();
     private ShapeData lastShapeData = new();
     public void StartUpdateRectangle(ShapeData data)
@@ -131,6 +141,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
         bool drawOnMask = SelectedStructureMember.HasMask && SelectedStructureMember.ShouldDrawOnMask;
         if (SelectedStructureMember is not LayerViewModel && !drawOnMask)
             return;
+        updateableChangeActive = true;
         drawingRectangle = true;
         Helpers.ActionAccumulator.AddActions(new DrawRectangle_Action(SelectedStructureMember.GuidValue, data, drawOnMask));
         lastShape = new ShapeCorners(data.Center, data.Size, data.Angle);
@@ -149,12 +160,24 @@ internal class DocumentViewModel : INotifyPropertyChanged
 
     public void ApplyTransform(object? param)
     {
-        if (!transformingRectangle)
+        if (!transformingRectangle && !pastingImage)
             return;
 
-        transformingRectangle = false;
-        TransformViewModel.HideTransform();
-        Helpers.ActionAccumulator.AddFinishedActions(new EndDrawRectangle_Action());
+        if (transformingRectangle)
+        {
+            transformingRectangle = false;
+            TransformViewModel.HideTransform();
+            Helpers.ActionAccumulator.AddFinishedActions(new EndDrawRectangle_Action());
+        }
+        else if (pastingImage)
+        {
+            pastingImage = false;
+            TransformViewModel.HideTransform();
+            Helpers.ActionAccumulator.AddFinishedActions(new EndPasteImage_Action());
+            pastedImage?.Dispose();
+            pastedImage = null;
+        }
+        updateableChangeActive = false;
     }
 
     bool startedSelection = false;
@@ -163,6 +186,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
         if (!startedSelection)
             Helpers.ActionAccumulator.AddActions(new ClearSelection_Action());
         startedSelection = true;
+        updateableChangeActive = true;
         Helpers.ActionAccumulator.AddActions(new SelectRectangle_Action(pos, size));
     }
 
@@ -171,21 +195,29 @@ internal class DocumentViewModel : INotifyPropertyChanged
         if (!startedSelection)
             return;
         startedSelection = false;
+        updateableChangeActive = false;
         Helpers.ActionAccumulator.AddFinishedActions(new EndSelectRectangle_Action());
     }
 
     private void OnTransformUpdate(object? sender, ShapeCorners newCorners)
     {
-        if (!transformingRectangle)
-            return;
-        StartUpdateRectangle(new ShapeData(
-            newCorners.RectCenter,
-            newCorners.RectSize,
-            newCorners.RectRotation,
-            lastShapeData.StrokeWidth,
-            lastShapeData.StrokeColor,
-            lastShapeData.FillColor,
-            lastShapeData.BlendMode));
+        if (transformingRectangle)
+        {
+            StartUpdateRectangle(new ShapeData(
+                newCorners.RectCenter,
+                newCorners.RectSize,
+                newCorners.RectRotation,
+                lastShapeData.StrokeWidth,
+                lastShapeData.StrokeColor,
+                lastShapeData.FillColor,
+                lastShapeData.BlendMode));
+        }
+        else if (pastingImage)
+        {
+            if (SelectedStructureMember is null || pastedImage is null)
+                return;
+            Helpers.ActionAccumulator.AddActions(new PasteImage_Action(pastedImage, newCorners, SelectedStructureMember.GuidValue, false));
+        }
     }
 
     public void ForceRefreshView()
@@ -208,6 +240,21 @@ internal class DocumentViewModel : INotifyPropertyChanged
         Helpers.ActionAccumulator.AddActions(new RefreshViewport_PassthroughAction(viewportGuid));
     }
 
+    private void PasteImage(object? args)
+    {
+        if (SelectedStructureMember is null || SelectedStructureMember is not LayerViewModel)
+            return;
+        OpenFileDialog dialog = new();
+        if (dialog.ShowDialog() != true)
+            return;
+
+        pastedImage = Surface.Load(dialog.FileName);
+        pastingImage = true;
+        ShapeCorners corners = new(new(), pastedImage.Size);
+        Helpers.ActionAccumulator.AddActions(new PasteImage_Action(pastedImage, corners, SelectedStructureMember.GuidValue, false));
+        TransformViewModel.ShowFreeTransform(corners);
+    }
+
     private void ClearSelection(object? param)
     {
         Helpers.ActionAccumulator.AddFinishedActions(new ClearSelection_Action());

+ 2 - 1
src/PixiEditorPrototype/Views/MainWindow.xaml

@@ -14,7 +14,7 @@
         xmlns:models="clr-namespace:PixiEditorPrototype.Models"
         xmlns:colorpicker="clr-namespace:ColorPicker;assembly=ColorPicker"
         mc:Ignorable="d"
-        Title="MainWindow" Height="576" Width="1024">
+        Title="MainWindow" Height="700" Width="1400">
     <Window.DataContext>
         <vm:ViewModelMain/>
     </Window.DataContext>
@@ -144,6 +144,7 @@
                     <Button Width="50" Margin="5" Command="{Binding ActiveDocument.RedoCommand}">Redo</Button>
                     <Button Width="100" Margin="5" Command="{Binding ActiveDocument.ClearSelectionCommand}">Clear selection</Button>
                     <Button Width="120" Margin="5" Command="{Binding ActiveDocument.ClearHistoryCommand}">Clear undo history</Button>
+                    <Button Width="100" Margin="5" Command="{Binding ActiveDocument.PasteImageCommand}">Paste Image</Button>
                     <Button Width="100" Margin="5" Command="{Binding ActiveDocument.ApplyTransformCommand}">Apply Transform</Button>
                 </StackPanel>
                 <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">

+ 2 - 0
src/README.md

@@ -25,6 +25,7 @@ Decouples the state of a document from the UI.
     - Operations
         - [x] Support for paints with different blending (replace vs. alpha compose)
         - [ ] Image (basic done, needs rotation, scale, skew, perspective, etc.)
+        - [ ] ChunkyImage
         - [ ] Rectangle (basic done, needs rotation support)
         - [ ] Ellipse
         - [ ] Line
@@ -109,6 +110,7 @@ Decouples the state of a document from the UI.
         - [ ] Apply mask
 - ViewModel
     - [ ] Action filtering
+    - [ ] Transform overlay
     - [x] Viewport movement as an action
     - [x] Integrate viewport from PixiEditor
     - [x] Rotate viewport