Explorar el Código

A lot of documents related models ported

Krzysztof Krysiński hace 2 años
padre
commit
db0ccfac64
Se han modificado 70 ficheros con 4554 adiciones y 15 borrados
  1. 30 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ChangeInfoListOptimizer.cs
  2. 10 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/IFolderHandlerFactory.cs
  3. 10 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ILayerHandlerFactory.cs
  4. 39 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/StructureHelpers.cs
  5. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Clipboard/DataImage.cs
  6. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/ColorSearchResult.cs
  7. 209 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/ActionAccumulator.cs
  8. 201 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/ChangeExecutionController.cs
  9. 23 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentInternalParts.cs
  10. 9 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentState.cs
  11. 142 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentStructureHelper.cs
  12. 396 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentUpdater.cs
  13. 48 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentEventsModule.cs
  14. 597 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentOperationsModule.cs
  15. 123 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentStructureModule.cs
  16. 58 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentToolsModule.cs
  17. 52 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/BrightnessToolExecutor.cs
  18. 52 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/ColorPickerToolExecutor.cs
  19. 36 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/EllipseToolExecutor.cs
  20. 63 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/EraserToolExecutor.cs
  21. 58 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/FloodFillToolExecutor.cs
  22. 44 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/LassoToolExecutor.cs
  23. 123 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/LineToolExecutor.cs
  24. 47 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/MagicWandToolExecutor.cs
  25. 80 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs
  26. 81 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/PenToolExecutor.cs
  27. 31 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/RectangleToolExecutor.cs
  28. 74 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/SelectToolExecutor.cs
  29. 167 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/ShapeToolExecutor.cs
  30. 77 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/ShiftLayerExecutor.cs
  31. 33 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/StructureMemberOpacityExecutor.cs
  32. 49 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/SymmetryExecutor.cs
  33. 50 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/TransformReferenceLayerExecutor.cs
  34. 86 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedAreaExecutor.cs
  35. 55 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs
  36. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/AddSoftSelectedMember_PassthroughAction.cs
  37. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/ClearSoftSelectedMembers_PassthroughAction.cs
  38. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/RefreshViewport_PassthroughAction.cs
  39. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/RemoveSoftSelectedMember_PassthroughAction.cs
  40. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/RemoveViewport_PassthroughAction.cs
  41. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/SetSelectedMember_PassthroughAction.cs
  42. 34 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Events/FilteredKeyEventArgs.cs
  43. 36 1
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocument.cs
  44. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocumentManagerHandler.cs
  45. 9 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IFolderHandler.cs
  46. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ILayerHandler.cs
  47. 17 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IReferenceLayerHandler.cs
  48. 26 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IStructureMemberHandler.cs
  49. 6 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ITransformHandler.cs
  50. 8 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Layers/StructureMemberPlacement.cs
  51. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Layers/StructureMemberSelectionType.cs
  52. 81 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Position/CropData.cs
  53. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Position/SymmetryAxisDragInfo.cs
  54. 17 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Position/ViewportInfo.cs
  55. 247 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/AffectedAreasGatherer.cs
  56. 212 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/CanvasUpdater.cs
  57. 589 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/MemberPreviewUpdater.cs
  58. 3 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/CanvasPreviewDirty_RenderInfo.cs
  59. 6 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/DirtyRect_RenderInfo.cs
  60. 5 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/IRenderInfo.cs
  61. 3 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/MaskPreviewDirty_RenderInfo.cs
  62. 3 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/PreviewDirty_RenderInfo.cs
  63. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Tools/BrightnessMode.cs
  64. 7 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Tools/ExecutionState.cs
  65. 13 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Tools/ExecutorType.cs
  66. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/PixiEditor.Avalonia.csproj
  67. 5 8
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SystemCommands.cs
  68. 2 2
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Main/MainTitleBar.axaml
  69. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainWindow.axaml
  70. 1 0
      src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainWindow.axaml.cs

+ 30 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ChangeInfoListOptimizer.cs

@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+
+namespace PixiEditor.Helpers;
+
+#nullable enable
+internal class ChangeInfoListOptimizer
+{
+    public static List<IChangeInfo> Optimize(List<IChangeInfo?> input)
+    {
+        List<IChangeInfo> output = new();
+        bool selectionInfoOccured = false;
+        // discard all Selection_ChangeInfos apart from the last one
+        for (int i = input.Count - 1; i >= 0; i--)
+        {
+            IChangeInfo? info = input[i];
+            if (info is null)
+                continue;
+            if (info is Selection_ChangeInfo && !selectionInfoOccured)
+                selectionInfoOccured = true;
+            else if (info is Selection_ChangeInfo)
+                continue;
+            output.Add(info);
+        }
+
+        output.Reverse();
+        return output;
+    }
+}

+ 10 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/IFolderHandlerFactory.cs

@@ -0,0 +1,10 @@
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+
+namespace PixiEditor.Avalonia.Helpers;
+
+internal interface IFolderHandlerFactory
+{
+    public IDocument Document { get; }
+    public IFolderHandler CreateFolderHandler(DocumentInternalParts helper, Guid infoGuidValue);
+}

+ 10 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/ILayerHandlerFactory.cs

@@ -0,0 +1,10 @@
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+
+namespace PixiEditor.Avalonia.Helpers;
+
+internal interface ILayerHandlerFactory
+{
+    public IDocument Document { get; }
+    public ILayerHandler CreateLayerHandler(DocumentInternalParts helper, Guid infoGuidValue);
+}

+ 39 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Helpers/StructureHelpers.cs

@@ -0,0 +1,39 @@
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+
+namespace PixiEditor.Avalonia.Helpers;
+
+public static class StructureHelpers
+{
+    public const int PreviewSize = 48;
+    /// <summary>
+    /// Calculates the size of a scaled-down preview for a given size of layer tight bounds.
+    /// </summary>
+    public static VecI CalculatePreviewSize(VecI tightBoundsSize)
+    {
+        double proportions = tightBoundsSize.Y / (double)tightBoundsSize.X;
+        const int prSize = PreviewSize;
+        return proportions > 1 ?
+            new VecI(Math.Max((int)Math.Round(prSize / proportions), 1), prSize) :
+            new VecI(prSize, Math.Max((int)Math.Round(prSize * proportions), 1));
+    }
+
+    public static WriteableBitmap CreateBitmap(VecI size)
+    {
+        return new WriteableBitmap(new PixelSize(Math.Max(size.X, 1), Math.Max(size.Y, 1)), new Vector(96, 96), PixelFormats.Bgra8888, AlphaFormat.Premul);
+    }
+
+    public static DrawingSurface CreateDrawingSurface(WriteableBitmap bitmap)
+    {
+        using var frameBuffer = bitmap.Lock();
+        return DrawingSurface.Create(
+            new ImageInfo(bitmap.PixelSize.Width, bitmap.PixelSize.Height, ColorType.Bgra8888, AlphaType.Premul,
+                ColorSpace.CreateSrgb()),
+            frameBuffer.Address,
+            frameBuffer.RowBytes);
+    }
+}

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Clipboard/DataImage.cs

@@ -0,0 +1,7 @@
+using ChunkyImageLib;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+public record struct DataImage(string? name, Surface image, VecI position)
+{
+    public DataImage(Surface image, VecI position) : this(null, image, position) { }
+}

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Commands/Search/ColorSearchResult.cs

@@ -27,7 +27,7 @@ internal class ColorSearchResult : SearchResult
     };
 
     //public override bool CanExecute => !requiresDocument || (requiresDocument && ViewModelMain.Current.BitmapManager.ActiveDocument != null);
-    public override bool CanExecute => !isPalettePaste || (IDocumentHandler.Instance != null && IDocumentHandler.Instance.HasActiveDocument);
+    public override bool CanExecute => !isPalettePaste || (IDocumentManagerHandler.Instance != null && IDocumentManagerHandler.Instance.HasActiveDocument);
 
     public override IImage Icon => icon;
 
@@ -51,7 +51,7 @@ internal class ColorSearchResult : SearchResult
     public static ColorSearchResult PastePalette(DrawingApi.Core.ColorsImpl.Color color, string searchTerm = null)
     {
         var result = new ColorSearchResult(color, x =>
-            IDocumentHandler.Instance.ActiveDocument.Palette.Add(new PaletteColor(x.R, x.G, x.B)))
+            IDocumentManagerHandler.Instance.ActiveDocument.Palette.Add(new PaletteColor(x.R, x.G, x.B)))
         {
             SearchTerm = searchTerm,
             isPalettePaste = true

+ 209 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/ActionAccumulator.cs

@@ -0,0 +1,209 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.Actions.Undo;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Helpers;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentPassthroughActions;
+using PixiEditor.Models.Rendering;
+using PixiEditor.Models.Rendering.RenderInfos;
+
+namespace PixiEditor.Models.DocumentModels;
+#nullable enable
+internal class ActionAccumulator
+{
+    private bool executing = false;
+
+    private List<IAction> queuedActions = new();
+    private IDocument document;
+    private DocumentInternalParts internals;
+
+    private CanvasUpdater canvasUpdater;
+    private MemberPreviewUpdater previewUpdater;
+
+    public ActionAccumulator(IDocument doc, DocumentInternalParts internals)
+    {
+        this.document = doc;
+        this.internals = internals;
+
+        canvasUpdater = new(doc, internals);
+        previewUpdater = new(doc, internals);
+    }
+
+    public void AddFinishedActions(params IAction[] actions)
+    {
+        queuedActions.AddRange(actions);
+        queuedActions.Add(new ChangeBoundary_Action());
+        TryExecuteAccumulatedActions();
+    }
+
+    public void AddActions(params IAction[] actions)
+    {
+        queuedActions.AddRange(actions);
+        TryExecuteAccumulatedActions();
+    }
+
+    private async void TryExecuteAccumulatedActions()
+    {
+        if (executing || queuedActions.Count == 0)
+            return;
+        executing = true;
+        DispatcherTimer busyTimer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(2000) };
+        busyTimer.Tick += (_, _) =>
+        {
+            busyTimer.Stop();
+            document.Busy = true;
+        };
+        busyTimer.Start();
+        
+        List<ILockedFramebuffer> lockedFramebuffers = new();
+
+        while (queuedActions.Count > 0)
+        {
+            // select actions to be processed
+            var toExecute = queuedActions;
+            queuedActions = new List<IAction>();
+
+            // pass them to changeabledocument for processing
+            List<IChangeInfo?> changes;
+            if (AreAllPassthrough(toExecute))
+                changes = toExecute.Select(a => (IChangeInfo?)a).ToList();
+            else
+                changes = await internals.Tracker.ProcessActions(toExecute);
+
+            // update viewmodels based on changes
+            List<IChangeInfo> optimizedChanges = ChangeInfoListOptimizer.Optimize(changes);
+            bool undoBoundaryPassed = toExecute.Any(static action => action is ChangeBoundary_Action or Redo_Action or Undo_Action);
+            bool viewportRefreshRequest = toExecute.Any(static action => action is RefreshViewport_PassthroughAction);
+            foreach (IChangeInfo info in optimizedChanges)
+            {
+                internals.Updater.ApplyChangeFromChangeInfo(info);
+            }
+            if (undoBoundaryPassed)
+                internals.Updater.AfterUndoBoundaryPassed();
+
+            // render changes
+            // If you are a sane person or maybe just someone who reads WPF documentation, you might think that the reasonable order of operations should be
+            // 1. Lock the writeable bitmaps
+            // 2. Update their contents
+            // 3. Add dirty rectangles
+            // 4. Unlock them
+            // As it turns out, doing operations in this order leads to WPF render thread crashing in some circumstatances.
+            // Then the whole app freezes without throwing any errors, because the UI thread is blocked on a mutex, waiting for the dead render thread.
+            // This is despite the order clearly being adviced in the documentations here: https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.imaging.writeablebitmap?view=windowsdesktop-6.0&viewFallbackFrom=net-6.0#remarks
+            // Because of that, I'm executing the operations in the order that makes a lot less sense:
+            // 1. Update the contents of the bitmaps
+            // 2. Lock Them
+            // 3. Add dirty rectangles
+            // 4. Unlock
+            // The locks clearly do nothing useful here, and I'm only calling them because WriteableBitmap checks if it's locked before letting you add dirty rectangles.
+            // Really, the locks are supposed to prevent me from updating the bitmap contents in step 1, but they can't since I'm doing direct unsafe memory copying
+            // Somehow this all works
+            // Also, there is a bug report for this on github https://github.com/dotnet/wpf/issues/5816
+
+            
+            // lock bitmaps
+            foreach (var (_, bitmap) in document.LazyBitmaps)
+            {
+                lockedFramebuffers.Add(bitmap.Lock());
+            }
+
+            // update the contents of the bitmaps
+            var affectedAreas = new AffectedAreasGatherer(internals.Tracker, optimizedChanges);
+            List<IRenderInfo> renderResult = new();
+            renderResult.AddRange(await canvasUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed || viewportRefreshRequest));
+            renderResult.AddRange(await previewUpdater.UpdateGatheredChunks(affectedAreas, undoBoundaryPassed));
+
+            if (undoBoundaryPassed)
+                LockPreviewBitmaps(document.StructureRoot, lockedFramebuffers);
+
+            // add dirty rectangles
+            AddDirtyRects(renderResult);
+
+            // unlock bitmaps
+            foreach (var lockedFramebuffer in lockedFramebuffers)
+            {
+                lockedFramebuffer.Dispose();
+            }
+
+            // force refresh viewports for better responsiveness
+            foreach (var (_, value) in internals.State.Viewports)
+            {
+                if (!value.Delayed)
+                    value.InvalidateVisual();
+            }
+        }
+
+        busyTimer.Stop();
+        if (document.Busy)
+            document.Busy = false;
+        executing = false;
+    }
+
+    private bool AreAllPassthrough(List<IAction> actions)
+    {
+        foreach (var action in actions)
+        {
+            if (action is not IChangeInfo)
+                return false;
+        }
+        return true;
+    }
+
+    private void LockPreviewBitmaps(IFolderHandler root, List<ILockedFramebuffer> lockedFramebuffers)
+    {
+        foreach (var child in root.Children)
+        {
+            lockedFramebuffers.Add(child.PreviewBitmap?.Lock());
+            child.MaskPreviewBitmap?.Lock();
+            if (child is IFolderHandler innerFolder)
+                LockPreviewBitmaps(innerFolder, lockedFramebuffers);
+        }
+        lockedFramebuffers.Add(document.PreviewBitmap.Lock());
+    }
+
+    private void AddDirtyRects(List<IRenderInfo> changes)
+    {
+        //TODO: Avalonia doesn't seem to have a way to add dirty rects to bitmaps
+        /*foreach (IRenderInfo renderInfo in changes)
+        {
+            switch (renderInfo)
+            {
+                case DirtyRect_RenderInfo info:
+                    {
+                        var bitmap = document.LazyBitmaps[info.Resolution];
+                        RectI finalRect = new RectI(VecI.Zero, new(bitmap.PixelSize.Width, bitmap.PixelSize.Height));
+
+                        RectI dirtyRect = new RectI(info.Pos, info.Size).Intersect(finalRect);
+                        bitmap.AddDirtyRect(new(dirtyRect.Left, dirtyRect.Top, dirtyRect.Width, dirtyRect.Height));
+                    }
+                    break;
+                case PreviewDirty_RenderInfo info:
+                    {
+                        var bitmap = document.StructureHelper.Find(info.GuidValue)?.PreviewBitmap;
+                        if (bitmap is null)
+                            continue;
+                        bitmap.AddDirtyRect(new Int32Rect(0, 0, bitmap.PixelSize.Width, bitmap.PixelSize.Height));
+                    }
+                    break;
+                case MaskPreviewDirty_RenderInfo info:
+                    {
+                        var bitmap = document.StructureHelper.Find(info.GuidValue)?.MaskPreviewBitmap;
+                        if (bitmap is null)
+                            continue;
+                        bitmap.AddDirtyRect(new Int32Rect(0, 0, bitmap.PixelSize.Width, bitmap.PixelSize.Height));
+                    }
+                    break;
+                case CanvasPreviewDirty_RenderInfo:
+                    {
+                        document.PreviewBitmap.AddDirtyRect(new Int32Rect(0, 0, document.PreviewBitmap.PixelSize.Width, document.PreviewBitmap.PixelSize.Height));
+                    }
+                    break;
+            }
+        }*/
+    }
+}

+ 201 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/ChangeExecutionController.cs

@@ -0,0 +1,201 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+using PixiEditor.Models.Enums;
+using PixiEditor.Views.UserControls.SymmetryOverlay;
+
+namespace PixiEditor.Models.DocumentModels;
+#nullable enable
+internal class ChangeExecutionController
+{
+    public bool LeftMousePressed { get; private set; }
+    public ShapeCorners LastTransformState { get; private set; }
+    public VecI LastPixelPosition => lastPixelPos;
+    public VecD LastPrecisePosition => lastPrecisePos;
+    public bool IsChangeActive => currentSession is not null;
+
+    private readonly IDocument document;
+    private readonly DocumentInternalParts internals;
+
+    private VecI lastPixelPos;
+    private VecD lastPrecisePos;
+
+    private UpdateableChangeExecutor? currentSession = null;
+    
+    private UpdateableChangeExecutor? _queuedExecutor = null;
+
+    public ChangeExecutionController(IDocument document, DocumentInternalParts internals)
+    {
+        this.document = document;
+        this.internals = internals;
+    }
+
+    public ExecutorType GetCurrentExecutorType()
+    {
+        if (currentSession is null)
+            return ExecutorType.None;
+        return currentSession.Type;
+    }
+
+    public bool TryStartExecutor<T>(bool force = false)
+        where T : UpdateableChangeExecutor, new()
+    {
+        if (CanStartExecutor(force))
+            return false;
+        if (force)
+            currentSession?.ForceStop();
+        
+        T executor = new T();
+        return TryStartExecutorInternal(executor);
+    }
+
+    public bool TryStartExecutor(UpdateableChangeExecutor brandNewExecutor, bool force = false)
+    {
+        if (CanStartExecutor(force))
+            return false;
+        if (force)
+            currentSession?.ForceStop();
+        
+        return TryStartExecutorInternal(brandNewExecutor);
+    }
+
+    private bool CanStartExecutor(bool force)
+    {
+        return (currentSession is not null || _queuedExecutor is not null) && !force;
+    }
+
+    private bool TryStartExecutorInternal(UpdateableChangeExecutor executor)
+    {
+        executor.Initialize(document, internals, this, EndExecutor);
+
+        if (executor.StartMode == ExecutorStartMode.OnMouseLeftButtonDown)
+        {
+            _queuedExecutor = executor;
+            return true;
+        }
+
+        return StartExecutor(executor);
+    }
+    
+    private bool StartExecutor(UpdateableChangeExecutor brandNewExecutor)
+    {
+        if (brandNewExecutor.Start() == ExecutionState.Success)
+        {
+            currentSession = brandNewExecutor;
+            return true;
+        }
+
+        return false;
+    }
+
+    private void EndExecutor(UpdateableChangeExecutor executor)
+    {
+        if (executor != currentSession)
+            throw new InvalidOperationException();
+        currentSession = null;
+        _queuedExecutor = null;
+    }
+
+    public bool TryStopActiveExecutor()
+    {
+        if (currentSession is null)
+            return false;
+        currentSession.ForceStop();
+        currentSession = null;
+        return true;
+    }
+
+    public void MidChangeUndoInlet() => currentSession?.OnMidChangeUndo();
+    public void MidChangeRedoInlet() => currentSession?.OnMidChangeRedo();
+
+    public void ConvertedKeyDownInlet(Key key)
+    {
+        currentSession?.OnConvertedKeyDown(key);
+    }
+
+    public void ConvertedKeyUpInlet(Key key)
+    {
+        currentSession?.OnConvertedKeyUp(key);
+    }
+
+    public void MouseMoveInlet(VecD newCanvasPos)
+    {
+        //update internal state
+        VecI newPixelPos = (VecI)newCanvasPos.Floor();
+        bool pixelPosChanged = false;
+        if (lastPixelPos != newPixelPos)
+        {
+            lastPixelPos = newPixelPos;
+            pixelPosChanged = true;
+        }
+        lastPrecisePos = newCanvasPos;
+
+        //call session events
+        if (currentSession is not null)
+        {
+            if (pixelPosChanged)
+                currentSession.OnPixelPositionChange(newPixelPos);
+            currentSession.OnPrecisePositionChange(newCanvasPos);
+        }
+    }
+
+    public void OpacitySliderDragStartedInlet() => currentSession?.OnOpacitySliderDragStarted();
+    public void OpacitySliderDraggedInlet(float newValue)
+    {
+        currentSession?.OnOpacitySliderDragged(newValue);
+    }
+    public void OpacitySliderDragEndedInlet() => currentSession?.OnOpacitySliderDragEnded();
+
+    public void SymmetryDragStartedInlet(SymmetryAxisDirection dir) => currentSession?.OnSymmetryDragStarted(dir);
+    public void SymmetryDraggedInlet(SymmetryAxisDragInfo info)
+    {
+        currentSession?.OnSymmetryDragged(info);
+    }
+
+    public void SymmetryDragEndedInlet(SymmetryAxisDirection dir) => currentSession?.OnSymmetryDragEnded(dir);
+
+    public void LeftMouseButtonDownInlet(VecD canvasPos)
+    {
+        //update internal state
+        LeftMousePressed = true;
+
+        if (_queuedExecutor != null && currentSession == null)
+        {
+            StartExecutor(_queuedExecutor);
+        }
+        
+        //call session event
+        currentSession?.OnLeftMouseButtonDown(canvasPos);
+    }
+
+    public void LeftMouseButtonUpInlet()
+    {
+        //update internal state
+        LeftMousePressed = false;
+
+        //call session events
+        currentSession?.OnLeftMouseButtonUp();
+    }
+
+    public void TransformMovedInlet(ShapeCorners corners)
+    {
+        LastTransformState = corners;
+        currentSession?.OnTransformMoved(corners);
+    }
+
+    public void TransformAppliedInlet() => currentSession?.OnTransformApplied();
+
+    public void LineOverlayMovedInlet(VecD start, VecD end)
+    {
+        currentSession?.OnLineOverlayMoved(start, end);
+    }
+
+    public void SelectedObjectNudgedInlet(VecI distance)
+    {
+        currentSession?.OnSelectedObjectNudged(distance);
+    }
+}

+ 23 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentInternalParts.cs

@@ -0,0 +1,23 @@
+using PixiEditor.ChangeableDocument;
+using PixiEditor.Models.Containers;
+
+namespace PixiEditor.Models.DocumentModels;
+#nullable enable
+internal class DocumentInternalParts
+{
+    public DocumentInternalParts(IDocument doc)
+    {
+        Tracker = new DocumentChangeTracker();
+        StructureHelper = new DocumentStructureHelper(doc, this);
+        Updater = new DocumentUpdater(doc, this);
+        ActionAccumulator = new ActionAccumulator(doc, this);
+        State = new DocumentState();
+        ChangeController = new ChangeExecutionController(doc, this);
+    }
+    public ActionAccumulator ActionAccumulator { get; }
+    public DocumentChangeTracker Tracker { get; }
+    public DocumentStructureHelper StructureHelper { get; }
+    public DocumentUpdater Updater { get; }
+    public DocumentState State { get; }
+    public ChangeExecutionController ChangeController { get; }
+}

+ 9 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentState.cs

@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+using PixiEditor.Models.Position;
+
+namespace PixiEditor.Models.DocumentModels;
+#nullable enable
+internal class DocumentState
+{
+    public Dictionary<Guid, ViewportInfo> Viewports { get; set; } = new();
+}

+ 142 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentStructureHelper.cs

@@ -0,0 +1,142 @@
+using System.Collections.Generic;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.Extensions.Common.Localization;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.Enums;
+
+namespace PixiEditor.Models.DocumentModels;
+#nullable enable
+internal class DocumentStructureHelper
+{
+    private IDocument doc;
+    private DocumentInternalParts internals;
+    public DocumentStructureHelper(IDocument doc, DocumentInternalParts internals)
+    {
+        this.doc = doc;
+        this.internals = internals;
+    }
+
+    private string GetUniqueName(string name, IFolderHandler folder)
+    {
+        int count = 1;
+        foreach (var child in folder.Children)
+        {
+            string childName = child.NameBindable;
+            if (childName.StartsWith(name))
+                count++;
+        }
+        return $"{name} {count}";
+    }
+
+    public Guid CreateNewStructureMember(StructureMemberType type, string? name = null, bool finish = true)
+    {
+        IStructureMemberHandler? member = doc.SelectedStructureMember;
+        if (member is null)
+        {
+            Guid guid = Guid.NewGuid();
+            //put member on top
+            internals.ActionAccumulator.AddActions(new CreateStructureMember_Action(doc.StructureRoot.GuidValue, guid, doc.StructureRoot.Children.Count, type));
+            name ??= GetUniqueName(type == StructureMemberType.Layer ? new LocalizedString("NEW_LAYER") : new LocalizedString("NEW_FOLDER"), doc.StructureRoot);
+            internals.ActionAccumulator.AddActions(new StructureMemberName_Action(guid, name));
+            if (finish)
+                internals.ActionAccumulator.AddFinishedActions();
+            return guid;
+        }
+        if (member is IFolderHandler folder)
+        {
+            Guid guid = Guid.NewGuid();
+            //put member inside folder on top
+            internals.ActionAccumulator.AddActions(new CreateStructureMember_Action(folder.GuidValue, guid, folder.Children.Count, type));
+            name ??= GetUniqueName(type == StructureMemberType.Layer ? new LocalizedString("NEW_LAYER") : new LocalizedString("NEW_FOLDER"), folder);
+            internals.ActionAccumulator.AddActions(new StructureMemberName_Action(guid, name));
+            if (finish)
+                internals.ActionAccumulator.AddFinishedActions();
+            return guid;
+        }
+        if (member is ILayerHandler layer)
+        {
+            Guid guid = Guid.NewGuid();
+            //put member above the layer
+            List<IStructureMemberHandler> path = doc.StructureHelper.FindPath(layer.GuidValue);
+            if (path.Count < 2)
+                throw new InvalidOperationException("Couldn't find a path to the selected member");
+            IFolderHandler parent = (IFolderHandler)path[1];
+            internals.ActionAccumulator.AddActions(new CreateStructureMember_Action(parent.GuidValue, guid, parent.Children.IndexOf(layer) + 1, type));
+            name ??= GetUniqueName(type == StructureMemberType.Layer ? new LocalizedString("NEW_LAYER") : new LocalizedString("NEW_FOLDER"), parent);
+            internals.ActionAccumulator.AddActions(new StructureMemberName_Action(guid, name));
+            if (finish)
+                internals.ActionAccumulator.AddFinishedActions();
+            return guid;
+        }
+        throw new ArgumentException($"Unknown member type: {type}");
+    }
+
+    private void HandleMoveInside(List<IStructureMemberHandler> memberToMovePath, List<IStructureMemberHandler> memberToMoveIntoPath)
+    {
+        if (memberToMoveIntoPath[0] is not IFolderHandler folder || memberToMoveIntoPath.Contains(memberToMovePath[0]))
+            return;
+        int index = folder.Children.Count;
+        if (memberToMoveIntoPath[0].GuidValue == memberToMovePath[1].GuidValue) // member is already in this folder
+            index--;
+        internals.ActionAccumulator.AddFinishedActions(new MoveStructureMember_Action(memberToMovePath[0].GuidValue, folder.GuidValue, index));
+        return;
+    }
+
+    private void HandleMoveAboveBelow(List<IStructureMemberHandler> memberToMovePath, List<IStructureMemberHandler> memberToMoveRelativeToPath, bool above)
+    {
+        IFolderHandler targetFolder = (IFolderHandler)memberToMoveRelativeToPath[1];
+        if (memberToMovePath[1].GuidValue == memberToMoveRelativeToPath[1].GuidValue)
+        { // members are in the same folder
+            int indexOfMemberToMove = targetFolder.Children.IndexOf(memberToMovePath[0]);
+            int indexOfMemberToMoveAbove = targetFolder.Children.IndexOf(memberToMoveRelativeToPath[0]);
+            int index = indexOfMemberToMoveAbove;
+            if (above)
+                index++;
+            if (indexOfMemberToMove < indexOfMemberToMoveAbove)
+                index--;
+            internals.ActionAccumulator.AddFinishedActions(new MoveStructureMember_Action(memberToMovePath[0].GuidValue, targetFolder.GuidValue, index));
+        }
+        else
+        { // members are in different folders
+            if (memberToMoveRelativeToPath.Contains(memberToMovePath[0]))
+                return;
+            int index = targetFolder.Children.IndexOf(memberToMoveRelativeToPath[0]);
+            if (above)
+                index++;
+            internals.ActionAccumulator.AddFinishedActions(new MoveStructureMember_Action(memberToMovePath[0].GuidValue, targetFolder.GuidValue, index));
+        }
+    }
+
+    public void TryMoveStructureMember(Guid memberToMove, Guid memberToMoveIntoOrNextTo, StructureMemberPlacement placement)
+    {
+        List<IStructureMemberHandler> memberPath = doc.StructureHelper.FindPath(memberToMove);
+        List<IStructureMemberHandler> refPath = doc.StructureHelper.FindPath(memberToMoveIntoOrNextTo);
+        if (memberPath.Count < 2 || refPath.Count < 2)
+            return;
+        switch (placement)
+        {
+            case StructureMemberPlacement.Above:
+                HandleMoveAboveBelow(memberPath, refPath, true);
+                break;
+            case StructureMemberPlacement.Below:
+                HandleMoveAboveBelow(memberPath, refPath, false);
+                break;
+            case StructureMemberPlacement.Inside:
+                HandleMoveInside(memberPath, refPath);
+                break;
+            case StructureMemberPlacement.BelowOutsideFolder:
+                {
+                    IFolderHandler refFolder = (IFolderHandler)refPath[1];
+                    int refIndexInParent = refFolder.Children.IndexOf(refPath[0]);
+                    if (refIndexInParent > 0 || refPath.Count == 2)
+                    {
+                        HandleMoveAboveBelow(memberPath, refPath, false);
+                        break;
+                    }
+                    HandleMoveAboveBelow(memberPath, doc.StructureHelper.FindPath(refPath[1].GuidValue), false);
+                }
+                break;
+        }
+    }
+}

+ 396 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/DocumentUpdater.cs

@@ -0,0 +1,396 @@
+using System.Collections.Generic;
+using Avalonia.Media.Imaging;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+using PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+using PixiEditor.ChangeableDocument.ChangeInfos.Root;
+using PixiEditor.ChangeableDocument.ChangeInfos.Root.ReferenceLayerChangeInfos;
+using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentPassthroughActions;
+using PixiEditor.Models.Enums;
+
+namespace PixiEditor.Models.DocumentModels;
+#nullable enable
+internal class DocumentUpdater
+{
+    private IDocument doc;
+    private DocumentInternalParts helper;
+
+    public DocumentUpdater(IDocument doc, DocumentInternalParts helper)
+    {
+        this.doc = doc;
+        this.helper = helper;
+    }
+
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
+    public void AfterUndoBoundaryPassed()
+    {
+        //TODO: Make sure AllChangesSaved trigger raise property changed itself
+        //doc.RaisePropertyChanged(nameof(doc.AllChangesSaved));
+    }
+
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
+    public void ApplyChangeFromChangeInfo(IChangeInfo arbitraryInfo)
+    {
+        if (arbitraryInfo is null)
+            return;
+
+        switch (arbitraryInfo)
+        {
+            case CreateStructureMember_ChangeInfo info:
+                ProcessCreateStructureMember(info);
+                break;
+            case DeleteStructureMember_ChangeInfo info:
+                ProcessDeleteStructureMember(info);
+                break;
+            case StructureMemberName_ChangeInfo info:
+                ProcessUpdateStructureMemberName(info);
+                break;
+            case StructureMemberIsVisible_ChangeInfo info:
+                ProcessUpdateStructureMemberIsVisible(info);
+                break;
+            case StructureMemberOpacity_ChangeInfo info:
+                ProcessUpdateStructureMemberOpacity(info);
+                break;
+            case MoveStructureMember_ChangeInfo info:
+                ProcessMoveStructureMember(info);
+                break;
+            case Size_ChangeInfo info:
+                ProcessSize(info);
+                break;
+            case RefreshViewport_PassthroughAction info:
+                ProcessRefreshViewport(info);
+                break;
+            case RemoveViewport_PassthroughAction info:
+                ProcessRemoveViewport(info);
+                break;
+            case StructureMemberMask_ChangeInfo info:
+                ProcessStructureMemberMask(info);
+                break;
+            case StructureMemberBlendMode_ChangeInfo info:
+                ProcessStructureMemberBlendMode(info);
+                break;
+            case LayerLockTransparency_ChangeInfo info:
+                ProcessLayerLockTransparency(info);
+                break;
+            case Selection_ChangeInfo info:
+                ProcessSelection(info);
+                break;
+            case SymmetryAxisState_ChangeInfo info:
+                ProcessSymmetryState(info);
+                break;
+            case SymmetryAxisPosition_ChangeInfo info:
+                ProcessSymmetryPosition(info);
+                break;
+            case StructureMemberClipToMemberBelow_ChangeInfo info:
+                ProcessClipToMemberBelow(info);
+                break;
+            case StructureMemberMaskIsVisible_ChangeInfo info:
+                ProcessMaskIsVisible(info);
+                break;
+            case SetReferenceLayer_ChangeInfo info:
+                ProcessSetReferenceLayer(info);
+                break;
+            case DeleteReferenceLayer_ChangeInfo info:
+                ProcessDeleteReferenceLayer(info);
+                break;
+            case TransformReferenceLayer_ChangeInfo info:
+                ProcessTransformReferenceLayer(info);
+                break;
+            case ReferenceLayerIsVisible_ChangeInfo info:
+                ProcessReferenceLayerIsVisible(info);
+                break;
+            case ReferenceLayerTopMost_ChangeInfo info:
+                ProcessReferenceLayerTopMost(info);
+                break;
+            case SetSelectedMember_PassthroughAction info:
+                ProcessSetSelectedMember(info);
+                break;
+            case AddSoftSelectedMember_PassthroughAction info:
+                ProcessAddSoftSelectedMember(info);
+                break;
+            case RemoveSoftSelectedMember_PassthroughAction info:
+                ProcessRemoveSoftSelectedMember(info);
+                break;
+            case ClearSoftSelectedMembers_PassthroughAction info:
+                ProcessClearSoftSelectedMembers(info);
+                break;
+                
+        }
+    }
+
+    private void ProcessReferenceLayerIsVisible(ReferenceLayerIsVisible_ChangeInfo info)
+    {
+        doc.ReferenceLayerHandler.SetReferenceLayerIsVisible(info.IsVisible);
+    }
+
+    private void ProcessTransformReferenceLayer(TransformReferenceLayer_ChangeInfo info)
+    {
+        doc.ReferenceLayerHandler.TransformReferenceLayer(info.Corners);
+    }
+
+    private void ProcessDeleteReferenceLayer(DeleteReferenceLayer_ChangeInfo info)
+    {
+        doc.ReferenceLayerHandler.DeleteReferenceLayer();
+    }
+
+    private void ProcessSetReferenceLayer(SetReferenceLayer_ChangeInfo info)
+    {
+        doc.ReferenceLayerHandler.SetReferenceLayer(info.ImagePbgra32Bytes, info.ImageSize, info.Shape);
+    }
+    
+    private void ProcessReferenceLayerTopMost(ReferenceLayerTopMost_ChangeInfo info)
+    {
+        doc.ReferenceLayerHandler.SetReferenceLayerTopMost(info.IsTopMost);
+    }
+
+    private void ProcessRemoveSoftSelectedMember(RemoveSoftSelectedMember_PassthroughAction info)
+    {
+        IStructureMemberHandler? member = doc.StructureHelper.Find(info.GuidValue);
+        if (member is null || member.Selection == StructureMemberSelectionType.Hard)
+            return;
+        if (member.Selection != StructureMemberSelectionType.Soft)
+            return;
+        member.Selection = StructureMemberSelectionType.None;
+        // TODO: Make sure Selection raises property changed internally
+        //member.RaisePropertyChanged(nameof(member.Selection));
+        doc.RemoveSoftSelectedMember(member);
+    }
+
+    private void ProcessClearSoftSelectedMembers(ClearSoftSelectedMembers_PassthroughAction info)
+    {
+        foreach (IStructureMemberHandler? oldMember in doc.SoftSelectedStructureMembers)
+        {
+            if (oldMember.Selection == StructureMemberSelectionType.Hard)
+                continue;
+            oldMember.Selection = StructureMemberSelectionType.None;
+            //oldMember.RaisePropertyChanged(nameof(oldMember.Selection));
+        }
+        doc.ClearSoftSelectedMembers();
+    }
+
+    private void ProcessAddSoftSelectedMember(AddSoftSelectedMember_PassthroughAction info)
+    {
+        IStructureMemberHandler? member = doc.StructureHelper.Find(info.GuidValue);
+        if (member is null || member.Selection == StructureMemberSelectionType.Hard)
+            return;
+        member.Selection = StructureMemberSelectionType.Soft;
+        //member.RaisePropertyChanged(nameof(member.Selection));
+        doc.AddSoftSelectedMember(member);
+    }
+
+    private void ProcessSetSelectedMember(SetSelectedMember_PassthroughAction info)
+    {
+        if (doc.SelectedStructureMember is { } oldMember)
+        {
+            oldMember.Selection = StructureMemberSelectionType.None;
+            //oldMember.RaisePropertyChanged(nameof(oldMember.Selection));
+        }
+        IStructureMemberHandler? member = doc.StructureHelper.FindOrThrow(info.GuidValue);
+        member.Selection = StructureMemberSelectionType.Hard;
+        //member.RaisePropertyChanged(nameof(member.Selection));
+        doc.SetSelectedMember(member);
+    }
+
+    private void ProcessMaskIsVisible(StructureMemberMaskIsVisible_ChangeInfo info)
+    {
+        IStructureMemberHandler? member = doc.StructureHelper.FindOrThrow(info.GuidValue);
+        member.SetMaskIsVisible(info.IsVisible);
+    }
+
+    private void ProcessClipToMemberBelow(StructureMemberClipToMemberBelow_ChangeInfo info)
+    {
+        IStructureMemberHandler? member = doc.StructureHelper.FindOrThrow(info.GuidValue);
+        member.SetClipToMemberBelowEnabled(info.ClipToMemberBelow);
+    }
+
+    private void ProcessSymmetryPosition(SymmetryAxisPosition_ChangeInfo info)
+    {
+        if (info.Direction == SymmetryAxisDirection.Horizontal)
+            doc.SetHorizontalSymmetryAxisY(info.NewPosition);
+        else if (info.Direction == SymmetryAxisDirection.Vertical)
+            doc.SetVerticalSymmetryAxisX(info.NewPosition);
+    }
+
+    private void ProcessSymmetryState(SymmetryAxisState_ChangeInfo info)
+    {
+        if (info.Direction == SymmetryAxisDirection.Horizontal)
+            doc.SetHorizontalSymmetryAxisEnabled(info.State);
+        else if (info.Direction == SymmetryAxisDirection.Vertical)
+            doc.SetVerticalSymmetryAxisEnabled(info.State);
+    }
+
+    private void ProcessSelection(Selection_ChangeInfo info)
+    {
+        doc.UpdateSelectionPath(info.NewPath);
+    }
+
+    private void ProcessLayerLockTransparency(LayerLockTransparency_ChangeInfo info)
+    {
+        ILayerHandler? layer = (ILayerHandler)doc.StructureHelper.FindOrThrow(info.GuidValue);
+        layer.SetLockTransparency(info.LockTransparency);
+    }
+
+    private void ProcessStructureMemberBlendMode(StructureMemberBlendMode_ChangeInfo info)
+    {
+        IStructureMemberHandler? memberVm = doc.StructureHelper.FindOrThrow(info.GuidValue);
+        memberVm.SetBlendMode(info.BlendMode);
+    }
+
+    private void ProcessStructureMemberMask(StructureMemberMask_ChangeInfo info)
+    {
+        IStructureMemberHandler? memberVm = doc.StructureHelper.FindOrThrow(info.GuidValue);
+
+        memberVm.SetHasMask(info.HasMask);
+        // TODO: Make sure HasMask raises property changed internally
+        //memberVm.RaisePropertyChanged(nameof(memberVm.MaskPreviewBitmap));
+        if (!info.HasMask && memberVm is ILayerHandler layer)
+            layer.ShouldDrawOnMask = false;
+    }
+
+    private void ProcessRefreshViewport(RefreshViewport_PassthroughAction info)
+    {
+        helper.State.Viewports[info.Info.GuidValue] = info.Info;
+    }
+
+    private void ProcessRemoveViewport(RemoveViewport_PassthroughAction info)
+    {
+        helper.State.Viewports.Remove(info.GuidValue);
+    }
+
+    private void ProcessSize(Size_ChangeInfo info)
+    {
+        VecI oldSize = doc.SizeBindable;
+
+        Dictionary<ChunkResolution, WriteableBitmap> newBitmaps = new();
+        foreach ((ChunkResolution res, DrawingSurface surf) in doc.Surfaces)
+        {
+            surf.Dispose();
+            newBitmaps[res] = StructureHelpers.CreateBitmap((VecI)(info.Size * res.Multiplier()));
+            doc.Surfaces[res] = StructureHelpers.CreateDrawingSurface(newBitmaps[res]);
+        }
+
+        doc.LazyBitmaps = newBitmaps;
+
+        doc.SetSize(info.Size);
+        doc.SetVerticalSymmetryAxisX(info.VerticalSymmetryAxisX);
+        doc.SetHorizontalSymmetryAxisY(info.HorizontalSymmetryAxisY);
+
+        VecI documentPreviewSize = StructureHelpers.CalculatePreviewSize(info.Size);
+        doc.PreviewSurface.Dispose();
+        doc.PreviewBitmap = StructureHelpers.CreateBitmap(documentPreviewSize);
+        doc.PreviewSurface = StructureHelpers.CreateDrawingSurface(doc.PreviewBitmap);
+
+        // TODO: Make sure property changed events are raised internally
+        /*doc.RaisePropertyChanged(nameof(doc.LazyBitmaps));
+        doc.RaisePropertyChanged(nameof(doc.PreviewBitmap));*/
+
+        //doc.InternalRaiseSizeChanged(new(doc, oldSize, info.Size));
+    }
+
+    private void ProcessCreateStructureMember(CreateStructureMember_ChangeInfo info)
+    {
+        IFolderHandler? parentFolderVM = (IFolderHandler)doc.StructureHelper.FindOrThrow(info.ParentGuid);
+
+        IStructureMemberHandler memberVM;
+        if (info is CreateLayer_ChangeInfo layerInfo)
+        {
+            memberVM = doc.LayerHandlerFactory.CreateLayerHandler(helper, info.GuidValue);
+            ((ILayerHandler)memberVM).SetLockTransparency(layerInfo.LockTransparency);
+        }
+        else if (info is CreateFolder_ChangeInfo)
+        {
+            memberVM = doc.FolderHandlerFactory.CreateFolderHandler(helper, info.GuidValue);
+        }
+        else
+        {
+            throw new NotSupportedException();
+        }
+
+        memberVM.SetOpacity(info.Opacity);
+        memberVM.SetIsVisible(info.IsVisible);
+        memberVM.SetClipToMemberBelowEnabled(info.ClipToMemberBelow);
+        memberVM.SetName(info.Name);
+        memberVM.SetHasMask(info.HasMask);
+        memberVM.SetMaskIsVisible(info.MaskIsVisible);
+        memberVM.SetBlendMode(info.BlendMode);
+
+        parentFolderVM.Children.Insert(info.Index, memberVM);
+
+        if (info is CreateFolder_ChangeInfo folderInfo)
+        {
+            foreach (CreateStructureMember_ChangeInfo childInfo in folderInfo.Children)
+            {
+                ProcessCreateStructureMember(childInfo);
+            }
+        }
+
+        if (doc.SelectedStructureMember is not null)
+        {
+            doc.SelectedStructureMember.Selection = StructureMemberSelectionType.None;
+            // TODO: Make sure property changed events are raised internally
+            //doc.SelectedStructureMember.RaisePropertyChanged(nameof(doc.SelectedStructureMember.Selection));
+        }
+
+        doc.SetSelectedMember(memberVM);
+        memberVM.Selection = StructureMemberSelectionType.Hard;
+
+        // TODO: Make sure property changed events are raised internally
+        /*doc.RaisePropertyChanged(nameof(doc.SelectedStructureMember));
+        doc.RaisePropertyChanged(nameof(memberVM.Selection));*/
+
+        //doc.InternalRaiseLayersChanged(new LayersChangedEventArgs(info.GuidValue, LayerAction.Add));
+    }
+
+    private void ProcessDeleteStructureMember(DeleteStructureMember_ChangeInfo info)
+    {
+        (IStructureMemberHandler memberVM, IFolderHandler folderVM) = doc.StructureHelper.FindChildAndParentOrThrow(info.GuidValue);
+        folderVM.Children.Remove(memberVM);
+        if (doc.SelectedStructureMember == memberVM)
+            doc.SetSelectedMember(null);
+        doc.ClearSoftSelectedMembers();
+        // TODO: Make sure property changed events are raised internally
+        //doc.InternalRaiseLayersChanged(new LayersChangedEventArgs(info.GuidValue, LayerAction.Remove));
+    }
+
+    private void ProcessUpdateStructureMemberIsVisible(StructureMemberIsVisible_ChangeInfo info)
+    {
+        IStructureMemberHandler? memberVM = doc.StructureHelper.FindOrThrow(info.GuidValue);
+        memberVM.SetIsVisible(info.IsVisible);
+    }
+
+    private void ProcessUpdateStructureMemberName(StructureMemberName_ChangeInfo info)
+    {
+        IStructureMemberHandler? memberVM = doc.StructureHelper.FindOrThrow(info.GuidValue);
+        memberVM.SetName(info.Name);
+    }
+
+    private void ProcessUpdateStructureMemberOpacity(StructureMemberOpacity_ChangeInfo info)
+    {
+        IStructureMemberHandler? memberVM = doc.StructureHelper.FindOrThrow(info.GuidValue);
+        memberVM.SetOpacity(info.Opacity);
+    }
+
+    private void ProcessMoveStructureMember(MoveStructureMember_ChangeInfo info)
+    {
+        (IStructureMemberHandler memberVM, IFolderHandler curFolderVM) = doc.StructureHelper.FindChildAndParentOrThrow(info.GuidValue);
+
+        IFolderHandler? targetFolderVM = (IFolderHandler)doc.StructureHelper.FindOrThrow(info.ParentToGuid);
+
+        curFolderVM.Children.Remove(memberVM);
+        targetFolderVM.Children.Insert(info.NewIndex, memberVM);
+
+        // TODO: Make sure property changed events are raised internally
+        //doc.InternalRaiseLayersChanged(new LayersChangedEventArgs(info.GuidValue, LayerAction.Move));
+    }
+}

+ 48 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentEventsModule.cs

@@ -0,0 +1,48 @@
+using Avalonia.Input;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.Events;
+using PixiEditor.Views.UserControls.SymmetryOverlay;
+
+namespace PixiEditor.Models.DocumentModels.Public;
+internal class DocumentEventsModule
+{
+    private IDocument DocumentsHandler { get; }
+    private DocumentInternalParts Internals { get; }
+
+    public DocumentEventsModule(IDocument documentsHandler, DocumentInternalParts internals)
+    {
+        DocumentsHandler = documentsHandler;
+        Internals = internals;
+    }
+
+    public void OnKeyDown(Key args) { }
+    public void OnKeyUp(Key args) { }
+
+    public void OnConvertedKeyDown(FilteredKeyEventArgs args)
+    {
+        Internals.ChangeController.ConvertedKeyDownInlet(args.Key);
+        DocumentsHandler.TransformHandler.ModifierKeysInlet(args.IsShiftDown, args.IsCtrlDown, args.IsAltDown);
+    }
+    public void OnConvertedKeyUp(FilteredKeyEventArgs args)
+    {
+        Internals.ChangeController.ConvertedKeyUpInlet(args.Key);
+        DocumentsHandler.TransformHandler.ModifierKeysInlet(args.IsShiftDown, args.IsCtrlDown, args.IsAltDown);
+    }
+
+    public void OnCanvasLeftMouseButtonDown(VecD pos) => Internals.ChangeController.LeftMouseButtonDownInlet(pos);
+    public void OnCanvasMouseMove(VecD newPos)
+    {
+        DocumentsHandler.CoordinatesString = $"X: {(int)newPos.X} Y: {(int)newPos.Y}";
+        Internals.ChangeController.MouseMoveInlet(newPos);
+    }
+    public void OnCanvasLeftMouseButtonUp() => Internals.ChangeController.LeftMouseButtonUpInlet();
+    public void OnOpacitySliderDragStarted() => Internals.ChangeController.OpacitySliderDragStartedInlet();
+    public void OnOpacitySliderDragged(float newValue) => Internals.ChangeController.OpacitySliderDraggedInlet(newValue);
+    public void OnOpacitySliderDragEnded() => Internals.ChangeController.OpacitySliderDragEndedInlet();
+    public void OnApplyTransform() => Internals.ChangeController.TransformAppliedInlet();
+    public void OnSymmetryDragStarted(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragStartedInlet(dir);
+    public void OnSymmetryDragged(SymmetryAxisDragInfo info) => Internals.ChangeController.SymmetryDraggedInlet(info);
+    public void OnSymmetryDragEnded(SymmetryAxisDirection dir) => Internals.ChangeController.SymmetryDragEndedInlet(dir);
+}

+ 597 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentOperationsModule.cs

@@ -0,0 +1,597 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.ChangeableDocument.Actions.Undo;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface.Vector;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Helpers.Extensions;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+using PixiEditor.Models.DocumentPassthroughActions;
+using PixiEditor.Models.Enums;
+using PixiEditor.Models.Position;
+
+namespace PixiEditor.Models.DocumentModels.Public;
+#nullable enable
+internal class DocumentOperationsModule
+{
+    private IDocument Document { get; }
+    private DocumentInternalParts Internals { get; }
+
+    public DocumentOperationsModule(IDocument document, DocumentInternalParts internals)
+    {
+        Document = document;
+        Internals = internals;
+    }
+
+    /// <summary>
+    /// Creates a new selection with the size of the document
+    /// </summary>
+    public void SelectAll()
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(
+            new SelectRectangle_Action(new RectI(VecI.Zero, Document.SizeBindable), SelectionMode.Add),
+            new EndSelectRectangle_Action());
+    }
+
+    /// <summary>
+    /// Clears the current selection
+    /// </summary>
+    public void ClearSelection()
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(new ClearSelection_Action());
+    }
+
+    /// <summary>
+    /// Deletes selected pixels
+    /// </summary>
+    /// <param name="clearSelection">Should the selection be cleared</param>
+    public void DeleteSelectedPixels(bool clearSelection = false)
+    {
+        var member = Document.SelectedStructureMember;
+        if (Internals.ChangeController.IsChangeActive || member is null)
+            return;
+        bool drawOnMask = member is ILayerHandler layer ? layer.ShouldDrawOnMask : true;
+        if (drawOnMask && !member.HasMaskBindable)
+            return;
+        Internals.ActionAccumulator.AddActions(new ClearSelectedArea_Action(member.GuidValue, drawOnMask));
+        if (clearSelection)
+            Internals.ActionAccumulator.AddActions(new ClearSelection_Action());
+        Internals.ActionAccumulator.AddFinishedActions();
+    }
+
+    /// <summary>
+    /// Sets the opacity of the member with the guid <paramref name="memberGuid"/>
+    /// </summary>
+    /// <param name="memberGuid">The Guid of the member</param>
+    /// <param name="value">A value between 0 and 1</param>
+    public void SetMemberOpacity(Guid memberGuid, float value)
+    {
+        if (Internals.ChangeController.IsChangeActive || value is > 1 or < 0)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(
+            new StructureMemberOpacity_Action(memberGuid, value),
+            new EndStructureMemberOpacity_Action());
+    }
+
+    /// <summary>
+    /// Adds a new viewport or updates a existing one
+    /// </summary>
+    public void AddOrUpdateViewport(ViewportInfo info) => Internals.ActionAccumulator.AddActions(new RefreshViewport_PassthroughAction(info));
+
+    /// <summary>
+    /// Deletes the viewport with the <paramref name="viewportGuid"/>
+    /// </summary>
+    /// <param name="viewportGuid">The Guid of the viewport to remove</param>
+    public void RemoveViewport(Guid viewportGuid) => Internals.ActionAccumulator.AddActions(new RemoveViewport_PassthroughAction(viewportGuid));
+
+    /// <summary>
+    /// Delete the whole undo stack
+    /// </summary>
+    public void ClearUndo()
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddActions(new DeleteRecordedChanges_Action());
+    }
+
+    /// <summary>
+    /// Pastes the <paramref name="images"/> as new layers
+    /// </summary>
+    /// <param name="images">The images to paste</param>
+    public void PasteImagesAsLayers(List<DataImage> images)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+
+        RectI maxSize = new RectI(VecI.Zero, Document.SizeBindable);
+        foreach (var imageWithName in images)
+        {
+            maxSize = maxSize.Union(new RectI(imageWithName.position, imageWithName.image.Size));
+        }
+
+        if (maxSize.Size != Document.SizeBindable)
+            Internals.ActionAccumulator.AddActions(new ResizeCanvas_Action(maxSize.Size, ResizeAnchor.TopLeft));
+
+        foreach (var imageWithName in images)
+        {
+            var layerGuid = Internals.StructureHelper.CreateNewStructureMember(StructureMemberType.Layer, Path.GetFileName(imageWithName.name));
+            DrawImage(imageWithName.image, new ShapeCorners(new RectD(imageWithName.position, imageWithName.image.Size)), layerGuid, true, false, false);
+        }
+        Internals.ActionAccumulator.AddFinishedActions();
+    }
+
+    /// <summary>
+    /// Creates a new structure member of type <paramref name="type"/> with the name <paramref name="name"/>
+    /// </summary>
+    /// <param name="type">The type of the member</param>
+    /// <param name="name">The name of the member</param>
+    /// <returns>The Guid of the new structure member or null if there is already an active change</returns>
+    public Guid? CreateStructureMember(StructureMemberType type, string? name = null, bool finish = true)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return null;
+        return Internals.StructureHelper.CreateNewStructureMember(type, name, finish);
+    }
+
+    /// <summary>
+    /// Duplicates the layer with the <paramref name="guidValue"/>
+    /// </summary>
+    /// <param name="guidValue">The Guid of the layer</param>
+    public void DuplicateLayer(Guid guidValue)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(new DuplicateLayer_Action(guidValue));
+    }
+
+    /// <summary>
+    /// Delete the member with the <paramref name="guidValue"/>
+    /// </summary>
+    /// <param name="guidValue">The Guid of the layer</param>
+    public void DeleteStructureMember(Guid guidValue)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(new DeleteStructureMember_Action(guidValue));
+    }
+
+    /// <summary>
+    /// Deletes all members with the <paramref name="guids"/>
+    /// </summary>
+    /// <param name="guids">The Guids of the layers to delete</param>
+    public void DeleteStructureMembers(IReadOnlyList<Guid> guids)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(guids.Select(static guid => new DeleteStructureMember_Action(guid)).ToArray());
+    }
+
+    /// <summary>
+    /// Resizes the canvas (Does not upscale the content of the image)
+    /// </summary>
+    /// <param name="newSize">The size the canvas should be resized to</param>
+    /// <param name="anchor">Where the existing content should be put</param>
+    public void ResizeCanvas(VecI newSize, ResizeAnchor anchor)
+    {
+        if (Internals.ChangeController.IsChangeActive || newSize.X > 9999 || newSize.Y > 9999 || newSize.X < 1 || newSize.Y < 1)
+            return;
+
+        if (Document.ReferenceLayerHandler.ReferenceBitmap is not null)
+        {
+            VecI offset = anchor.FindOffsetFor(Document.SizeBindable, newSize);
+            ShapeCorners curShape = Document.ReferenceLayerHandler.ReferenceShapeBindable;
+            ShapeCorners offsetCorners = new ShapeCorners()
+            {
+                TopLeft = curShape.TopLeft + offset,
+                TopRight = curShape.TopRight + offset,
+                BottomLeft = curShape.BottomLeft + offset,
+                BottomRight = curShape.BottomRight + offset,
+            };
+            Internals.ActionAccumulator.AddActions(new TransformReferenceLayer_Action(offsetCorners), new EndTransformReferenceLayer_Action());
+        }
+
+        Internals.ActionAccumulator.AddFinishedActions(new ResizeCanvas_Action(newSize, anchor));
+    }
+
+    /// <summary>
+    /// Resizes the image (Upscales the content of the image)
+    /// </summary>
+    /// <param name="newSize">The size the image should be resized to</param>
+    /// <param name="resampling">The resampling method to use</param>
+    public void ResizeImage(VecI newSize, ResamplingMethod resampling)
+    {
+        if (Internals.ChangeController.IsChangeActive || newSize.X > 9999 || newSize.Y > 9999 || newSize.X < 1 || newSize.Y < 1)
+            return;
+
+        if (Document.ReferenceLayerHandler.ReferenceBitmap is not null)
+        {
+            VecD scale = ((VecD)newSize).Divide(Document.SizeBindable);
+            ShapeCorners curShape = Document.ReferenceLayerHandler.ReferenceShapeBindable;
+            ShapeCorners offsetCorners = new ShapeCorners()
+            {
+                TopLeft = curShape.TopLeft.Multiply(scale),
+                TopRight = curShape.TopRight.Multiply(scale),
+                BottomLeft = curShape.BottomLeft.Multiply(scale),
+                BottomRight = curShape.BottomRight.Multiply(scale),
+            };
+            Internals.ActionAccumulator.AddActions(new TransformReferenceLayer_Action(offsetCorners), new EndTransformReferenceLayer_Action());
+        }
+
+        Internals.ActionAccumulator.AddFinishedActions(new ResizeImage_Action(newSize, resampling));
+    }
+
+    /// <summary>
+    /// Replaces all <paramref name="oldColor"/> with <paramref name="newColor"/>
+    /// </summary>
+    /// <param name="oldColor">The color to replace</param>
+    /// <param name="newColor">The new color</param>
+    public void ReplaceColor(PaletteColor oldColor, PaletteColor newColor)
+    {
+        if (Internals.ChangeController.IsChangeActive || oldColor == newColor)
+            return;
+        
+        Internals.ActionAccumulator.AddFinishedActions(new ReplaceColor_Action(oldColor.ToColor(), newColor.ToColor()));
+        ReplaceInPalette(oldColor, newColor);
+    }
+
+    private void ReplaceInPalette(PaletteColor oldColor, PaletteColor newColor)
+    {
+        int indexOfOldColor = Document.Palette.IndexOf(oldColor);
+        if(indexOfOldColor == -1)
+            return;
+        
+        Document.Palette.RemoveAt(indexOfOldColor);
+        Document.Palette.Insert(indexOfOldColor, newColor);
+    }
+
+    /// <summary>
+    /// Creates a new mask on the <paramref name="member"/>
+    /// </summary>
+    public void CreateMask(IStructureMemberHandler member)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        if (!member.MaskIsVisibleBindable)
+            Internals.ActionAccumulator.AddActions(new StructureMemberMaskIsVisible_Action(true, member.GuidValue));
+        Internals.ActionAccumulator.AddFinishedActions(new CreateStructureMemberMask_Action(member.GuidValue));
+    }
+
+    /// <summary>
+    /// Deletes the mask of the <paramref name="member"/>
+    /// </summary>
+    public void DeleteMask(IStructureMemberHandler member)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(new DeleteStructureMemberMask_Action(member.GuidValue));
+    }
+    
+    /// <summary>
+    /// Applies the mask to the image
+    /// </summary>
+    public void ApplyMask(IStructureMemberHandler member)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        
+        Internals.ActionAccumulator.AddFinishedActions(new ApplyMask_Action(member.GuidValue), new DeleteStructureMemberMask_Action(member.GuidValue));
+    }
+
+    /// <summary>
+    /// Sets the selected structure memeber
+    /// </summary>
+    /// <param name="memberGuid">The Guid of the member to select</param>
+    public void SetSelectedMember(Guid memberGuid) => Internals.ActionAccumulator.AddActions(new SetSelectedMember_PassthroughAction(memberGuid));
+
+    /// <summary>
+    /// Adds a member to the soft selection
+    /// </summary>
+    /// <param name="memberGuid">The Guid of the member to add</param>
+    public void AddSoftSelectedMember(Guid memberGuid) => Internals.ActionAccumulator.AddActions(new AddSoftSelectedMember_PassthroughAction(memberGuid));
+
+    /// <summary>
+    /// Removes a member from the soft selection
+    /// </summary>
+    /// <param name="memberGuid">The Guid of the member to remove</param>
+    public void RemoveSoftSelectedMember(Guid memberGuid) => Internals.ActionAccumulator.AddActions(new RemoveSoftSelectedMember_PassthroughAction(memberGuid));
+
+    /// <summary>
+    /// Clears the soft selection
+    /// </summary>
+    public void ClearSoftSelectedMembers() => Internals.ActionAccumulator.AddActions(new ClearSoftSelectedMembers_PassthroughAction());
+
+    /// <summary>
+    /// Undo last change
+    /// </summary>
+    public void Undo()
+    {
+        if (Internals.ChangeController.IsChangeActive)
+        {
+            Internals.ChangeController.MidChangeUndoInlet();
+            return;
+        }
+        Internals.ActionAccumulator.AddActions(new Undo_Action());
+    }
+
+    /// <summary>
+    /// Redo previously undone change
+    /// </summary>
+    public void Redo()
+    {
+        if (Internals.ChangeController.IsChangeActive)
+        {
+            Internals.ChangeController.MidChangeRedoInlet();
+            return;
+        }
+        Internals.ActionAccumulator.AddActions(new Redo_Action());
+    }
+
+    public void NudgeSelectedObject(VecI distance)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+        {
+            Internals.ChangeController.SelectedObjectNudgedInlet(distance);
+        }    
+    }
+
+    /// <summary>
+    /// Moves a member next to or inside another structure member
+    /// </summary>
+    /// <param name="memberToMove">The member to move</param>
+    /// <param name="memberToMoveIntoOrNextTo">The target member</param>
+    /// <param name="placement">Where to place the <paramref name="memberToMove"/></param>
+    public void MoveStructureMember(Guid memberToMove, Guid memberToMoveIntoOrNextTo, StructureMemberPlacement placement)
+    {
+        if (Internals.ChangeController.IsChangeActive || memberToMove == memberToMoveIntoOrNextTo)
+            return;
+        Internals.StructureHelper.TryMoveStructureMember(memberToMove, memberToMoveIntoOrNextTo, placement);
+    }
+
+    /// <summary>
+    /// Merge all structure members with the Guids inside <paramref name="members"/>
+    /// </summary>
+    public void MergeStructureMembers(IReadOnlyList<Guid> members)
+    {
+        if (Internals.ChangeController.IsChangeActive || members.Count < 2)
+            return;
+        var (child, parent) = Document.StructureHelper.FindChildAndParent(members[0]);
+        if (child is null || parent is null)
+            return;
+        int index = parent.Children.IndexOf(child);
+        Guid newGuid = Guid.NewGuid();
+
+        //make a new layer, put combined image onto it, delete layers that were merged
+        Internals.ActionAccumulator.AddActions(
+            new CreateStructureMember_Action(parent.GuidValue, newGuid, index, StructureMemberType.Layer),
+            new StructureMemberName_Action(newGuid, child.NameBindable),
+            new CombineStructureMembersOnto_Action(members.ToHashSet(), newGuid));
+        foreach (var member in members)
+            Internals.ActionAccumulator.AddActions(new DeleteStructureMember_Action(member));
+        Internals.ActionAccumulator.AddActions(new ChangeBoundary_Action());
+    }
+
+    /// <summary>
+    /// Starts a image transform and pastes the transformed image on the currently selected layer
+    /// </summary>
+    /// <param name="image">The image to paste</param>
+    /// <param name="startPos">Where the transform should start</param>
+    public void PasteImageWithTransform(Surface image, VecI startPos)
+    {
+        if (Document.SelectedStructureMember is null)
+            return;
+        Internals.ChangeController.TryStartExecutor(new PasteImageExecutor(image, startPos));
+    }
+
+    /// <summary>
+    /// Starts a image transform and pastes the transformed image on the currently selected layer
+    /// </summary>
+    /// <param name="image">The image to paste</param>
+    /// <param name="startPos">Where the transform should start</param>
+    public void PasteImageWithTransform(Surface image, VecI startPos, Guid memberGuid, bool drawOnMask)
+    {
+        Internals.ChangeController.TryStartExecutor(new PasteImageExecutor(image, startPos, memberGuid, drawOnMask));
+    }
+
+    /// <summary>
+    /// Starts a transform on the selected area
+    /// </summary>
+    /// <param name="toolLinked">Is this transform started by a tool</param>
+    public void TransformSelectedArea(bool toolLinked)
+    {
+        if (Document.SelectedStructureMember is null ||
+            Internals.ChangeController.IsChangeActive && !toolLinked ||
+            Document.SelectionPathBindable.IsEmpty)
+            return;
+        Internals.ChangeController.TryStartExecutor(new TransformSelectedAreaExecutor(toolLinked));
+    }
+
+    /// <summary>
+    /// Ties stopping the currently executing tool linked executor
+    /// </summary>
+    public void TryStopToolLinkedExecutor()
+    {
+        if (Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked)
+            Internals.ChangeController.TryStopActiveExecutor();
+    }
+
+    public void DrawImage(Surface image, ShapeCorners corners, Guid memberGuid, bool ignoreClipSymmetriesEtc, bool drawOnMask) =>
+        DrawImage(image, corners, memberGuid, ignoreClipSymmetriesEtc, drawOnMask, true);
+
+    /// <summary>
+    /// Draws a image on the member with the <paramref name="memberGuid"/>
+    /// </summary>
+    /// <param name="image">The image to draw onto the layer</param>
+    /// <param name="corners">The shape the image should fit into</param>
+    /// <param name="memberGuid">The Guid of the member to paste on</param>
+    /// <param name="ignoreClipSymmetriesEtc">Ignore selection clipping and symmetry (See DrawingChangeHelper.ApplyClipsSymmetriesEtc of UpdateableDocument)</param>
+    /// <param name="drawOnMask">Draw on the mask or on the image</param>
+    /// <param name="finish">Is this a finished action</param>
+    private void DrawImage(Surface image, ShapeCorners corners, Guid memberGuid, bool ignoreClipSymmetriesEtc, bool drawOnMask, bool finish)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddActions(
+            new PasteImage_Action(image, corners, memberGuid, ignoreClipSymmetriesEtc, drawOnMask),
+            new EndPasteImage_Action());
+        if (finish)
+            Internals.ActionAccumulator.AddFinishedActions();
+    }
+
+    /// <summary>
+    /// Resizes the canvas to fit the content
+    /// </summary>
+    public void ClipCanvas()
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ActionAccumulator.AddFinishedActions(new ClipCanvas_Action());
+    }
+
+    /// <summary>
+    /// Flips the image on the <paramref name="flipType"/> axis
+    /// </summary>
+    public void FlipImage(FlipType flipType) => FlipImage(flipType, null);
+
+    /// <summary>
+    /// Flips the members with the Guids of <paramref name="membersToFlip"/> on the <paramref name="flipType"/> axis
+    /// </summary>
+    public void FlipImage(FlipType flipType, List<Guid> membersToFlip)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        
+        Internals.ActionAccumulator.AddFinishedActions(new FlipImage_Action(flipType, membersToFlip));
+    }
+
+    /// <summary>
+    /// Rotates the image
+    /// </summary>
+    /// <param name="rotation">The degrees to rotate the image by</param>
+    public void RotateImage(RotationAngle rotation) => RotateImage(rotation, null);
+
+    /// <summary>
+    /// Rotates the members with the Guids of <paramref name="membersToRotate"/>
+    /// </summary>
+    /// <param name="rotation">The degrees to rotate the members by</param>
+    public void RotateImage(RotationAngle rotation, List<Guid> membersToRotate)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+        
+        Internals.ActionAccumulator.AddFinishedActions(new RotateImage_Action(rotation, membersToRotate));
+    }
+    
+    /// <summary>
+    /// Puts the content of the image in the middle of the canvas
+    /// </summary>
+    public void CenterContent(IReadOnlyList<Guid> structureMembers)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+
+        Internals.ActionAccumulator.AddFinishedActions(new CenterContent_Action(structureMembers.ToList()));
+    }
+
+    /// <summary>
+    /// Imports a reference layer from a Pbgra Int32 array
+    /// </summary>
+    /// <param name="imageSize">The size of the image</param>
+    public void ImportReferenceLayer(ImmutableArray<byte> imagePbgra32Bytes, VecI imageSize)
+    {
+        if (Internals.ChangeController.IsChangeActive)
+            return;
+
+        RectD referenceImageRect = new RectD(VecD.Zero, Document.SizeBindable).AspectFit(new RectD(VecD.Zero, imageSize));
+        ShapeCorners corners = new ShapeCorners(referenceImageRect);
+        Internals.ActionAccumulator.AddFinishedActions(new SetReferenceLayer_Action(corners, imagePbgra32Bytes, imageSize));
+    }
+
+    /// <summary>
+    /// Deletes the reference layer
+    /// </summary>
+    public void DeleteReferenceLayer()
+    {
+        if (Internals.ChangeController.IsChangeActive || Document.ReferenceLayerHandler.ReferenceBitmap is null)
+            return;
+
+        Internals.ActionAccumulator.AddFinishedActions(new DeleteReferenceLayer_Action());
+    }
+
+    /// <summary>
+    /// Starts a transform on the reference layer
+    /// </summary>
+    public void TransformReferenceLayer()
+    {
+        if (Document.ReferenceLayerHandler.ReferenceBitmap is null || Internals.ChangeController.IsChangeActive)
+            return;
+        Internals.ChangeController.TryStartExecutor(new TransformReferenceLayerExecutor());
+    }
+
+    /// <summary>
+    /// Resets the reference layer transform
+    /// </summary>
+    public void ResetReferenceLayerPosition()
+    {
+        if (Document.ReferenceLayerHandler.ReferenceBitmap is null || Internals.ChangeController.IsChangeActive)
+            return;
+
+        VecD size = new(Document.ReferenceLayerHandler.ReferenceBitmap.PixelSize.Width, Document.ReferenceLayerHandler.ReferenceBitmap.PixelSize.Height);
+        RectD referenceImageRect = new RectD(VecD.Zero, Document.SizeBindable).AspectFit(new RectD(VecD.Zero, size));
+        ShapeCorners corners = new ShapeCorners(referenceImageRect);
+        Internals.ActionAccumulator.AddFinishedActions(
+            new TransformReferenceLayer_Action(corners),
+            new EndTransformReferenceLayer_Action()
+            );
+    }
+
+    public void SelectionToMask(SelectionMode mode)
+    {
+        if (Document.SelectedStructureMember is not { } member || Document.SelectionPathBindable.IsEmpty)
+            return;
+
+        if (!Document.SelectedStructureMember.HasMaskBindable)
+        {
+            Internals.ActionAccumulator.AddActions(new CreateStructureMemberMask_Action(member.GuidValue));
+        }
+        
+        Internals.ActionAccumulator.AddFinishedActions(new SelectionToMask_Action(member.GuidValue, mode));
+    }
+
+    public void CropToSelection(bool clearSelection = true)
+    {
+        var bounds = Document.SelectionPathBindable.TightBounds;
+        if (Document.SelectionPathBindable.IsEmpty || bounds.Width <= 0 || bounds.Height <= 0)
+            return;
+
+        Internals.ActionAccumulator.AddActions(new Crop_Action((RectI)bounds));
+
+        if (clearSelection)
+        {
+            Internals.ActionAccumulator.AddFinishedActions(new ClearSelection_Action());
+        }
+        else
+        {
+            Internals.ActionAccumulator.AddFinishedActions();
+        }
+    }
+    
+    public void InvertSelection()
+    {
+        var selection = Document.SelectionPathBindable;
+        var inverse = new VectorPath();
+        inverse.AddRect(new RectI(new(0, 0), Document.SizeBindable));
+
+        Internals.ActionAccumulator.AddFinishedActions(new SetSelection_Action(inverse.Op(selection, VectorPathOp.Difference)));
+    }
+}

+ 123 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentStructureModule.cs

@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+using PixiEditor.Models.Containers;
+
+namespace PixiEditor.Models.DocumentModels.Public;
+#nullable enable
+internal class DocumentStructureModule
+{
+    private readonly IDocument doc;
+    public DocumentStructureModule(IDocument owner)
+    {
+        this.doc = owner;
+    }
+
+    public IStructureMemberHandler FindOrThrow(Guid guid) => Find(guid) ?? throw new ArgumentException("Could not find member with guid " + guid.ToString());
+    public IStructureMemberHandler? Find(Guid guid)
+    {
+        List<IStructureMemberHandler>? list = FindPath(guid);
+        return list.Count > 0 ? list[0] : null;
+    }
+
+    public IStructureMemberHandler? FindFirstWhere(Predicate<IStructureMemberHandler> predicate)
+    {
+        return FindFirstWhere(predicate, doc.StructureRoot);
+    }
+    private IStructureMemberHandler? FindFirstWhere(Predicate<IStructureMemberHandler> predicate, IFolderHandler folderVM)
+    {
+        foreach (IStructureMemberHandler? child in folderVM.Children)
+        {
+            if (predicate(child))
+                return child;
+            if (child is IFolderHandler innerFolderVM)
+            {
+                IStructureMemberHandler? result = FindFirstWhere(predicate, innerFolderVM);
+                if (result is not null)
+                    return result;
+            }
+        }
+        return null;
+    }
+
+    public (IStructureMemberHandler?, IFolderHandler?) FindChildAndParent(Guid childGuid)
+    {
+        List<IStructureMemberHandler>? path = FindPath(childGuid);
+        return path.Count switch
+        {
+            0 => (null, null),
+            1 => (path[0], null),
+            >= 2 => (path[0], (IFolderHandler)path[1]),
+            _ => (null, null),
+        }; 
+    }
+
+    public (IStructureMemberHandler, IFolderHandler) FindChildAndParentOrThrow(Guid childGuid)
+    {
+        List<IStructureMemberHandler>? path = FindPath(childGuid);
+        if (path.Count < 2)
+            throw new ArgumentException("Couldn't find child and parent");
+        return (path[0], (IFolderHandler)path[1]);
+    }
+    public List<IStructureMemberHandler> FindPath(Guid guid)
+    {
+        List<IStructureMemberHandler>? list = new List<IStructureMemberHandler>();
+        if (FillPath(doc.StructureRoot, guid, list))
+            list.Add(doc.StructureRoot);
+        return list;
+    }
+    
+    /// <summary>
+    ///     Returns all layers in the document.
+    /// </summary>
+    /// <returns>List of ILayerHandlers. Empty if no layers found.</returns>
+    public List<ILayerHandler> GetAllLayers()
+    {
+        List<ILayerHandler> layers = new List<ILayerHandler>();
+        foreach (IStructureMemberHandler? member in doc.StructureRoot.Children)
+        {
+            if (member is ILayerHandler layer)
+                layers.Add(layer);
+            else if (member is IFolderHandler folder)
+                layers.AddRange(GetAllLayers(folder, layers));
+        }
+        
+        return layers;
+    }
+    
+    private List<ILayerHandler> GetAllLayers(IFolderHandler folder, List<ILayerHandler> layers)
+    {
+        foreach (IStructureMemberHandler? member in folder.Children)
+        {
+            if (member is ILayerHandler layer)
+                layers.Add(layer);
+            else if (member is IFolderHandler innerFolder)
+                layers.AddRange(GetAllLayers(innerFolder, layers));
+        }
+        return layers;
+    }
+
+    private bool FillPath(IFolderHandler folder, Guid guid, List<IStructureMemberHandler> toFill)
+    {
+        if (folder.GuidValue == guid)
+        {
+            return true;
+        }
+        foreach (IStructureMemberHandler? member in folder.Children)
+        {
+            if (member is ILayerHandler childLayer && childLayer.GuidValue == guid)
+            {
+                toFill.Add(member);
+                return true;
+            }
+
+            if (member is IFolderHandler childFolder)
+            {
+                if (FillPath(childFolder, guid, toFill))
+                {
+                    toFill.Add(childFolder);
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+}

+ 58 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/Public/DocumentToolsModule.cs

@@ -0,0 +1,58 @@
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+using PixiEditor.Models.Enums;
+
+namespace Models.DocumentModels.Public;
+internal class DocumentToolsModule
+{
+    private IDocumentManagerHandler DocumentManager { get; set; }
+    private DocumentInternalParts Internals { get; set; }
+
+    public DocumentToolsModule(IDocumentManagerHandler doc, DocumentInternalParts internals)
+    {
+        this.DocumentManager = doc;
+        this.Internals = internals;
+    }
+
+    public void UseSymmetry(SymmetryAxisDirection dir) => Internals.ChangeController.TryStartExecutor(new SymmetryExecutor(dir));
+
+    public void UseOpacitySlider() => Internals.ChangeController.TryStartExecutor<StructureMemberOpacityExecutor>();
+
+    public void UseShiftLayerTool() => Internals.ChangeController.TryStartExecutor<ShiftLayerExecutor>();
+
+    public void UsePenTool() => Internals.ChangeController.TryStartExecutor<PenToolExecutor>();
+
+    public void UseEraserTool() => Internals.ChangeController.TryStartExecutor<EraserToolExecutor>();
+
+    public void UseColorPickerTool() => Internals.ChangeController.TryStartExecutor<ColorPickerToolExecutor>();
+
+    public void UseRectangleTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<RectangleToolExecutor>(force);
+    }
+
+    public void UseEllipseTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<EllipseToolExecutor>(force);
+    }
+
+    public void UseLineTool()
+    {
+        bool force = Internals.ChangeController.GetCurrentExecutorType() == ExecutorType.ToolLinked;
+        Internals.ChangeController.TryStartExecutor<LineToolExecutor>(force);
+    }
+
+    public void UseSelectTool() => Internals.ChangeController.TryStartExecutor<SelectToolExecutor>();
+
+    public void UseBrightnessTool() => Internals.ChangeController.TryStartExecutor<BrightnessToolExecutor>();
+
+    public void UseFloodFillTool() => Internals.ChangeController.TryStartExecutor<FloodFillToolExecutor>();
+
+    public void UseLassoTool() => Internals.ChangeController.TryStartExecutor<LassoToolExecutor>();
+
+    public void UseMagicWandTool() => Internals.ChangeController.TryStartExecutor<MagicWandToolExecutor>();
+}

+ 52 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/BrightnessToolExecutor.cs

@@ -0,0 +1,52 @@
+using Avalonia.Input;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.Enums;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class BrightnessToolExecutor : UpdateableChangeExecutor
+{
+    private Guid guidValue;
+    private bool repeat;
+    private float correctionFactor;
+    private int toolSize;
+
+    public override ExecutionState Start()
+    {
+        IStructureMemberHandler? member = document!.SelectedStructureMember;
+        BrightnessToolViewModel? tool = vm?.ToolsSubViewModel.GetTool<BrightnessToolViewModel>();
+        if (vm is null || tool is null || member is null)
+            return ExecutionState.Error;
+        if (member is not ILayerHandler layer || layer.ShouldDrawOnMask)
+            return ExecutionState.Error;
+
+        guidValue = member.GuidValue;
+        repeat = tool.BrightnessMode == BrightnessMode.Repeat;
+        toolSize = tool.ToolSize;
+        correctionFactor = tool.Darken || tool.UsedWith == MouseButton.Right ? -tool.CorrectionFactor : tool.CorrectionFactor;
+
+        ChangeBrightness_Action action = new(guidValue, controller!.LastPixelPosition, correctionFactor, toolSize, repeat);
+        internals!.ActionAccumulator.AddActions(action);
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        ChangeBrightness_Action action = new(guidValue, pos, correctionFactor, toolSize, repeat);
+        internals!.ActionAccumulator.AddActions(action);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndChangeBrightness_Action());
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndChangeBrightness_Action());
+    }
+}

+ 52 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/ColorPickerToolExecutor.cs

@@ -0,0 +1,52 @@
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+
+#nullable enable
+internal class ColorPickerToolExecutor : UpdateableChangeExecutor
+{
+    private bool includeReference;
+    private bool includeCanvas;
+    private DocumentScope scope;
+    private ColorsViewModel? colorsViewModel;
+
+    public override ExecutionState Start()
+    {
+        colorsViewModel = ViewModelMain.Current?.ColorsSubViewModel;
+        ColorPickerToolViewModel? tool = ViewModelMain.Current?.ToolsSubViewModel.GetTool<ColorPickerToolViewModel>();
+
+        if (colorsViewModel is null || tool is null)
+            return ExecutionState.Error;
+
+        scope = tool.Mode;
+        includeReference = tool.PickFromReferenceLayer && document!.ReferenceLayerViewModel.ReferenceBitmap is not null;
+        includeCanvas = tool.PickFromCanvas;
+        
+        colorsViewModel.PrimaryColor = document.PickColor(controller.LastPrecisePosition, scope, includeReference, includeCanvas, document.ReferenceLayerViewModel.IsTopMost);
+        return ExecutionState.Success;
+    }
+
+    public override void OnPrecisePositionChange(VecD pos)
+    {
+        if (!includeReference)
+            return;
+        colorsViewModel.PrimaryColor = document.PickColor(pos, scope, includeReference, includeCanvas, document.ReferenceLayerViewModel.IsTopMost);
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        colorsViewModel.PrimaryColor = document.PickColor(pos, scope, includeReference, includeCanvas, document.ReferenceLayerViewModel.IsTopMost);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        
+    }
+}

+ 36 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/EllipseToolExecutor.cs

@@ -0,0 +1,36 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.Actions.Generated;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class EllipseToolExecutor : ShapeToolExecutor<EllipseToolViewModel>
+{
+    private void DrawEllipseOrCircle(VecI curPos, bool firstDraw)
+    {
+        RectI rect;
+        if (firstDraw)
+            rect = new RectI(curPos, VecI.Zero);
+        else if (toolViewModel!.DrawCircle)
+            rect = GetSquaredCoordinates(startPos, curPos);
+        else
+            rect = RectI.FromTwoPixels(startPos, curPos);
+
+        lastRect = rect;
+
+        internals!.ActionAccumulator.AddActions(new DrawEllipse_Action(memberGuid, rect, strokeColor, fillColor, strokeWidth, drawOnMask));
+    }
+
+    public override ExecutorType Type => ExecutorType.ToolLinked;
+    protected override DocumentTransformMode TransformMode => DocumentTransformMode.Scale_NoRotate_NoShear_NoPerspective;
+    protected override void DrawShape(VecI currentPos, bool firstDraw) => DrawEllipseOrCircle(currentPos, firstDraw);
+
+    protected override IAction TransformMovedAction(ShapeData data, ShapeCorners corners) =>
+        new DrawEllipse_Action(memberGuid, (RectI)RectD.FromCenterAndSize(data.Center, data.Size), strokeColor,
+            fillColor, strokeWidth, drawOnMask);
+
+    protected override IAction EndDrawAction() => new EndDrawEllipse_Action();
+}

+ 63 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/EraserToolExecutor.cs

@@ -0,0 +1,63 @@
+#nullable enable
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+
+internal class EraserToolExecutor : UpdateableChangeExecutor
+{
+    private Guid guidValue;
+    private Color color;
+    private int toolSize;
+    private bool drawOnMask;
+
+    public override ExecutionState Start()
+    {
+        ViewModelMain? vm = ViewModelMain.Current;
+        StructureMemberViewModel? member = document!.SelectedStructureMember;
+        EraserToolViewModel? eraserTool = (EraserToolViewModel?)(vm?.ToolsSubViewModel.GetTool<EraserToolViewModel>());
+        BasicToolbar? toolbar = eraserTool?.Toolbar as BasicToolbar;
+        if (vm is null || eraserTool is null || member is null || toolbar is null)
+            return ExecutionState.Error;
+        drawOnMask = member is LayerViewModel layer ? layer.ShouldDrawOnMask : true;
+        if (drawOnMask && !member.HasMaskBindable)
+            return ExecutionState.Error;
+        if (!drawOnMask && member is not LayerViewModel)
+            return ExecutionState.Error;
+
+        guidValue = member.GuidValue;
+        color = vm.ColorsSubViewModel.PrimaryColor;
+        toolSize = toolbar.ToolSize;
+
+        vm.ColorsSubViewModel.AddSwatch(new PaletteColor(color.R, color.G, color.B));
+        IAction? action = new LineBasedPen_Action(guidValue, DrawingApi.Core.ColorsImpl.Colors.Transparent, controller!.LastPixelPosition, toolSize, true,
+            drawOnMask);
+        internals!.ActionAccumulator.AddActions(action);
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        IAction? action = new LineBasedPen_Action(guidValue, DrawingApi.Core.ColorsImpl.Colors.Transparent, pos, toolSize, true, drawOnMask);
+        internals!.ActionAccumulator.AddActions(action);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndLineBasedPen_Action());
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        IAction? action = new EndLineBasedPen_Action();
+        internals!.ActionAccumulator.AddFinishedActions(action);
+    }
+}

+ 58 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/FloodFillToolExecutor.cs

@@ -0,0 +1,58 @@
+using PixiEditor.ChangeableDocument.Actions.Undo;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class FloodFillToolExecutor : UpdateableChangeExecutor
+{
+    private bool considerAllLayers;
+    private bool drawOnMask;
+    private Guid memberGuid;
+    private Color color;
+
+    public override ExecutionState Start()
+    {
+        var fillTool = ViewModelMain.Current?.ToolsSubViewModel.GetTool<FloodFillToolViewModel>();
+        ColorsViewModel? colorsVM = ViewModelMain.Current?.ColorsSubViewModel;
+        var member = document!.SelectedStructureMember;
+
+        if (fillTool is null || member is null || colorsVM is null)
+            return ExecutionState.Error;
+        drawOnMask = member is LayerViewModel layer ? layer.ShouldDrawOnMask : true;
+        if (drawOnMask && !member.HasMaskBindable)
+            return ExecutionState.Error;
+        if (!drawOnMask && member is not LayerViewModel)
+            return ExecutionState.Error;
+
+        colorsVM.AddSwatch(new PaletteColor(color.R, color.G, color.B));
+        memberGuid = member.GuidValue;
+        considerAllLayers = fillTool.ConsiderAllLayers;
+        color = colorsVM.PrimaryColor;
+        var pos = controller!.LastPixelPosition;
+
+        internals!.ActionAccumulator.AddActions(new FloodFill_Action(memberGuid, pos, color, considerAllLayers, drawOnMask));
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        internals!.ActionAccumulator.AddActions(new FloodFill_Action(memberGuid, pos, color, considerAllLayers, drawOnMask));
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        internals!.ActionAccumulator.AddActions(new ChangeBoundary_Action());
+        onEnded!(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddActions(new ChangeBoundary_Action());
+    }
+}

+ 44 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/LassoToolExecutor.cs

@@ -0,0 +1,44 @@
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+
+internal sealed class LassoToolExecutor : UpdateableChangeExecutor
+{
+    private SelectionMode? mode;
+    
+    public override ExecutionState Start()
+    {
+        mode = ViewModelMain.Current?.ToolsSubViewModel.GetTool<LassoToolViewModel>()?.ResultingSelectionMode;
+
+        if (mode is null)
+            return ExecutionState.Error;
+        
+        AddStartAction(controller!.LastPixelPosition);
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnPixelPositionChange(VecI pos) => AddStartAction(pos);
+
+    public override void OnLeftMouseButtonUp()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndSelectLasso_Action());
+        onEnded!(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndSelectLasso_Action());
+    }
+
+    private void AddStartAction(VecI pos)
+    {
+        var action = new SelectLasso_Action(pos, mode!.Value);
+        
+        internals!.ActionAccumulator.AddActions(action);
+    }
+}

+ 123 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/LineToolExecutor.cs

@@ -0,0 +1,123 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class LineToolExecutor : UpdateableChangeExecutor
+{
+    public override ExecutorType Type => ExecutorType.ToolLinked;
+
+    private VecI startPos;
+    private Color strokeColor;
+    private int strokeWidth;
+    private Guid memberGuid;
+    private bool drawOnMask;
+
+    private VecI curPos;
+    private bool started = false;
+    private bool transforming = false;
+    private LineToolViewModel? toolViewModel;
+
+    public override ExecutionState Start()
+    {
+        ColorsViewModel? colorsVM = ViewModelMain.Current?.ColorsSubViewModel;
+        toolViewModel = ViewModelMain.Current?.ToolsSubViewModel.GetTool<LineToolViewModel>();
+        StructureMemberViewModel? member = document?.SelectedStructureMember;
+        if (colorsVM is null || toolViewModel is null || member is null)
+            return ExecutionState.Error;
+
+        drawOnMask = member is LayerViewModel layer ? layer.ShouldDrawOnMask : true;
+        if (drawOnMask && !member.HasMaskBindable)
+            return ExecutionState.Error;
+        if (!drawOnMask && member is not LayerViewModel)
+            return ExecutionState.Error;
+
+        startPos = controller!.LastPixelPosition;
+        strokeColor = colorsVM.PrimaryColor;
+        strokeWidth = toolViewModel.ToolSize;
+        memberGuid = member.GuidValue;
+
+        colorsVM.AddSwatch(new PaletteColor(strokeColor.R, strokeColor.G, strokeColor.B));
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        if (transforming)
+            return;
+        started = true;
+
+        if (toolViewModel!.Snap)
+            pos = ShapeToolExecutor<ShapeTool>.Get45IncrementedPosition(startPos, pos);
+        curPos = pos;
+        internals!.ActionAccumulator.AddActions(new DrawLine_Action(memberGuid, startPos, pos, strokeWidth, strokeColor, StrokeCap.Butt, drawOnMask));
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        if (!started)
+        {
+            onEnded!(this);
+            return;
+        }
+
+        document!.LineToolOverlayViewModel.Show(startPos + new VecD(0.5), curPos + new VecD(0.5));
+        transforming = true;
+    }
+
+    public override void OnLineOverlayMoved(VecD start, VecD end)
+    {
+        if (!transforming)
+            return;
+        internals!.ActionAccumulator.AddActions(new DrawLine_Action(memberGuid, (VecI)start, (VecI)end, strokeWidth, strokeColor, StrokeCap.Butt, drawOnMask));
+    }
+
+    public override void OnSelectedObjectNudged(VecI distance)
+    {
+        if (!transforming)
+            return;
+        document!.LineToolOverlayViewModel.Nudge(distance);
+    }
+
+    public override void OnMidChangeUndo()
+    {
+        if (!transforming)
+            return;
+        document!.LineToolOverlayViewModel.Undo();
+    }
+
+    public override void OnMidChangeRedo()
+    {
+        if (!transforming)
+            return;
+        document!.LineToolOverlayViewModel.Redo();
+    }
+
+    public override void OnTransformApplied()
+    {
+        if (!transforming)
+            return;
+
+        document!.LineToolOverlayViewModel.Hide();
+        internals!.ActionAccumulator.AddFinishedActions(new EndDrawLine_Action());
+        onEnded!(this);
+    }
+
+    public override void ForceStop()
+    {
+        if (transforming)
+            document!.LineToolOverlayViewModel.Hide();
+
+        internals!.ActionAccumulator.AddFinishedActions(new EndDrawLine_Action());
+    }
+}

+ 47 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/MagicWandToolExecutor.cs

@@ -0,0 +1,47 @@
+using PixiEditor.ChangeableDocument.Actions.Undo;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+
+internal class MagicWandToolExecutor : UpdateableChangeExecutor
+{
+    private bool considerAllLayers;
+    private bool drawOnMask;
+    private List<Guid> memberGuids;
+    private SelectionMode mode;
+
+    public override ExecutionState Start()
+    {
+        var magicWand = ViewModelMain.Current?.ToolsSubViewModel.GetTool<MagicWandToolViewModel>();
+        var members = document!.ExtractSelectedLayers(true);
+
+        if (magicWand is null || members.Count == 0)
+            return ExecutionState.Error;
+
+        mode = magicWand.SelectMode;
+        memberGuids = members;
+        considerAllLayers = magicWand.DocumentScope == DocumentScope.AllLayers;
+        if (considerAllLayers)
+            memberGuids = document!.StructureHelper.GetAllLayers().Select(x => x.GuidValue).ToList();
+        var pos = controller!.LastPixelPosition;
+
+        internals!.ActionAccumulator.AddActions(new MagicWand_Action(memberGuids, pos, mode));
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        internals!.ActionAccumulator.AddActions(new ChangeBoundary_Action());
+        onEnded!(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddActions(new ChangeBoundary_Action());
+    }
+}

+ 80 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/PasteImageExecutor.cs

@@ -0,0 +1,80 @@
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class PasteImageExecutor : UpdateableChangeExecutor
+{
+    private readonly Surface image;
+    private readonly VecI pos;
+    private bool drawOnMask;
+    private Guid? memberGuid;
+
+    public PasteImageExecutor(Surface image, VecI pos)
+    {
+        this.image = image;
+        this.pos = pos;
+    }
+
+    public PasteImageExecutor(Surface image, VecI pos, Guid memberGuid, bool drawOnMask)
+    {
+        this.image = image;
+        this.pos = pos;
+        this.memberGuid = memberGuid;
+        this.drawOnMask = drawOnMask;
+    }
+    
+    public override ExecutionState Start()
+    {
+        if (memberGuid == null)
+        {
+            var member = document!.SelectedStructureMember;
+
+            if (member is null)
+                return ExecutionState.Error;
+            drawOnMask = member is not LayerViewModel layer || layer.ShouldDrawOnMask;
+            
+            switch (drawOnMask)
+            {
+                case true when !member.HasMaskBindable:
+                case false when member is not LayerViewModel:
+                    return ExecutionState.Error;
+            }
+            
+            memberGuid = member.GuidValue;
+        }
+
+        ShapeCorners corners = new(new RectD(pos, image.Size));
+        internals!.ActionAccumulator.AddActions(new PasteImage_Action(image, corners, memberGuid.Value, false, drawOnMask));
+        document.TransformViewModel.ShowTransform(DocumentTransformMode.Scale_Rotate_Shear_Perspective, true, corners, true);
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnTransformMoved(ShapeCorners corners)
+    {
+        internals!.ActionAccumulator.AddActions(new PasteImage_Action(image, corners, memberGuid.Value, false, drawOnMask));
+    }
+
+    public override void OnSelectedObjectNudged(VecI distance) => document!.TransformViewModel.Nudge(distance);
+
+    public override void OnMidChangeUndo() => document!.TransformViewModel.Undo();
+
+    public override void OnMidChangeRedo() => document!.TransformViewModel.Redo();
+
+    public override void OnTransformApplied()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndPasteImage_Action());
+        document!.TransformViewModel.HideTransform();
+        onEnded!.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        document!.TransformViewModel.HideTransform();
+        internals!.ActionAccumulator.AddActions(new EndPasteImage_Action());
+    }
+}

+ 81 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/PenToolExecutor.cs

@@ -0,0 +1,81 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class PenToolExecutor : UpdateableChangeExecutor
+{
+    private Guid guidValue;
+    private Color color;
+    private int toolSize;
+    private bool drawOnMask;
+    private bool pixelPerfect;
+
+    public override ExecutionState Start()
+    {
+        ViewModelMain? vm = ViewModelMain.Current;
+        StructureMemberViewModel? member = document!.SelectedStructureMember;
+        PenToolViewModel? penTool = vm?.ToolsSubViewModel.GetTool<PenToolViewModel>();
+        if (vm is null || penTool is null || member is null || penTool?.Toolbar is not BasicToolbar toolbar)
+            return ExecutionState.Error;
+        drawOnMask = member is not LayerViewModel layer || layer.ShouldDrawOnMask;
+        if (drawOnMask && !member.HasMaskBindable)
+            return ExecutionState.Error;
+        if (!drawOnMask && member is not LayerViewModel)
+            return ExecutionState.Error;
+
+        guidValue = member.GuidValue;
+        color = vm.ColorsSubViewModel.PrimaryColor;
+        toolSize = toolbar.ToolSize;
+        pixelPerfect = penTool.PixelPerfectEnabled;
+
+        vm.ColorsSubViewModel.AddSwatch(new PaletteColor(color.R, color.G, color.B));
+        IAction? action = pixelPerfect switch
+        {
+            false => new LineBasedPen_Action(guidValue, color, controller!.LastPixelPosition, toolSize, false, drawOnMask),
+            true => new PixelPerfectPen_Action(guidValue, controller!.LastPixelPosition, color, drawOnMask)
+        };
+        internals!.ActionAccumulator.AddActions(action);
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        IAction? action = pixelPerfect switch
+        {
+            false => new LineBasedPen_Action(guidValue, color, pos, toolSize, false, drawOnMask),
+            true => new PixelPerfectPen_Action(guidValue, pos, color, drawOnMask)
+        };
+        internals!.ActionAccumulator.AddActions(action);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        IAction? action = pixelPerfect switch
+        {
+            false => new EndLineBasedPen_Action(),
+            true => new EndPixelPerfectPen_Action()
+        };
+
+        internals!.ActionAccumulator.AddFinishedActions(action);
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        IAction? action = pixelPerfect switch
+        {
+            false => new EndLineBasedPen_Action(),
+            true => new EndPixelPerfectPen_Action()
+        };
+        internals!.ActionAccumulator.AddFinishedActions(action);
+    }
+}

+ 31 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/RectangleToolExecutor.cs

@@ -0,0 +1,31 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class RectangleToolExecutor : ShapeToolExecutor<RectangleToolViewModel>
+{
+    public override ExecutorType Type => ExecutorType.ToolLinked;
+    private void DrawRectangle(VecI curPos, bool firstDraw)
+    {
+        RectI rect;
+        if (firstDraw)
+            rect = new RectI(curPos, VecI.Zero);
+        else if (toolViewModel!.DrawSquare)
+            rect = GetSquaredCoordinates(startPos, curPos);
+        else
+            rect = RectI.FromTwoPixels(startPos, curPos);
+        lastRect = rect;
+
+        internals!.ActionAccumulator.AddActions(new DrawRectangle_Action(memberGuid, new ShapeData(rect.Center, rect.Size, 0, strokeWidth, strokeColor, fillColor), drawOnMask));
+    }
+
+    protected override void DrawShape(VecI currentPos, bool first) => DrawRectangle(currentPos, first);
+
+    protected override IAction TransformMovedAction(ShapeData data, ShapeCorners corners) => new DrawRectangle_Action(memberGuid, data, drawOnMask);
+
+    protected override IAction EndDrawAction() => new EndDrawRectangle_Action();
+}

+ 74 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/SelectToolExecutor.cs

@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class SelectToolExecutor : UpdateableChangeExecutor
+{
+    private SelectToolViewModel? toolViewModel;
+    private Toolbar? toolbar;
+    private VecI startPos;
+    private SelectionShape selectShape;
+    private SelectionMode selectMode;
+
+    public override ExecutionState Start()
+    {
+        toolViewModel = ViewModelMain.Current?.ToolsSubViewModel.GetTool<SelectToolViewModel>();
+        toolbar = toolViewModel?.Toolbar;
+
+        if (toolViewModel is null || toolbar is null)
+            return ExecutionState.Error;
+        
+        startPos = controller!.LastPixelPosition;
+        selectShape = toolViewModel.SelectShape;
+        selectMode = toolViewModel.ResultingSelectionMode;
+
+        IAction action = CreateUpdateAction(selectShape, new RectI(startPos, new(0)), selectMode);
+        internals!.ActionAccumulator.AddActions(action);
+        
+        return ExecutionState.Success;
+    }
+
+    private static IAction CreateUpdateAction(SelectionShape shape, RectI rect, SelectionMode mode) => shape switch
+    {
+        SelectionShape.Rectangle => new SelectRectangle_Action(rect, mode),
+        SelectionShape.Circle => new SelectEllipse_Action(rect, mode),
+        _ => throw new NotImplementedException(),
+    };
+
+    private static IAction CreateEndAction(SelectionShape shape) => shape switch
+    {
+        SelectionShape.Rectangle => new EndSelectRectangle_Action(),
+        SelectionShape.Circle => new EndSelectEllipse_Action(),
+        _ => throw new NotImplementedException(),
+    };
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        IAction action = CreateUpdateAction(selectShape, RectI.FromTwoPixels(startPos, pos), selectMode);
+        internals!.ActionAccumulator.AddActions(action);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        IAction action = CreateEndAction(selectShape);
+        internals!.ActionAccumulator.AddFinishedActions(action);
+        onEnded!(this);
+    }
+
+    public override void ForceStop()
+    {
+        IAction action = CreateEndAction(selectShape);
+        internals!.ActionAccumulator.AddFinishedActions(action);
+    }
+}

+ 167 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/ShapeToolExecutor.cs

@@ -0,0 +1,167 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Extensions.Palettes;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+
+#nullable enable
+
+internal abstract class ShapeToolExecutor<T> : UpdateableChangeExecutor where T : ShapeTool
+{
+    protected int strokeWidth;
+    protected Color fillColor;
+    protected Color strokeColor;
+    protected Guid memberGuid;
+    protected bool drawOnMask;
+
+    protected bool transforming = false;
+    protected T? toolViewModel;
+    protected VecI startPos;
+    protected RectI lastRect;
+
+    private bool noMovement = true;
+
+    public override ExecutionState Start()
+    {
+        ColorsViewModel? colorsVM = ViewModelMain.Current?.ColorsSubViewModel;
+        toolViewModel = ViewModelMain.Current?.ToolsSubViewModel.GetTool<T>();
+        BasicShapeToolbar? toolbar = (BasicShapeToolbar?)toolViewModel?.Toolbar;
+        StructureMemberViewModel? member = document?.SelectedStructureMember;
+        if (colorsVM is null || toolbar is null || member is null)
+            return ExecutionState.Error;
+        drawOnMask = member is LayerViewModel layer ? layer.ShouldDrawOnMask : true;
+        if (drawOnMask && !member.HasMaskBindable)
+            return ExecutionState.Error;
+        if (!drawOnMask && member is not LayerViewModel)
+            return ExecutionState.Error;
+
+        fillColor = toolbar.Fill ? toolbar.FillColor.ToColor() : DrawingApi.Core.ColorsImpl.Colors.Transparent;
+        startPos = controller!.LastPixelPosition;
+        strokeColor = colorsVM.PrimaryColor;
+        strokeWidth = toolbar.ToolSize;
+        memberGuid = member.GuidValue;
+
+        colorsVM.AddSwatch(new PaletteColor(strokeColor.R, strokeColor.G, strokeColor.B));
+        DrawShape(startPos, true);
+        return ExecutionState.Success;
+    }
+
+    protected abstract void DrawShape(VecI currentPos, bool firstDraw);
+    protected abstract IAction TransformMovedAction(ShapeData data, ShapeCorners corners);
+    protected abstract IAction EndDrawAction();
+    protected virtual DocumentTransformMode TransformMode => DocumentTransformMode.Scale_Rotate_NoShear_NoPerspective;
+
+    public static VecI Get45IncrementedPosition(VecI startPos, VecI curPos)
+    {
+        Span<VecI> positions = stackalloc VecI[]
+        {
+            (VecI)(((VecD)curPos).ProjectOntoLine(startPos, startPos + new VecD(1, 1)) - new VecD(0.25).Multiply((curPos - startPos).Signs())).Round(),
+            (VecI)(((VecD)curPos).ProjectOntoLine(startPos, startPos + new VecD(1, -1)) - new VecD(0.25).Multiply((curPos - startPos).Signs())).Round(),
+            (VecI)(((VecD)curPos).ProjectOntoLine(startPos, startPos + new VecD(1, 0)) - new VecD(0.25).Multiply((curPos - startPos).Signs())).Round(),
+            (VecI)(((VecD)curPos).ProjectOntoLine(startPos, startPos + new VecD(0, 1)) - new VecD(0.25).Multiply((curPos - startPos).Signs())).Round()
+        };
+        VecI max = positions[0];
+        double maxLength = double.MaxValue;
+        foreach (var pos in positions)
+        {
+            double length = (pos - curPos).LengthSquared;
+            if (length < maxLength)
+            {
+                maxLength = length;
+                max = pos;
+            }
+        }
+        return max;
+    }
+
+    public static VecI GetSquaredPosition(VecI startPos, VecI curPos)
+    {
+        VecI pos1 = (VecI)(((VecD)curPos).ProjectOntoLine(startPos, startPos + new VecD(1, 1)) - new VecD(0.25).Multiply((curPos - startPos).Signs())).Round();
+        VecI pos2 = (VecI)(((VecD)curPos).ProjectOntoLine(startPos, startPos + new VecD(1, -1)) - new VecD(0.25).Multiply((curPos - startPos).Signs())).Round();
+        if ((pos1 - curPos).LengthSquared > (pos2 - curPos).LengthSquared)
+            return (VecI)pos2;
+        return (VecI)pos1;
+    }
+
+    public static RectI GetSquaredCoordinates(VecI startPos, VecI curPos)
+    {
+        VecI pos = GetSquaredPosition(startPos, curPos);
+        return RectI.FromTwoPixels(startPos, pos);
+    }
+
+    public override void OnTransformMoved(ShapeCorners corners)
+    {
+        if (!transforming)
+            return;
+
+        var rect = RectD.FromCenterAndSize(corners.RectCenter, corners.RectSize);
+        ShapeData shapeData = new ShapeData(rect.Center, rect.Size, corners.RectRotation, strokeWidth, strokeColor,
+            fillColor);
+        IAction drawAction = TransformMovedAction(shapeData, corners);
+
+        internals!.ActionAccumulator.AddActions(drawAction);
+    }
+
+    public override void OnTransformApplied()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(EndDrawAction());
+        document!.TransformViewModel.HideTransform();
+        onEnded?.Invoke(this);
+    }
+
+    public override void OnSelectedObjectNudged(VecI distance)
+    {
+        if (!transforming)
+            return;
+        document!.TransformViewModel.Nudge(distance);
+    }
+
+    public override void OnMidChangeUndo()
+    {
+        if (!transforming)
+            return;
+        document!.TransformViewModel.Undo();
+    }
+
+    public override void OnMidChangeRedo()
+    {
+        if (!transforming)
+            return;
+        document!.TransformViewModel.Redo();
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        if (transforming)
+            return;
+        noMovement = false;
+        DrawShape(pos, false);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        if (transforming)
+            return;
+        if (noMovement)
+        {
+            internals!.ActionAccumulator.AddFinishedActions(EndDrawAction());
+            onEnded?.Invoke(this);
+            return;
+        }
+        transforming = true;
+        document!.TransformViewModel.ShowTransform(TransformMode, false, new ShapeCorners((RectD)lastRect), true);
+    }
+
+    public override void ForceStop()
+    {
+        if (transforming)
+            document!.TransformViewModel.HideTransform();
+        internals!.ActionAccumulator.AddFinishedActions(EndDrawAction());
+    }
+}

+ 77 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/ShiftLayerExecutor.cs

@@ -0,0 +1,77 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class ShiftLayerExecutor : UpdateableChangeExecutor
+{
+    private List<Guid> _affectedMemberGuids = new List<Guid>();
+    private VecI startPos;
+    private MoveToolViewModel? tool;
+
+    public override ExecutorStartMode StartMode => ExecutorStartMode.OnMouseLeftButtonDown;
+
+    public override ExecutionState Start()
+    {
+        ViewModelMain? vm = ViewModelMain.Current;
+        StructureMemberViewModel? member = document!.SelectedStructureMember;
+        
+        tool = ViewModelMain.Current?.ToolsSubViewModel.GetTool<MoveToolViewModel>();
+        if (vm is null || tool is null)
+            return ExecutionState.Error;
+
+        if (tool.MoveAllLayers)
+        {
+            _affectedMemberGuids.AddRange(document.StructureHelper.GetAllLayers().Select(x => x.GuidValue));
+        }
+        else
+        {
+            if (member != null)
+                _affectedMemberGuids.Add(member.GuidValue);
+            _affectedMemberGuids.AddRange(document!.SoftSelectedStructureMembers.Select(x => x.GuidValue));
+        }
+
+        RemoveDrawOnMaskLayers(_affectedMemberGuids);
+        
+        startPos = controller!.LastPixelPosition;
+
+        ShiftLayer_Action action = new(_affectedMemberGuids, VecI.Zero, tool.KeepOriginalImage);
+        internals!.ActionAccumulator.AddActions(action);
+
+        return ExecutionState.Success;
+    }
+
+    private void RemoveDrawOnMaskLayers(List<Guid> affectedMemberGuids)
+    {
+        for (var i = 0; i < affectedMemberGuids.Count; i++)
+        {
+            var guid = affectedMemberGuids[i];
+            if (document!.StructureHelper.FindOrThrow(guid) is LayerViewModel { ShouldDrawOnMask: true })
+            {
+                _affectedMemberGuids.Remove(guid);
+                i--;
+            }
+        }
+    }
+
+    public override void OnPixelPositionChange(VecI pos)
+    {
+        ShiftLayer_Action action = new(_affectedMemberGuids, pos - startPos, tool!.KeepOriginalImage);
+        internals!.ActionAccumulator.AddActions(action);
+    }
+
+    public override void OnLeftMouseButtonUp()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndShiftLayer_Action());
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndShiftLayer_Action());
+    }
+}

+ 33 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/StructureMemberOpacityExecutor.cs

@@ -0,0 +1,33 @@
+using PixiEditor.Models.Enums;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+internal class StructureMemberOpacityExecutor : UpdateableChangeExecutor
+{
+    private Guid memberGuid;
+    public override ExecutionState Start()
+    {
+        if (document.SelectedStructureMember is null)
+            return ExecutionState.Error;
+        memberGuid = document.SelectedStructureMember.GuidValue;
+        StructureMemberOpacity_Action action = new StructureMemberOpacity_Action(memberGuid, document.SelectedStructureMember.OpacityBindable);
+        internals.ActionAccumulator.AddActions(action);
+        return ExecutionState.Success;
+    }
+
+    public override void OnOpacitySliderDragged(float newValue)
+    {
+        StructureMemberOpacity_Action action = new StructureMemberOpacity_Action(memberGuid, newValue);
+        internals.ActionAccumulator.AddActions(action);
+    }
+
+    public override void OnOpacitySliderDragEnded()
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndStructureMemberOpacity_Action());
+        onEnded?.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndStructureMemberOpacity_Action());
+    }
+}

+ 49 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/SymmetryExecutor.cs

@@ -0,0 +1,49 @@
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.Models.Enums;
+using PixiEditor.Views.UserControls.SymmetryOverlay;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+internal class SymmetryExecutor : UpdateableChangeExecutor
+{
+    private readonly SymmetryAxisDirection dir;
+
+    public SymmetryExecutor(SymmetryAxisDirection dir)
+    {
+        this.dir = dir;
+    }
+
+    public override ExecutionState Start()
+    {
+        if (!document.HorizontalSymmetryAxisEnabledBindable && dir == SymmetryAxisDirection.Horizontal ||
+            !document.VerticalSymmetryAxisEnabledBindable && dir == SymmetryAxisDirection.Vertical)
+            return ExecutionState.Error;
+
+        double lastPos = dir switch
+        {
+            SymmetryAxisDirection.Horizontal => document.HorizontalSymmetryAxisYBindable,
+            SymmetryAxisDirection.Vertical => document.VerticalSymmetryAxisXBindable,
+            _ => throw new NotImplementedException(),
+        };
+        internals.ActionAccumulator.AddActions(new SymmetryAxisPosition_Action(dir, lastPos));
+
+        return ExecutionState.Success;
+    }
+
+    public override void OnSymmetryDragged(SymmetryAxisDragInfo info)
+    {
+        if (info.Direction != dir)
+            return;
+        internals.ActionAccumulator.AddActions(new SymmetryAxisPosition_Action(dir, info.NewPosition));
+    }
+
+    public override void OnSymmetryDragEnded(SymmetryAxisDirection dir)
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndSymmetryAxisPosition_Action());
+        onEnded!(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals.ActionAccumulator.AddFinishedActions(new EndSymmetryAxisPosition_Action());
+    }
+}

+ 50 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/TransformReferenceLayerExecutor.cs

@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+internal class TransformReferenceLayerExecutor : UpdateableChangeExecutor
+{
+    public override ExecutionState Start()
+    {
+        if (document!.ReferenceLayerViewModel.ReferenceBitmap is null)
+            return ExecutionState.Error;
+
+        ShapeCorners corners = document.ReferenceLayerViewModel.ReferenceShapeBindable;
+        document.TransformViewModel.ShowTransform(DocumentTransformMode.Scale_Rotate_Shear_NoPerspective, true, corners, true);
+        document.ReferenceLayerViewModel.IsTransforming = true;
+        internals!.ActionAccumulator.AddActions(new TransformReferenceLayer_Action(corners));
+        return ExecutionState.Success;
+    }
+
+    public override void OnTransformMoved(ShapeCorners corners)
+    {
+        internals!.ActionAccumulator.AddActions(new TransformReferenceLayer_Action(corners));
+    }
+
+    public override void OnSelectedObjectNudged(VecI distance) => document!.TransformViewModel.Nudge(distance);
+
+    public override void OnMidChangeUndo() => document!.TransformViewModel.Undo();
+
+    public override void OnMidChangeRedo() => document!.TransformViewModel.Redo();
+
+    public override void OnTransformApplied()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndTransformReferenceLayer_Action());
+        document!.TransformViewModel.HideTransform();
+        document.ReferenceLayerViewModel.IsTransforming = false;
+        onEnded!.Invoke(this);
+    }
+
+    public override void ForceStop()
+    {
+        internals!.ActionAccumulator.AddFinishedActions(new EndTransformReferenceLayer_Action());
+        document!.TransformViewModel.HideTransform();
+        document.ReferenceLayerViewModel.IsTransforming = false;
+    }
+}

+ 86 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/TransformSelectedAreaExecutor.cs

@@ -0,0 +1,86 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Enums;
+using PixiEditor.ViewModels.SubViewModels.Document;
+using PixiEditor.ViewModels.SubViewModels.Tools.Tools;
+using PixiEditor.ViewModels.SubViewModels.Tools.ToolSettings.Toolbars;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal class TransformSelectedAreaExecutor : UpdateableChangeExecutor
+{
+    private Guid[]? membersToTransform;
+    private MoveToolViewModel? tool;
+    public override ExecutorType Type { get; }
+
+    public TransformSelectedAreaExecutor(bool toolLinked)
+    {
+        Type = toolLinked ? ExecutorType.ToolLinked : ExecutorType.Regular;
+    }
+
+    public override ExecutionState Start()
+    {
+        tool = ViewModelMain.Current?.ToolsSubViewModel.GetTool<MoveToolViewModel>();
+        if (tool is null || document!.SelectedStructureMember is null || document!.SelectionPathBindable.IsEmpty)
+            return ExecutionState.Error;
+
+        tool.TransformingSelectedArea = true;
+        List<StructureMemberViewModel> members = new();
+        
+        members = document.SoftSelectedStructureMembers
+            .Append(document.SelectedStructureMember)
+            .Where(static m => m is LayerViewModel).ToList();
+        
+        if (!members.Any())
+            return ExecutionState.Error;
+
+        ShapeCorners corners = new(document.SelectionPathBindable.TightBounds);
+        document.TransformViewModel.ShowTransform(DocumentTransformMode.Scale_Rotate_Shear_Perspective, true, corners, Type == ExecutorType.Regular);
+        membersToTransform = members.Select(static a => a.GuidValue).ToArray();
+        internals!.ActionAccumulator.AddActions(
+            new TransformSelectedArea_Action(membersToTransform, corners, tool.KeepOriginalImage, false));
+        return ExecutionState.Success;
+    }
+
+    public override void OnTransformMoved(ShapeCorners corners)
+    {
+        internals!.ActionAccumulator.AddActions(
+            new TransformSelectedArea_Action(membersToTransform!, corners, tool!.KeepOriginalImage, false));
+    }
+
+    public override void OnSelectedObjectNudged(VecI distance) => document!.TransformViewModel.Nudge(distance);
+
+    public override void OnMidChangeUndo() => document!.TransformViewModel.Undo();
+
+    public override void OnMidChangeRedo() => document!.TransformViewModel.Redo();
+
+    public override void OnTransformApplied()
+    {
+        if (tool is not null)
+        {
+            tool.TransformingSelectedArea = false;
+        }
+        
+        internals!.ActionAccumulator.AddActions(new EndTransformSelectedArea_Action());
+        internals!.ActionAccumulator.AddFinishedActions();
+        document!.TransformViewModel.HideTransform();
+        onEnded!.Invoke(this);
+
+        if (Type == ExecutorType.ToolLinked)
+        {
+            ViewModelMain.Current!.ToolsSubViewModel.RestorePreviousTool();
+        }
+    }
+
+    public override void ForceStop()
+    {
+        if (tool is not null)
+        {
+            tool.TransformingSelectedArea = false;
+        }
+        
+        internals!.ActionAccumulator.AddActions(new EndTransformSelectedArea_Action());
+        internals!.ActionAccumulator.AddFinishedActions();
+        document!.TransformViewModel.HideTransform();
+    }
+}

+ 55 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentModels/UpdateableChangeExecutors/UpdateableChangeExecutor.cs

@@ -0,0 +1,55 @@
+using System.Windows.Input;
+using Avalonia.Input;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.Enums;
+using PixiEditor.Views.UserControls.SymmetryOverlay;
+
+namespace PixiEditor.Models.DocumentModels.UpdateableChangeExecutors;
+#nullable enable
+internal abstract class UpdateableChangeExecutor
+{
+    protected IDocument? document;
+    protected DocumentInternalParts? internals;
+    protected ChangeExecutionController? controller;
+    private bool initialized = false;
+
+    protected Action<UpdateableChangeExecutor>? onEnded;
+    public virtual ExecutorType Type => ExecutorType.Regular;
+    public virtual ExecutorStartMode StartMode => ExecutorStartMode.RightAway;
+
+    public void Initialize(IDocument document, DocumentInternalParts internals, ChangeExecutionController controller, Action<UpdateableChangeExecutor> onEnded)
+    {
+        if (initialized)
+            throw new InvalidOperationException();
+        initialized = true;
+
+        this.document = document;
+        this.internals = internals;
+        this.controller = controller;
+        this.onEnded = onEnded;
+    }
+
+    public abstract ExecutionState Start();
+    public abstract void ForceStop();
+    public virtual void OnPixelPositionChange(VecI pos) { }
+    public virtual void OnPrecisePositionChange(VecD pos) { }
+    public virtual void OnLeftMouseButtonDown(VecD pos) { }
+    public virtual void OnLeftMouseButtonUp() { }
+    public virtual void OnOpacitySliderDragStarted() { }
+    public virtual void OnOpacitySliderDragged(float newValue) { }
+    public virtual void OnOpacitySliderDragEnded() { }
+    public virtual void OnSymmetryDragStarted(SymmetryAxisDirection dir) { }
+    public virtual void OnSymmetryDragged(SymmetryAxisDragInfo info) { }
+    public virtual void OnSymmetryDragEnded(SymmetryAxisDirection dir) { }
+    public virtual void OnConvertedKeyDown(Key key) { }
+    public virtual void OnConvertedKeyUp(Key key) { }
+    public virtual void OnTransformMoved(ShapeCorners corners) { }
+    public virtual void OnTransformApplied() { }
+    public virtual void OnLineOverlayMoved(VecD start, VecD end) { }
+    public virtual void OnMidChangeUndo() { }
+    public virtual void OnMidChangeRedo() { }
+    public virtual void OnSelectedObjectNudged(VecI distance) { }
+}

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/AddSoftSelectedMember_PassthroughAction.cs

@@ -0,0 +1,5 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.Models.DocumentPassthroughActions;
+internal record class AddSoftSelectedMember_PassthroughAction(Guid GuidValue) : IChangeInfo, IAction;

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/ClearSoftSelectedMembers_PassthroughAction.cs

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

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/RefreshViewport_PassthroughAction.cs

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

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/RemoveSoftSelectedMember_PassthroughAction.cs

@@ -0,0 +1,5 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.Models.DocumentPassthroughActions;
+internal record class RemoveSoftSelectedMember_PassthroughAction(Guid GuidValue) : IAction, IChangeInfo;

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/RemoveViewport_PassthroughAction.cs

@@ -0,0 +1,5 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.Models.DocumentPassthroughActions;
+internal record class RemoveViewport_PassthroughAction(Guid GuidValue) : IAction, IChangeInfo;

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/DocumentPassthroughActions/SetSelectedMember_PassthroughAction.cs

@@ -0,0 +1,5 @@
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+
+namespace PixiEditor.Models.DocumentPassthroughActions;
+internal record class SetSelectedMember_PassthroughAction(Guid GuidValue) : IAction, IChangeInfo;

+ 34 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Events/FilteredKeyEventArgs.cs

@@ -0,0 +1,34 @@
+using System.Windows.Input;
+using Avalonia.Input;
+
+namespace PixiEditor.Models.Events;
+#nullable enable
+internal class FilteredKeyEventArgs : EventArgs
+{
+    public FilteredKeyEventArgs(
+        Key unfilteredKey, Key key, KeyStates state, bool isRepeat, bool isShiftDown, bool isCtrlDown, bool isAltDown)
+    {
+        UnfilteredKey = unfilteredKey;
+        Key = key;
+        State = state;
+        IsRepeat = isRepeat;
+
+        KeyModifiers modifiers = KeyModifiers.None;
+        if (isShiftDown)
+            modifiers |= KeyModifiers.Shift;
+        if (isCtrlDown)
+            modifiers |= KeyModifiers.Control;
+        if (isAltDown)
+            modifiers |= KeyModifiers.Alt;
+        Modifiers = modifiers;
+    }
+
+    public KeyModifiers Modifiers { get; }
+    public Key UnfilteredKey { get; }
+    public Key Key { get; }
+    public KeyStates State { get; }
+    public bool IsRepeat { get; }
+    public bool IsShiftDown => (Modifiers & KeyModifiers.Shift) != 0;
+    public bool IsCtrlDown => (Modifiers & KeyModifiers.Control) != 0;
+    public bool IsAltDown => (Modifiers & KeyModifiers.Alt) != 0;
+}

+ 36 - 1
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocument.cs

@@ -1,9 +1,44 @@
 using System.Collections.Generic;
+using Avalonia.Media.Imaging;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.Vector;
 using PixiEditor.Extensions.Palettes;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.DocumentModels.Public;
 
 namespace PixiEditor.Models.Containers;
 
-public interface IDocument
+internal interface IDocument
 {
     public List<PaletteColor> Palette { get; set; }
+    public VecI SizeBindable { get; set; }
+    public IStructureMemberHandler? SelectedStructureMember { get; protected set; }
+    public IReferenceLayerHandler ReferenceLayerHandler { get; }
+    public VectorPath SelectionPathBindable { get; }
+    public IFolderHandler StructureRoot { get; set; }
+    public Dictionary<ChunkResolution, DrawingSurface> Surfaces { get; set; }
+    public DocumentStructureModule StructureHelper { get; }
+    public DrawingSurface PreviewSurface { get; set; }
+    public bool AllChangesSaved { get; set; }
+    public string CoordinatesString { get; set; }
+    public IReadOnlyCollection<IStructureMemberHandler?> SoftSelectedStructureMembers { get; set; }
+    public Dictionary<ChunkResolution, WriteableBitmap> LazyBitmaps { get; set; }
+    public WriteableBitmap PreviewBitmap { get; set; }
+    public ILayerHandlerFactory LayerHandlerFactory { get; }
+    public IFolderHandlerFactory FolderHandlerFactory { get; set; }
+    public ITransformHandler TransformHandler { get; }
+    public bool Busy { get; set; }
+    public void RemoveSoftSelectedMember(IStructureMemberHandler member);
+    public void ClearSoftSelectedMembers();
+    public void AddSoftSelectedMember(IStructureMemberHandler member);
+    public void SetSelectedMember(IStructureMemberHandler member);
+    public void SetHorizontalSymmetryAxisY(double infoNewPosition);
+    public void SetVerticalSymmetryAxisX(double infoNewPosition);
+    public void SetHorizontalSymmetryAxisEnabled(bool infoState);
+    public void SetVerticalSymmetryAxisEnabled(bool infoState);
+    public void UpdateSelectionPath(VectorPath infoNewPath);
+    public void SetSize(VecI infoSize);
 }

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocumentHandler.cs → src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IDocumentManagerHandler.cs

@@ -3,9 +3,9 @@ using PixiEditor.Extensions.Palettes;
 
 namespace PixiEditor.Models.Containers;
 
-internal interface IDocumentHandler
+internal interface IDocumentManagerHandler
 {
-    public static IDocumentHandler? Instance { get; }
+    public static IDocumentManagerHandler? Instance { get; }
     public bool HasActiveDocument { get; }
     public IDocument? ActiveDocument { get; set; }
 }

+ 9 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IFolderHandler.cs

@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace PixiEditor.Models.Containers;
+
+internal interface IFolderHandler : IStructureMemberHandler
+{
+    public ObservableCollection<IStructureMemberHandler> Children { get; }
+}

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ILayerHandler.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.Containers;
+
+internal interface ILayerHandler : IStructureMemberHandler
+{
+    public bool ShouldDrawOnMask { get; set; }
+    public void SetLockTransparency(bool infoLockTransparency);
+}

+ 17 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IReferenceLayerHandler.cs

@@ -0,0 +1,17 @@
+using System.Collections.Immutable;
+using Avalonia.Media.Imaging;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Models.Containers;
+
+public interface IReferenceLayerHandler
+{
+    public WriteableBitmap? ReferenceBitmap { get; protected set; }
+    public ShapeCorners ReferenceShapeBindable { get; set; }
+    public void SetReferenceLayerIsVisible(bool infoIsVisible);
+    public void TransformReferenceLayer(ShapeCorners infoCorners);
+    public void DeleteReferenceLayer();
+    public void SetReferenceLayer(ImmutableArray<byte> infoImagePbgra32Bytes, VecI infoImageSize, ShapeCorners infoShape);
+    public void SetReferenceLayerTopMost(bool infoIsTopMost);
+}

+ 26 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/IStructureMemberHandler.cs

@@ -0,0 +1,26 @@
+using Avalonia.Media.Imaging;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.Models.Enums;
+using BlendMode = PixiEditor.ChangeableDocument.Enums.BlendMode;
+
+namespace PixiEditor.Models.Containers;
+
+internal interface IStructureMemberHandler
+{
+    public bool HasMaskBindable { get; }
+    public Guid GuidValue { get; }
+    public string NameBindable { get; set; }
+    public DrawingSurface? MaskPreviewSurface { get; set; }
+    public DrawingSurface? PreviewSurface { get; set; }
+    public WriteableBitmap? PreviewBitmap { get; set; }
+    public WriteableBitmap? MaskPreviewBitmap { get; set; }
+    public bool MaskIsVisibleBindable { get; set; }
+    public StructureMemberSelectionType Selection { get; set; }
+    public void SetMaskIsVisible(bool infoIsVisible);
+    public void SetClipToMemberBelowEnabled(bool infoClipToMemberBelow);
+    public void SetBlendMode(BlendMode infoBlendMode);
+    public void SetHasMask(bool infoHasMask);
+    public void SetOpacity(float infoOpacity);
+    public void SetIsVisible(bool infoIsVisible);
+    public void SetName(string infoName);
+}

+ 6 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Handlers/ITransformHandler.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.Models.Containers;
+
+public interface ITransformHandler
+{
+    public void ModifierKeysInlet(bool argsIsShiftDown, bool argsIsCtrlDown, bool argsIsAltDown);
+}

+ 8 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Layers/StructureMemberPlacement.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Models.Enums;
+internal enum StructureMemberPlacement
+{
+    Above,
+    Below,
+    Inside,
+    BelowOutsideFolder
+}

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Layers/StructureMemberSelectionType.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.Enums;
+internal enum StructureMemberSelectionType
+{
+    None,
+    Soft,
+    Hard
+}

+ 81 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Position/CropData.cs

@@ -0,0 +1,81 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace PixiEditor.Models.Position;
+
+[StructLayout(LayoutKind.Explicit)]
+public unsafe struct CropData
+{
+    [FieldOffset(0)]
+    private readonly int _width;
+    [FieldOffset(4)]
+    private readonly int _height;
+    [FieldOffset(8)]
+    private readonly int _offsetX;
+    [FieldOffset(12)]
+    private readonly int _offsetY;
+
+    public int Width => _width;
+
+    public int Height => _height;
+
+    public int OffsetX => _offsetX;
+
+    public int OffsetY => _offsetY;
+
+    public CropData(int width, int height, int offsetX, int offsetY)
+    {
+        _width = width;
+        _height = height;
+        _offsetX = offsetX;
+        _offsetY = offsetY;
+    }
+
+    public static CropData FromByteArray(byte[] data)
+    {
+        if (data.Length != sizeof(CropData))
+        {
+            throw new ArgumentOutOfRangeException(nameof(data), $"data must be {sizeof(CropData)} long");
+        }
+
+        fixed (void* ptr = data)
+        {
+            return Marshal.PtrToStructure<CropData>(new IntPtr(ptr));
+        }
+    }
+
+    public static CropData FromStream(Stream stream)
+    {
+        if (stream.Length < sizeof(CropData))
+        {
+            throw new ArgumentOutOfRangeException(nameof(stream), $"The specified stream must be at least {sizeof(CropData)} bytes long");
+        }
+
+        byte[] buffer = new byte[sizeof(CropData)];
+        stream.Read(buffer);
+
+        return FromByteArray(buffer);
+    }
+
+    public byte[] ToByteArray()
+    {
+        IntPtr ptr = Marshal.AllocHGlobal(sizeof(CropData));
+        Marshal.StructureToPtr(this, ptr, true);
+
+        Span<byte> bytes = new Span<byte>(ptr.ToPointer(), sizeof(CropData));
+        byte[] array = bytes.ToArray();
+
+        Marshal.FreeHGlobal(ptr);
+
+        return array;
+    }
+
+    public MemoryStream ToStream()
+    {
+        MemoryStream stream = new();
+        stream.Write(ToByteArray());
+        return stream;
+    }
+
+}

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Position/SymmetryAxisDragInfo.cs

@@ -0,0 +1,5 @@
+using PixiEditor.ChangeableDocument.Enums;
+
+namespace PixiEditor.Views.UserControls.SymmetryOverlay;
+#nullable enable
+internal record class SymmetryAxisDragInfo(SymmetryAxisDirection Direction, double NewPosition);

+ 17 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Position/ViewportInfo.cs

@@ -0,0 +1,17 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Models.Position;
+
+/// <summary>
+/// Used to keep track of viewports inside DocumentViewModel without directly referencing them
+/// </summary>
+internal readonly record struct ViewportInfo(
+    double Angle,
+    VecD Center,
+    VecD RealDimensions,
+    VecD Dimensions,
+    ChunkResolution Resolution,
+    Guid GuidValue,
+    bool Delayed,
+    Action InvalidateVisual);

+ 247 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/AffectedAreasGatherer.cs

@@ -0,0 +1,247 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+using PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+using PixiEditor.ChangeableDocument.ChangeInfos.Root;
+using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Models.Rendering;
+#nullable enable
+internal class AffectedAreasGatherer
+{
+    private readonly DocumentChangeTracker tracker;
+
+    public AffectedArea MainImageArea { get; private set; } = new();
+    public Dictionary<Guid, AffectedArea> ImagePreviewAreas { get; private set; } = new();
+    public Dictionary<Guid, AffectedArea> MaskPreviewAreas { get; private set; } = new();
+
+    public AffectedAreasGatherer(DocumentChangeTracker tracker, IReadOnlyList<IChangeInfo> changes)
+    {
+        this.tracker = tracker;
+        ProcessChanges(changes);
+    }
+
+    private void ProcessChanges(IReadOnlyList<IChangeInfo> changes)
+    {
+        foreach (var change in changes)
+        {
+            switch (change)
+            {
+                case MaskArea_ChangeInfo info:
+                    if (info.Area.Chunks is null)
+                        throw new InvalidOperationException("Chunks must not be null");
+                    AddToMainImage(info.Area);
+                    AddToImagePreviews(info.GuidValue, info.Area, true);
+                    AddToMaskPreview(info.GuidValue, info.Area);
+                    break;
+                case LayerImageArea_ChangeInfo info:
+                    if (info.Area.Chunks is null)
+                        throw new InvalidOperationException("Chunks must not be null");
+                    AddToMainImage(info.Area);
+                    AddToImagePreviews(info.GuidValue, info.Area);
+                    break;
+                case CreateStructureMember_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue);
+                    AddAllToMaskPreview(info.GuidValue);
+                    break;
+                case DeleteStructureMember_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToImagePreviews(info.ParentGuid);
+                    break;
+                case MoveStructureMember_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    if (info.ParentFromGuid != info.ParentToGuid)
+                        AddWholeCanvasToImagePreviews(info.ParentFromGuid);
+                    break;
+                case Size_ChangeInfo:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryMaskPreview();
+                    break;
+                case StructureMemberMask_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    AddWholeCanvasToMaskPreview(info.GuidValue);
+                    AddWholeCanvasToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberBlendMode_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberClipToMemberBelow_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberOpacity_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberIsVisible_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+                case StructureMemberMaskIsVisible_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue, false);
+                    AddAllToImagePreviews(info.GuidValue, true);
+                    break;
+            }
+        }
+    }
+
+    private void AddAllToImagePreviews(Guid memberGuid, bool ignoreSelf = false)
+    {
+        var member = tracker.Document.FindMember(memberGuid);
+        if (member is IReadOnlyLayer layer)
+        {
+            var chunks = layer.LayerImage.FindAllChunks();
+            AddToImagePreviews(memberGuid, new AffectedArea(chunks), ignoreSelf);
+        }
+        else if (member is IReadOnlyFolder folder)
+        {
+            AddWholeCanvasToImagePreviews(memberGuid, ignoreSelf);
+            foreach (var child in folder.Children)
+                AddAllToImagePreviews(child.GuidValue);
+        }
+    }
+
+    private void AddAllToMainImage(Guid memberGuid, bool useMask = true)
+    {
+        var member = tracker.Document.FindMember(memberGuid);
+        if (member is IReadOnlyLayer layer)
+        {
+            var chunks = layer.LayerImage.FindAllChunks();
+            if (layer.Mask is not null && layer.MaskIsVisible && useMask)
+                chunks.IntersectWith(layer.Mask.FindAllChunks());
+            AddToMainImage(new AffectedArea(chunks));
+        }
+        else
+        {
+            AddWholeCanvasToMainImage();
+        }
+    }
+
+    private void AddAllToMaskPreview(Guid memberGuid)
+    {
+        if (!tracker.Document.TryFindMember(memberGuid, out var member))
+            return;
+        if (member.Mask is not null)
+        {
+            var chunks = member.Mask.FindAllChunks();
+            AddToMaskPreview(memberGuid, new AffectedArea(chunks));
+        }
+        if (member is IReadOnlyFolder folder)
+        {
+            foreach (var child in folder.Children)
+                AddAllToMaskPreview(child.GuidValue);
+        }
+    }
+
+
+    private void AddToMainImage(AffectedArea area)
+    {
+        var temp = MainImageArea;
+        temp.UnionWith(area);
+        MainImageArea = temp;
+    }
+
+    private void AddToImagePreviews(Guid memberGuid, AffectedArea area, bool ignoreSelf = false)
+    {
+        var path = tracker.Document.FindMemberPath(memberGuid);
+        if (path.Count < 2)
+            return;
+        for (int i = ignoreSelf ? 1 : 0; i < path.Count - 1; i++)
+        {
+            var member = path[i];
+            if (!ImagePreviewAreas.ContainsKey(member.GuidValue))
+            {
+                ImagePreviewAreas[member.GuidValue] = new AffectedArea(area);
+            }
+            else
+            {
+                var temp = ImagePreviewAreas[member.GuidValue];
+                temp.UnionWith(area);
+                ImagePreviewAreas[member.GuidValue] = temp;
+            }
+        }
+    }
+
+    private void AddToMaskPreview(Guid memberGuid, AffectedArea area)
+    {
+        if (!MaskPreviewAreas.ContainsKey(memberGuid))
+        {
+            MaskPreviewAreas[memberGuid] = new AffectedArea(area);
+        }
+        else
+        {
+            var temp = MaskPreviewAreas[memberGuid];
+            temp.UnionWith(area);
+            MaskPreviewAreas[memberGuid] = temp;
+        }
+    }
+
+
+    private void AddWholeCanvasToMainImage()
+    {
+        MainImageArea = AddWholeArea(MainImageArea);
+    }
+
+    private void AddWholeCanvasToImagePreviews(Guid memberGuid, bool ignoreSelf = false)
+    {
+        var path = tracker.Document.FindMemberPath(memberGuid);
+        if (path.Count < 2)
+            return;
+        // skip root folder
+        for (int i = ignoreSelf ? 1 : 0; i < path.Count - 1; i++)
+        {
+            var member = path[i];
+            if (!ImagePreviewAreas.ContainsKey(member.GuidValue))
+                ImagePreviewAreas[member.GuidValue] = new AffectedArea();
+            ImagePreviewAreas[member.GuidValue] = AddWholeArea(ImagePreviewAreas[member.GuidValue]);
+        }
+    }
+
+    private void AddWholeCanvasToMaskPreview(Guid memberGuid)
+    {
+        if (!MaskPreviewAreas.ContainsKey(memberGuid))
+            MaskPreviewAreas[memberGuid] = new AffectedArea();
+        MaskPreviewAreas[memberGuid] = AddWholeArea(MaskPreviewAreas[memberGuid]);
+    }
+
+
+    private void AddWholeCanvasToEveryImagePreview()
+    {
+        tracker.Document.ForEveryReadonlyMember((member) => AddWholeCanvasToImagePreviews(member.GuidValue));
+    }
+
+    private void AddWholeCanvasToEveryMaskPreview()
+    {
+        tracker.Document.ForEveryReadonlyMember((member) => 
+        {
+            if (member.Mask is not null)
+                AddWholeCanvasToMaskPreview(member.GuidValue);
+        });
+    }
+
+    private AffectedArea AddWholeArea(AffectedArea area)
+    {
+        VecI size = new(
+            (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++)
+            {
+                area.Chunks.Add(new(i, j));
+            }
+        }
+        area.GlobalArea = new RectI(VecI.Zero, tracker.Document.Size);
+        return area;
+    }
+}

+ 212 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/CanvasUpdater.cs

@@ -0,0 +1,212 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using PixiEditor.ChangeableDocument.Rendering;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.PaintImpl;
+using PixiEditor.Models.Containers;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.Rendering.RenderInfos;
+
+namespace PixiEditor.Models.Rendering;
+#nullable enable
+internal class CanvasUpdater
+{
+    private readonly IDocument doc;
+    private readonly DocumentInternalParts internals;
+
+    private static readonly Paint ReplacingPaint = new() { BlendMode = BlendMode.Src };
+    private static readonly Paint ClearPaint = new() { BlendMode = BlendMode.Src, Color = PixiEditor.DrawingApi.Core.ColorsImpl.Colors.Transparent };
+
+    /// <summary>
+    /// Affected chunks that have not been rerendered yet.
+    /// </summary>
+    private readonly Dictionary<ChunkResolution, HashSet<VecI>> affectedAndNonRerenderedChunks = new() { [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new() };
+
+    /// <summary>
+    /// Affected chunks that have not been rerendered yet.
+    /// Doesn't include chunks that were affected after the last time rerenderDelayed was true.
+    /// </summary>
+    private readonly Dictionary<ChunkResolution, HashSet<VecI>> nonRerenderedChunksAffectedBeforeLastRerenderDelayed = new() { [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new() };
+
+
+    public CanvasUpdater(IDocument doc, DocumentInternalParts internals)
+    {
+        this.doc = doc;
+        this.internals = internals;
+    }
+
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
+    public async Task<List<IRenderInfo>> UpdateGatheredChunks
+        (AffectedAreasGatherer chunkGatherer, bool rerenderDelayed)
+    {
+        return await Task.Run(() => Render(chunkGatherer, rerenderDelayed)).ConfigureAwait(true);
+    }
+
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
+    public List<IRenderInfo> UpdateGatheredChunksSync
+        (AffectedAreasGatherer chunkGatherer, bool rerenderDelayed)
+    {
+        return Render(chunkGatherer, rerenderDelayed);
+    }
+
+    private Dictionary<ChunkResolution, HashSet<VecI>> FindChunksVisibleOnViewports(bool onDelayed, bool all)
+    {
+        Dictionary<ChunkResolution, HashSet<VecI>> chunks = new()
+        {
+            [ChunkResolution.Full] = new(),
+            [ChunkResolution.Half] = new(),
+            [ChunkResolution.Quarter] = new(),
+            [ChunkResolution.Eighth] = new()
+        };
+        foreach (var (_, viewport) in internals.State.Viewports)
+        {
+            if (onDelayed != viewport.Delayed && !all)
+                continue;
+
+            var viewportChunks = OperationHelper.FindChunksTouchingRectangle(
+                viewport.Center,
+                viewport.Dimensions,
+                -viewport.Angle,
+                ChunkResolution.Full.PixelSize());
+            chunks[viewport.Resolution].UnionWith(viewportChunks);
+        }
+        return chunks;
+    }
+
+    private Dictionary<ChunkResolution, HashSet<VecI>> FindGlobalChunksToRerender(AffectedAreasGatherer areasGatherer, bool renderDelayed)
+    {
+        // find all affected non rerendered chunks
+        var chunksToRerender = new Dictionary<ChunkResolution, HashSet<VecI>>();
+        foreach (var (res, stored) in affectedAndNonRerenderedChunks)
+        {
+            chunksToRerender[res] = new HashSet<VecI>(stored);
+            chunksToRerender[res].UnionWith(areasGatherer.MainImageArea.Chunks);
+        }
+
+        // find all chunks that would need to be rerendered if affected
+        var chunksToMaybeRerender = FindChunksVisibleOnViewports(false, renderDelayed);
+        if (!renderDelayed)
+        {
+            var chunksOnDelayedViewports = FindChunksVisibleOnViewports(true, false);
+            foreach (var (res, stored) in nonRerenderedChunksAffectedBeforeLastRerenderDelayed)
+            {
+                chunksOnDelayedViewports[res].IntersectWith(stored);
+                chunksToMaybeRerender[res].UnionWith(chunksOnDelayedViewports[res]);
+            }
+        }
+
+        // find affected chunks that need to be rerendered right now
+        foreach (var (res, toRerender) in chunksToRerender)
+        {
+            toRerender.IntersectWith(chunksToMaybeRerender[res]);
+        }
+
+        return chunksToRerender;
+    }
+
+    private void UpdateAffectedNonRerenderedChunks(Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender, AffectedArea chunkGathererAffectedArea)
+    {
+        if (chunkGathererAffectedArea.Chunks.Count > 0)
+        {
+            foreach (var (res, chunks) in chunksToRerender)
+            {
+                affectedAndNonRerenderedChunks[res].UnionWith(chunkGathererAffectedArea.Chunks);
+            }
+        }
+
+        foreach (var (res, chunks) in chunksToRerender)
+        {
+            affectedAndNonRerenderedChunks[res].ExceptWith(chunks);
+            nonRerenderedChunksAffectedBeforeLastRerenderDelayed[res].ExceptWith(chunks);
+        }
+    }
+
+    private List<IRenderInfo> Render(AffectedAreasGatherer chunkGatherer, bool rerenderDelayed)
+    {
+        Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender = FindGlobalChunksToRerender(chunkGatherer, rerenderDelayed);
+
+        bool updatingStoredChunks = false;
+        foreach (var (res, stored) in affectedAndNonRerenderedChunks)
+        {
+            HashSet<VecI> storedCopy = new HashSet<VecI>(stored);
+            storedCopy.IntersectWith(chunksToRerender[res]);
+            if (storedCopy.Count > 0)
+            {
+                updatingStoredChunks = true;
+                break;
+            }
+        }
+
+        bool anythingToUpdate = false;
+        foreach (var (_, chunks) in chunksToRerender)
+        {
+            anythingToUpdate |= chunks.Count > 0;
+        }
+        if (!anythingToUpdate)
+            return new();
+
+        UpdateAffectedNonRerenderedChunks(chunksToRerender, chunkGatherer.MainImageArea);
+        
+        List<IRenderInfo> infos = new();
+        UpdateMainImage(chunksToRerender, updatingStoredChunks ? null : chunkGatherer.MainImageArea.GlobalArea.Value, infos);
+        return infos;
+    }
+
+    private void UpdateMainImage(Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender, RectI? globalClippingRectangle, List<IRenderInfo> infos)
+    {
+        foreach (var (resolution, chunks) in chunksToRerender)
+        {
+            int chunkSize = resolution.PixelSize();
+            RectI? globalScaledClippingRectangle = null;
+            if (globalClippingRectangle is not null)
+                globalScaledClippingRectangle = (RectI?)((RectI)globalClippingRectangle).Scale(resolution.Multiplier()).RoundOutwards();
+
+            DrawingSurface screenSurface = doc.Surfaces[resolution];
+            foreach (var chunkPos in chunks)
+            {
+                RenderChunk(chunkPos, screenSurface, resolution, globalClippingRectangle, globalScaledClippingRectangle);
+                RectI chunkRect = new(chunkPos * chunkSize, new(chunkSize, chunkSize));
+                if (globalScaledClippingRectangle is RectI rect)
+                    chunkRect = chunkRect.Intersect(rect);
+
+                infos.Add(new DirtyRect_RenderInfo(
+                    chunkRect.Pos,
+                    chunkRect.Size,
+                    resolution
+                ));
+            }
+        }
+    }
+
+    private void RenderChunk(VecI chunkPos, DrawingSurface screenSurface, ChunkResolution resolution, RectI? globalClippingRectangle, RectI? globalScaledClippingRectangle)
+    {
+        if (globalScaledClippingRectangle is not null)
+        {
+            screenSurface.Canvas.Save();
+            screenSurface.Canvas.ClipRect((RectD)globalScaledClippingRectangle);
+        }
+
+        ChunkRenderer.MergeWholeStructure(chunkPos, resolution, internals.Tracker.Document.StructureRoot, globalClippingRectangle).Switch(
+            (Chunk chunk) =>
+            {
+                screenSurface.Canvas.DrawSurface(chunk.Surface.DrawingSurface, chunkPos.Multiply(chunk.PixelSize), ReplacingPaint);
+                chunk.Dispose();
+            },
+            (EmptyChunk _) =>
+            {
+                var pos = chunkPos * resolution.PixelSize();
+                screenSurface.Canvas.DrawRect(pos.X, pos.Y, resolution.PixelSize(), resolution.PixelSize(), ClearPaint);
+            });
+
+        if (globalScaledClippingRectangle is not null)
+            screenSurface.Canvas.Restore();
+    }
+}

+ 589 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/MemberPreviewUpdater.cs

@@ -0,0 +1,589 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface.PaintImpl;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.Models.DocumentModels;
+using PixiEditor.Models.Rendering.RenderInfos;
+using ChunkyImageLib.Operations;
+using PixiEditor.Avalonia.Helpers;
+using PixiEditor.Models.Containers;
+
+#nullable enable
+
+namespace PixiEditor.Models.Rendering;
+internal class MemberPreviewUpdater
+{
+    private const float smoothingThreshold = 1.5f;
+
+    private readonly IDocument doc;
+    private readonly DocumentInternalParts internals;
+
+    private Dictionary<Guid, RectI> lastMainPreviewTightBounds = new();
+    private Dictionary<Guid, RectI> lastMaskPreviewTightBounds = new();
+
+    private Dictionary<Guid, AffectedArea> mainPreviewAreasAccumulator = new();
+    private Dictionary<Guid, AffectedArea> maskPreviewAreasAccumulator = new();
+
+    private static readonly Paint SmoothReplacingPaint = new() { BlendMode = BlendMode.Src, FilterQuality = FilterQuality.Medium, IsAntiAliased = true };
+    private static readonly Paint ReplacingPaint = new() { BlendMode = BlendMode.Src };
+    private static readonly Paint ClearPaint = new() { BlendMode = BlendMode.Src, Color = DrawingApi.Core.ColorsImpl.Colors.Transparent };
+
+    public MemberPreviewUpdater(IDocument doc, DocumentInternalParts internals)
+    {
+        this.doc = doc;
+        this.internals = internals;
+    }
+
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
+    public async Task<List<IRenderInfo>> UpdateGatheredChunks
+        (AffectedAreasGatherer chunkGatherer, bool rerenderPreviews)
+    {
+        AddAreasToAccumulator(chunkGatherer);
+        if (!rerenderPreviews)
+            return new List<IRenderInfo>();
+
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?>? changedMainPreviewBounds = null;
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?>? changedMaskPreviewBounds = null;
+        await Task.Run(() =>
+        {
+            changedMainPreviewBounds = FindChangedTightBounds(false);
+            changedMaskPreviewBounds = FindChangedTightBounds(true);
+        }).ConfigureAwait(true);
+
+        RecreatePreviewBitmaps(changedMainPreviewBounds!, changedMaskPreviewBounds!);
+        var renderInfos = await Task.Run(() => Render(changedMainPreviewBounds!, changedMaskPreviewBounds)).ConfigureAwait(true);
+
+        CleanupUnusedTightBounds();
+
+        foreach (var a in changedMainPreviewBounds)
+        {
+            if (a.Value is not null)
+                lastMainPreviewTightBounds[a.Key] = a.Value.Value.tightBounds;
+            else
+                lastMainPreviewTightBounds.Remove(a.Key);
+        }
+
+        foreach (var a in changedMaskPreviewBounds)
+        {
+            if (a.Value is not null)
+                lastMaskPreviewTightBounds[a.Key] = a.Value.Value.tightBounds;
+            else
+                lastMaskPreviewTightBounds.Remove(a.Key);
+        }
+
+        return renderInfos;
+    }
+
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
+    public List<IRenderInfo> UpdateGatheredChunksSync
+        (AffectedAreasGatherer chunkGatherer, bool rerenderPreviews)
+    {
+        AddAreasToAccumulator(chunkGatherer);
+        if (!rerenderPreviews)
+            return new List<IRenderInfo>();
+
+        var changedMainPreviewBounds = FindChangedTightBounds(false);
+        var changedMaskPreviewBounds = FindChangedTightBounds(true);
+
+        RecreatePreviewBitmaps(changedMainPreviewBounds, changedMaskPreviewBounds);
+        var renderInfos = Render(changedMainPreviewBounds, changedMaskPreviewBounds);
+
+        CleanupUnusedTightBounds();
+
+        foreach (var a in changedMainPreviewBounds)
+        {
+            if (a.Value is not null)
+                lastMainPreviewTightBounds[a.Key] = a.Value.Value.tightBounds;
+        }
+
+        foreach (var a in changedMaskPreviewBounds)
+        {
+            if (a.Value is not null)
+                lastMaskPreviewTightBounds[a.Key] = a.Value.Value.tightBounds;
+        }
+
+        return renderInfos;
+    }
+
+    /// <summary>
+    /// Cleans up <see cref="lastMainPreviewTightBounds"/> and <see cref="lastMaskPreviewTightBounds"/> to get rid of tight bounds that belonged to now deleted layers
+    /// </summary>
+    private void CleanupUnusedTightBounds()
+    {
+        Dictionary<Guid, RectI> clearedLastMainPreviewTightBounds = new Dictionary<Guid, RectI>();
+        Dictionary<Guid, RectI> clearedLastMaskPreviewTightBounds = new Dictionary<Guid, RectI>();
+
+        internals.Tracker.Document.ForEveryReadonlyMember(member =>
+        {
+            if (lastMainPreviewTightBounds.ContainsKey(member.GuidValue))
+                clearedLastMainPreviewTightBounds.Add(member.GuidValue, lastMainPreviewTightBounds[member.GuidValue]);
+            if (lastMaskPreviewTightBounds.ContainsKey(member.GuidValue))
+                clearedLastMaskPreviewTightBounds.Add(member.GuidValue, lastMaskPreviewTightBounds[member.GuidValue]);
+        });
+
+        lastMainPreviewTightBounds = clearedLastMainPreviewTightBounds;
+        lastMaskPreviewTightBounds = clearedLastMaskPreviewTightBounds;
+    }
+
+    /// <summary>
+    /// Unions the areas inside <see cref="mainPreviewAreasAccumulator"/> and <see cref="maskPreviewAreasAccumulator"/> with the newly updated areas
+    /// </summary>
+    private void AddAreasToAccumulator(AffectedAreasGatherer areasGatherer)
+    {
+        AddAreas(areasGatherer.ImagePreviewAreas, mainPreviewAreasAccumulator);
+        AddAreas(areasGatherer.MaskPreviewAreas, maskPreviewAreasAccumulator);
+    }
+
+    private static void AddAreas(Dictionary<Guid, AffectedArea> from, Dictionary<Guid, AffectedArea> to)
+    {
+        foreach ((Guid guid, AffectedArea area) in from)
+        {
+            if (!to.ContainsKey(guid))
+                to[guid] = new AffectedArea();
+            var toArea = to[guid];
+            toArea.UnionWith(area);
+            to[guid] = toArea;
+        }
+    }
+
+    /// <summary>
+    /// Looks at the accumulated areas and determines which members need to have their preview bitmaps resized or deleted
+    /// </summary>
+    private Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> FindChangedTightBounds(bool forMasks)
+    {
+        // VecI? == null stands for "layer is empty, the preview needs to be deleted"
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> newPreviewBitmapSizes = new();
+
+        var targetAreas = forMasks ? maskPreviewAreasAccumulator : mainPreviewAreasAccumulator;
+        var targetLastBounds = forMasks ? lastMaskPreviewTightBounds : lastMainPreviewTightBounds;
+        foreach (var (guid, area) in targetAreas)
+        {
+            var member = internals.Tracker.Document.FindMember(guid);
+            if (member is null)
+                continue;
+
+            if (forMasks && member.Mask is null)
+            {
+                newPreviewBitmapSizes.Add(guid, null);
+                continue;
+            }
+
+            RectI? tightBounds = GetOrFindMemberTightBounds(member, area, forMasks);
+            RectI? maybeLastBounds = targetLastBounds.TryGetValue(guid, out RectI lastBounds) ? lastBounds : null;
+            if (tightBounds == maybeLastBounds)
+                continue;
+
+            if (tightBounds is null)
+            {
+                newPreviewBitmapSizes.Add(guid, null);
+                continue;
+            }
+
+            VecI previewSize = StructureHelpers.CalculatePreviewSize(tightBounds.Value.Size);
+            newPreviewBitmapSizes.Add(guid, (previewSize, tightBounds.Value));
+        }
+        return newPreviewBitmapSizes;
+    }
+
+    /// <summary>
+    /// Recreates the preview bitmaps using the passed sizes (or deletes them when new size is null)
+    /// </summary>
+    private void RecreatePreviewBitmaps(
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> newPreviewSizes, 
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> newMaskSizes)
+    {
+        // update previews
+        foreach (var (guid, newSize) in newPreviewSizes)
+        {
+            IStructureMemberHandler member = doc.StructureHelper.FindOrThrow(guid);
+
+            if (newSize is null)
+            {
+                member.PreviewSurface?.Dispose();
+                member.PreviewSurface = null;
+                member.PreviewBitmap = null;
+            }
+            else
+            {
+                if (member.PreviewBitmap is not null && member.PreviewBitmap.PixelSize.Width == newSize.Value.previewSize.X && member.PreviewBitmap.PixelSize.Height == newSize.Value.previewSize.Y)
+                {
+                    member.PreviewSurface!.Canvas.Clear();
+                }
+                else
+                {
+                    member.PreviewSurface?.Dispose();
+                    member.PreviewBitmap = StructureHelpers.CreateBitmap(newSize.Value.previewSize);
+                    member.PreviewSurface = StructureHelpers.CreateDrawingSurface(member.PreviewBitmap);
+                }
+            }
+
+            //TODO: Make sure PreviewBitmap implementation raises PropertyChanged
+            //member.RaisePropertyChanged(nameof(member.PreviewBitmap));
+        }
+
+        // update masks
+        foreach (var (guid, newSize) in newMaskSizes)
+        {
+            IStructureMemberHandler member = doc.StructureHelper.FindOrThrow(guid);
+
+            member.MaskPreviewSurface?.Dispose();
+            if (newSize is null)
+            {
+                member.MaskPreviewSurface = null;
+                member.MaskPreviewBitmap = null;
+            }
+            else
+            {
+                member.MaskPreviewBitmap = StructureHelpers.CreateBitmap(newSize.Value.previewSize);
+                member.MaskPreviewSurface = StructureHelpers.CreateDrawingSurface(member.MaskPreviewBitmap);
+            }
+
+            //TODO: Make sure MaskPreviewBitmap implementation raises PropertyChanged
+            //member.RaisePropertyChanged(nameof(member.MaskPreviewBitmap));
+        }
+    }
+
+    /// <summary>
+    /// Returns the previosly known committed tight bounds if there are no reasons to believe they have changed (based on the passed <paramref name="currentlyAffectedArea"/>).
+    /// Otherwise, calculates the new bounds via <see cref="FindLayerTightBounds"/> and returns them.
+    /// </summary>
+    private RectI? GetOrFindMemberTightBounds(IReadOnlyStructureMember member, AffectedArea currentlyAffectedArea, bool forMask)
+    {
+        if (forMask && member.Mask is null)
+            throw new InvalidOperationException();
+
+        RectI? prevTightBounds = null;
+
+        var targetLastCollection = forMask ? lastMaskPreviewTightBounds : lastMainPreviewTightBounds;
+
+        if (targetLastCollection.TryGetValue(member.GuidValue, out RectI tightBounds))
+            prevTightBounds = tightBounds;
+
+        if (prevTightBounds is not null && currentlyAffectedArea.GlobalArea is not null && prevTightBounds.Value.ContainsExclusive(currentlyAffectedArea.GlobalArea.Value))
+        {
+            // if the affected area is fully inside the previous tight bounds, the tight bounds couldn't possibly have changed
+            return prevTightBounds.Value;
+        }
+
+        return member switch
+        {
+            IReadOnlyLayer layer => FindLayerTightBounds(layer, forMask),
+            IReadOnlyFolder folder => FindFolderTightBounds(folder, forMask),
+            _ => throw new ArgumentOutOfRangeException()
+        };
+    }
+
+    /// <summary>
+    /// Finds the current committed tight bounds for a layer.
+    /// </summary>
+    private RectI? FindLayerTightBounds(IReadOnlyLayer layer, bool forMask)
+    {
+        if (layer.Mask is null && forMask)
+            throw new InvalidOperationException();
+
+        IReadOnlyChunkyImage targetImage = forMask ? layer.Mask! : layer.LayerImage;
+        return FindImageTightBounds(targetImage);
+    }
+
+    /// <summary>
+    /// Finds the current committed tight bounds for a folder recursively.
+    /// </summary>
+    private RectI? FindFolderTightBounds(IReadOnlyFolder folder, bool forMask)
+    {
+        if (forMask)
+        {
+            if (folder.Mask is null)
+                throw new InvalidOperationException();
+            return FindImageTightBounds(folder.Mask);
+        }
+
+        RectI? combinedBounds = null;
+        foreach (var child in folder.Children)
+        {
+            RectI? curBounds = null;
+            
+            if (child is IReadOnlyLayer childLayer)
+                curBounds = FindLayerTightBounds(childLayer, false);
+            else if (child is IReadOnlyFolder childFolder)
+                curBounds = FindFolderTightBounds(childFolder, false);
+
+            if (combinedBounds is null)
+                combinedBounds = curBounds;
+            else if (curBounds is not null)
+                combinedBounds = combinedBounds.Value.Union(curBounds.Value);
+        }
+
+        return combinedBounds;
+    }
+
+    /// <summary>
+    /// Finds the current committed tight bounds for an image in a reasonably efficient way.
+    /// Looks at the low-res chunks for large images, meaning the resulting bounds aren't 100% precise.
+    /// </summary>
+    private RectI? FindImageTightBounds(IReadOnlyChunkyImage targetImage)
+    {
+        RectI? bounds = targetImage.FindChunkAlignedCommittedBounds();
+        if (bounds is null)
+            return null;
+
+        int biggest = bounds.Value.Size.LongestAxis;
+        ChunkResolution resolution = biggest switch
+        {
+            > ChunkyImage.FullChunkSize * 9 => ChunkResolution.Eighth,
+            > ChunkyImage.FullChunkSize * 5 => ChunkResolution.Quarter,
+            > ChunkyImage.FullChunkSize * 3 => ChunkResolution.Half,
+            _ => ChunkResolution.Full,
+        };
+        return targetImage.FindTightCommittedBounds(resolution);
+    }
+
+    /// <summary>
+    /// Re-renders changed chunks using <see cref="mainPreviewAreasAccumulator"/> and <see cref="maskPreviewAreasAccumulator"/> along with the passed lists of bitmaps that need full re-render.
+    /// </summary>
+    private List<IRenderInfo> Render(
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> recreatedMainPreviewSizes,
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> recreatedMaskPreviewSizes)
+    {
+        List<IRenderInfo> infos = new();
+
+        var (mainPreviewChunksToRerender, maskPreviewChunksToRerender) = GetChunksToRerenderAndResetAccumulator();
+
+        RenderWholeCanvasPreview(mainPreviewChunksToRerender, maskPreviewChunksToRerender, infos);
+        RenderMainPreviews(mainPreviewChunksToRerender, recreatedMainPreviewSizes, infos);
+        RenderMaskPreviews(maskPreviewChunksToRerender, recreatedMaskPreviewSizes, infos);
+
+        return infos;
+
+        // asynchronously re-render changed chunks (where tight bounds didn't change) or the whole preview image (where the tight bounds did change)
+
+        // don't forget to get rid of the bitmap recreation code in DocumentUpdater
+    }
+
+    private (Dictionary<Guid, AffectedArea> main, Dictionary<Guid, AffectedArea> mask) GetChunksToRerenderAndResetAccumulator()
+    {
+        var result = (mainPreviewPostponedChunks: mainPreviewAreasAccumulator, maskPreviewPostponedChunks: maskPreviewAreasAccumulator);
+        mainPreviewAreasAccumulator = new();
+        maskPreviewAreasAccumulator = new();
+        return result;
+    }
+
+    /// <summary>
+    /// Re-renders the preview of the whole canvas which is shown as the tab icon
+    /// </summary>
+    private void RenderWholeCanvasPreview(Dictionary<Guid, AffectedArea> mainPreviewChunks, Dictionary<Guid, AffectedArea> maskPreviewChunks, List<IRenderInfo> infos)
+    {
+        var cumulative = mainPreviewChunks
+            .Concat(maskPreviewChunks)
+            .Aggregate(new AffectedArea(), (set, pair) =>
+        {
+            set.UnionWith(pair.Value);
+            return set;
+        });
+        if (cumulative.GlobalArea is null)
+            return;
+
+        var previewSize = StructureHelpers.CalculatePreviewSize(internals.Tracker.Document.Size);
+        float scaling = (float)previewSize.X / doc.SizeBindable.X;
+
+        bool somethingChanged = false;
+        foreach (var chunkPos in cumulative.Chunks)
+        {
+            somethingChanged = true;
+            ChunkResolution resolution = scaling switch
+            {
+                > 1 / 2f => ChunkResolution.Full,
+                > 1 / 4f => ChunkResolution.Half,
+                > 1 / 8f => ChunkResolution.Quarter,
+                _ => ChunkResolution.Eighth,
+            };
+            var pos = chunkPos * resolution.PixelSize();
+            var rendered = ChunkRenderer.MergeWholeStructure(chunkPos, resolution, internals.Tracker.Document.StructureRoot);
+            doc.PreviewSurface.Canvas.Save();
+            doc.PreviewSurface.Canvas.Scale(scaling);
+            doc.PreviewSurface.Canvas.ClipRect((RectD)cumulative.GlobalArea);
+            doc.PreviewSurface.Canvas.Scale(1 / (float)resolution.Multiplier());
+            if (rendered.IsT1)
+            {
+                doc.PreviewSurface.Canvas.DrawRect(pos.X, pos.Y, resolution.PixelSize(), resolution.PixelSize(), ClearPaint);
+            }
+            else if (rendered.IsT0)
+            {
+                using var renderedChunk = rendered.AsT0;
+                renderedChunk.DrawOnSurface(doc.PreviewSurface, pos, SmoothReplacingPaint);
+            }
+            doc.PreviewSurface.Canvas.Restore();
+        }
+        if (somethingChanged)
+            infos.Add(new CanvasPreviewDirty_RenderInfo());
+    }
+
+    private void RenderMainPreviews(
+        Dictionary<Guid, AffectedArea> mainPreviewChunks, 
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> recreatedPreviewSizes, 
+        List<IRenderInfo> infos)
+    {
+        foreach (var guid in mainPreviewChunks.Select(a => a.Key).Concat(recreatedPreviewSizes.Select(a => a.Key)))
+        {
+            // find the true affected area
+            AffectedArea? affArea = null;
+            RectI? tightBounds = null;
+            
+            if (mainPreviewChunks.TryGetValue(guid, out AffectedArea areaFromChunks))
+                affArea = areaFromChunks;
+
+            if (recreatedPreviewSizes.TryGetValue(guid, out (VecI _, RectI tightBounds)? value))
+            {
+                if (value is null)
+                    continue;
+                tightBounds = value.Value.tightBounds;
+                affArea = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(value.Value.tightBounds, ChunkyImage.FullChunkSize), value.Value.tightBounds);
+            }
+
+            if (affArea is null || affArea.Value.GlobalArea is null || affArea.Value.GlobalArea.Value.IsZeroOrNegativeArea)
+                continue;
+
+            // re-render the area
+            var memberVM = doc.StructureHelper.Find(guid);
+            if (memberVM is null || memberVM.PreviewSurface is null)
+                continue;
+
+            if (tightBounds is null)
+                tightBounds = lastMainPreviewTightBounds[guid];
+
+            var member = internals.Tracker.Document.FindMemberOrThrow(guid);
+
+            var previewSize = StructureHelpers.CalculatePreviewSize(tightBounds.Value.Size);
+            float scaling = (float)previewSize.X / tightBounds.Value.Width;
+            VecI position = tightBounds.Value.Pos;
+
+            if (memberVM is ILayerHandler)
+            {
+                RenderLayerMainPreview((IReadOnlyLayer)member, memberVM, affArea.Value, position, scaling);
+                infos.Add(new PreviewDirty_RenderInfo(guid));
+            }
+            else if (memberVM is IFolderHandler)
+            {
+                RenderFolderMainPreview((IReadOnlyFolder)member, memberVM, affArea.Value, position, scaling);
+                infos.Add(new PreviewDirty_RenderInfo(guid));
+            }
+            else
+            {
+                throw new ArgumentOutOfRangeException();
+            }
+        }
+    }
+
+    /// <summary>
+    /// Re-render the <paramref name="area"/> of the main preview of the <paramref name="memberVM"/> folder
+    /// </summary>
+    private void RenderFolderMainPreview(IReadOnlyFolder folder, IStructureMemberHandler memberVM, AffectedArea area, VecI position, float scaling)
+    {
+        memberVM.PreviewSurface.Canvas.Save();
+        memberVM.PreviewSurface.Canvas.Scale(scaling);
+        memberVM.PreviewSurface.Canvas.Translate(-position);
+        memberVM.PreviewSurface.Canvas.ClipRect((RectD)area.GlobalArea);
+        foreach (var chunk in area.Chunks)
+        {
+            var pos = chunk * ChunkResolution.Full.PixelSize();
+            // drawing in full res here is kinda slow
+            // we could switch to a lower resolution based on (canvas size / preview size) to make it run faster
+            OneOf<Chunk, EmptyChunk> rendered = ChunkRenderer.MergeWholeStructure(chunk, ChunkResolution.Full, folder);
+            if (rendered.IsT0)
+            {
+                memberVM.PreviewSurface.Canvas.DrawSurface(rendered.AsT0.Surface.DrawingSurface, pos, scaling < smoothingThreshold ? SmoothReplacingPaint : ReplacingPaint);
+                rendered.AsT0.Dispose();
+            }
+            else
+            {
+                memberVM.PreviewSurface.Canvas.DrawRect(pos.X, pos.Y, ChunkResolution.Full.PixelSize(), ChunkResolution.Full.PixelSize(), ClearPaint);
+            }
+        }
+        memberVM.PreviewSurface.Canvas.Restore();
+    }
+
+    /// <summary>
+    /// Re-render the <paramref name="area"/> of the main preview of the <paramref name="memberVM"/> layer
+    /// </summary>
+    private void RenderLayerMainPreview(IReadOnlyLayer layer, IStructureMemberHandler memberVM, AffectedArea area, VecI position, float scaling)
+    {
+        memberVM.PreviewSurface.Canvas.Save();
+        memberVM.PreviewSurface.Canvas.Scale(scaling);
+        memberVM.PreviewSurface.Canvas.Translate(-position);
+        memberVM.PreviewSurface.Canvas.ClipRect((RectD)area.GlobalArea);
+
+        foreach (var chunk in area.Chunks)
+        {
+            var pos = chunk * ChunkResolution.Full.PixelSize();
+            if (!layer.LayerImage.DrawCommittedChunkOn(chunk, ChunkResolution.Full, memberVM.PreviewSurface, pos, scaling < smoothingThreshold ? SmoothReplacingPaint : ReplacingPaint))
+                memberVM.PreviewSurface.Canvas.DrawRect(pos.X, pos.Y, ChunkyImage.FullChunkSize, ChunkyImage.FullChunkSize, ClearPaint);
+        }
+
+        memberVM.PreviewSurface.Canvas.Restore();
+    }
+
+    private void RenderMaskPreviews(
+        Dictionary<Guid, AffectedArea> maskPreviewChunks,
+        Dictionary<Guid, (VecI previewSize, RectI tightBounds)?> recreatedMaskSizes, 
+        List<IRenderInfo> infos)
+    {
+        foreach (Guid guid in maskPreviewChunks.Select(a => a.Key).Concat(recreatedMaskSizes.Select(a => a.Key)))
+        {
+            // find the true affected area
+            AffectedArea? affArea = null;
+            RectI? tightBounds = null;
+
+            if (maskPreviewChunks.TryGetValue(guid, out AffectedArea areaFromChunks))
+                affArea = areaFromChunks;
+
+            if (recreatedMaskSizes.TryGetValue(guid, out (VecI _, RectI tightBounds)? value))
+            {
+                if (value is null)
+                    continue;
+                tightBounds = value.Value.tightBounds;
+                affArea = new AffectedArea(OperationHelper.FindChunksTouchingRectangle(value.Value.tightBounds, ChunkyImage.FullChunkSize), value.Value.tightBounds);
+            }
+
+            if (affArea is null || affArea.Value.GlobalArea is null || affArea.Value.GlobalArea.Value.IsZeroOrNegativeArea)
+                continue;
+
+            // re-render the area
+
+            var memberVM = doc.StructureHelper.Find(guid);
+            if (memberVM is null || !memberVM.HasMaskBindable || memberVM.MaskPreviewSurface is null)
+                continue;
+
+            if (tightBounds is null)
+                tightBounds = lastMainPreviewTightBounds[guid];
+
+            var previewSize = StructureHelpers.CalculatePreviewSize(tightBounds.Value.Size);
+            float scaling = (float)previewSize.X / tightBounds.Value.Width;
+            VecI position = tightBounds.Value.Pos;
+
+            var member = internals.Tracker.Document.FindMemberOrThrow(guid);
+
+            memberVM.MaskPreviewSurface!.Canvas.Save();
+            memberVM.MaskPreviewSurface.Canvas.Scale(scaling);
+            memberVM.MaskPreviewSurface.Canvas.Translate(-position);
+            memberVM.MaskPreviewSurface.Canvas.ClipRect((RectD)affArea.Value.GlobalArea);
+            foreach (var chunk in affArea.Value.Chunks)
+            {
+                var pos = chunk * ChunkResolution.Full.PixelSize();
+                member.Mask!.DrawMostUpToDateChunkOn
+                    (chunk, ChunkResolution.Full, memberVM.MaskPreviewSurface, pos, scaling < smoothingThreshold ? SmoothReplacingPaint : ReplacingPaint);
+            }
+
+            memberVM.MaskPreviewSurface.Canvas.Restore();
+            infos.Add(new MaskPreviewDirty_RenderInfo(guid));
+        }
+    }
+}

+ 3 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/CanvasPreviewDirty_RenderInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.Models.Rendering.RenderInfos;
+#nullable enable
+internal record CanvasPreviewDirty_RenderInfo : IRenderInfo;

+ 6 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/DirtyRect_RenderInfo.cs

@@ -0,0 +1,6 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace PixiEditor.Models.Rendering.RenderInfos;
+#nullable enable
+public record class DirtyRect_RenderInfo(VecI Pos, VecI Size, ChunkResolution Resolution) : IRenderInfo;

+ 5 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/IRenderInfo.cs

@@ -0,0 +1,5 @@
+namespace PixiEditor.Models.Rendering.RenderInfos;
+#nullable enable
+public interface IRenderInfo
+{
+}

+ 3 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/MaskPreviewDirty_RenderInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.Models.Rendering.RenderInfos;
+#nullable enable
+public record class MaskPreviewDirty_RenderInfo(Guid GuidValue) : IRenderInfo;

+ 3 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Rendering/RenderInfos/PreviewDirty_RenderInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.Models.Rendering.RenderInfos;
+#nullable enable
+public record class PreviewDirty_RenderInfo(Guid GuidValue) : IRenderInfo;

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Tools/BrightnessMode.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.Enums;
+
+public enum BrightnessMode
+{
+    Default,
+    Repeat
+}

+ 7 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Tools/ExecutionState.cs

@@ -0,0 +1,7 @@
+namespace PixiEditor.Models.Enums;
+
+public enum ExecutionState
+{
+    Success,
+    Error
+}

+ 13 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Models/Tools/ExecutorType.cs

@@ -0,0 +1,13 @@
+namespace PixiEditor.Models.Enums;
+internal enum ExecutorType
+{
+    None,
+    Regular,
+    ToolLinked,
+}
+
+internal enum ExecutorStartMode
+{
+    RightAway,
+    OnMouseLeftButtonDown,
+}

+ 1 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/PixiEditor.Avalonia.csproj

@@ -3,6 +3,7 @@
         <TargetFramework>net7.0</TargetFramework>
         <Nullable>enable</Nullable>
         <LangVersion>latest</LangVersion>
+        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
     </PropertyGroup>
 
 

+ 5 - 8
src/PixiEditor.Avalonia/PixiEditor.Avalonia/ViewModels/SystemCommands.cs

@@ -1,4 +1,6 @@
-using Avalonia.Controls;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
 using Avalonia.Interactivity;
 using CommunityToolkit.Mvvm.Input;
 
@@ -15,15 +17,10 @@ public static class SystemCommands
     public static RoutedEvent<RoutedEventArgs> RestoreWindowEvent { get; }
         = RoutedEvent.Register<Window, RoutedEventArgs>(nameof(RestoreWindowEvent), RoutingStrategies.Bubble);
 
-    public static RelayCommand<Window> CloseWindowCommand { get; }
-
-    static SystemCommands()
-    {
-        CloseWindowCommand = new RelayCommand<Window>(CloseWindow);
-    }
+    public static ICommand CloseWindowCommand { get; } = new RelayCommand<Window>(CloseWindow);
 
     private static void CloseWindow(Window? obj)
     {
-
+        // TODO: Close window, this is just a placeholder, won't work
     }
 }

+ 2 - 2
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/Main/MainTitleBar.axaml

@@ -6,6 +6,7 @@
              xmlns:ui="clr-namespace:PixiEditor.Extensions.UI;assembly=PixiEditor.Extensions"
              xmlns:dataHolders="clr-namespace:PixiEditor.Models.DataHolders"
              xmlns:viewModels="clr-namespace:PixiEditor.Avalonia.ViewModels"
+             xmlns:views="clr-namespace:PixiEditor.Avalonia.Views"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PixiEditor.Avalonia.Views.Main.MainTitleBar">
             <xaml:Menu
@@ -70,8 +71,7 @@
                         <Separator />
                         <MenuItem
                             ui:Translator.Key="EXIT"
-                            Command="{x:Static viewModels:SystemCommands.CloseWindowCommand}"
-                            >
+                            Command="{x:Static viewModels:SystemCommands.CloseWindowCommand}">
                             <MenuItem.Icon>
                                 <TextBlock Text="&#xE106;" FontFamily="{DynamicResource NativeIconFont}" FontSize="20"/>
                             </MenuItem.Icon>

+ 1 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainWindow.axaml

@@ -11,6 +11,7 @@
         x:ClassModifier="internal"
         WindowStartupLocation="CenterScreen"
         WindowState="Maximized"
+        Name="Window"
         Icon="/Assets/avalonia-logo.ico"
         Title="PixiEditor">
     <views:MainView />

+ 1 - 0
src/PixiEditor.Avalonia/PixiEditor.Avalonia/Views/MainWindow.axaml.cs

@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using Avalonia.Controls;
+using Avalonia.Interactivity;
 using Microsoft.Extensions.DependencyInjection;
 using PixiEditor.Avalonia.ViewModels;
 using PixiEditor.DrawingApi.Core.Bridge;