Browse Source

Line tool, pipette tool

Equbuxu 3 years ago
parent
commit
7054a73bf0

+ 40 - 7
src/ChunkyImageLib/ChunkyImage.cs

@@ -25,7 +25,7 @@ namespace ChunkyImageLib;
 ///     - latestChunks stores chunks with some (or none, or all) queued operations applied
 ///     - latestChunksData stores the data for some or all of the latest chunks (not necessarily synced with latestChunks).
 ///         The data includes how many operations from the queue have already been applied to the chunk, as well as chunk deleted state (the clear operation deletes chunks)
-///     - LatestSize contains the new size if any resize operations were requested, otherwise the commited size
+///     - LatestSize contains the new size if any resize operations were requested, otherwise the committed size
 /// You can check the current state via queuedOperations.Count == 0
 /// 
 /// Depending on the chosen blend mode the latest chunks contain different things:
@@ -102,6 +102,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         };
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public ChunkyImage CloneFromCommitted()
     {
         lock (lockObject)
@@ -122,10 +123,12 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public SKColor GetCommittedPixel(VecI posOnImage)
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             var chunkPos = OperationHelper.GetChunkPos(posOnImage, FullChunkSize);
             var posInChunk = posOnImage - chunkPos * FullChunkSize;
             return MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) switch
@@ -136,10 +139,12 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
     
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public SKColor GetMostUpToDatePixel(VecI posOnImage)
     {
         lock (lockObject)
         {
+            ThrowIfDisposed();
             var chunkPos = OperationHelper.GetChunkPos(posOnImage, FullChunkSize);
             var posInChunk = posOnImage - chunkPos * FullChunkSize;
 
@@ -190,6 +195,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// <returns>
     /// True if the chunk existed and was drawn, otherwise false
     /// </returns>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null)
     {
         lock (lockObject)
@@ -244,6 +250,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public bool LatestOrCommittedChunkExists(VecI chunkPos)
     {
         lock (lockObject)
@@ -262,6 +269,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     internal bool DrawCommittedChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null)
     {
         lock (lockObject)
@@ -275,6 +283,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     internal bool CommittedChunkExists(VecI chunkPos)
     {
         lock (lockObject)
@@ -319,6 +328,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     private Chunk? MaybeGetCommittedChunk(VecI pos, ChunkResolution resolution)
         => committedChunks[resolution].TryGetValue(pos, out Chunk? value) ? value : null;
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void AddRasterClip(ChunkyImage clippingMask)
     {
         lock (lockObject)
@@ -330,6 +340,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void SetClippingPath(SKPath clippingPath)
     {
         lock (lockObject)
@@ -344,6 +355,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// <summary>
     /// Porter duff compositing operators (apart from SrcOver) likely won't have the intended effect.
     /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void SetBlendMode(SKBlendMode mode)
     {
         lock (lockObject)
@@ -355,6 +367,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void SetHorizontalAxisOfSymmetry(int position)
     {
         lock (lockObject)
@@ -366,6 +379,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void SetVerticalAxisOfSymmetry(int position)
     {
         lock (lockObject)
@@ -377,6 +391,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnableLockTransparency()
     {
         lock (lockObject)
@@ -386,6 +401,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawRectangle(ShapeData rect)
     {
         lock (lockObject)
@@ -396,6 +412,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawEllipse(RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth)
     {
         lock (lockObject)
@@ -408,10 +425,11 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
 
     /// <summary>
     /// Be careful about the copyImage argument. The default is true, and this is a thread safe version without any side effects. 
-    /// It will hovewer copy the surface right away which can be slow (in updateable changes especially). 
+    /// It will however copy the surface right away which can be slow (in updateable changes especially). 
     /// If copyImage is set to false, the image won't be copied and instead a reference will be stored.
     /// Surface is NOT THREAD SAFE, so if you pass a Surface here with copyImage == false you must not do anything with that surface anywhere (not even read) until CommitChanges/CancelChanges is called.
     /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawImage(ShapeCorners corners, Surface image, SKPaint? paint = null, bool copyImage = true)
     {
         lock (lockObject)
@@ -424,10 +442,11 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
 
     /// <summary>
     /// Be careful about the copyImage argument. The default is true, and this is a thread safe version without any side effects. 
-    /// It will hovewer copy the surface right away which can be slow (in updateable changes especially). 
+    /// It will however copy the surface right away which can be slow (in updateable changes especially). 
     /// If copyImage is set to false, the image won't be copied and instead a reference will be stored.
     /// Surface is NOT THREAD SAFE, so if you pass a Surface here with copyImage == false you must not do anything with that surface anywhere (not even read) until CommitChanges/CancelChanges is called.
     /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawImage(VecI pos, Surface image, SKPaint? paint = null, bool copyImage = true)
     {
         lock (lockObject)
@@ -439,6 +458,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     }
 
     /// <param name="customBounds">Bounds used for affected chunks, will be computed from path in O(n) if null is passed</param>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawPath(SKPath path, SKColor color, float strokeWidth, SKStrokeCap strokeCap, SKBlendMode blendMode, RectI? customBounds = null)
     {
         lock (lockObject)
@@ -449,6 +469,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawBresenhamLine(VecI from, VecI to, SKColor color, SKBlendMode blendMode)
     {
         lock (lockObject)
@@ -459,6 +480,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawSkiaLine(VecI from, VecI to, SKStrokeCap strokeCap, float strokeWidth, SKColor color, SKBlendMode blendMode)
     {
         lock (lockObject)
@@ -469,6 +491,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawPixels(IEnumerable<VecI> pixels, SKColor color, SKBlendMode blendMode)
     {
         lock (lockObject)
@@ -479,6 +502,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawPixel(VecI pos, SKColor color, SKBlendMode blendMode)
     {
         lock (lockObject)
@@ -489,6 +513,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false)
     {
         lock (lockObject)
@@ -499,6 +524,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueClearRegion(RectI region)
     {
         lock (lockObject)
@@ -509,6 +535,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueClear()
     {
         lock (lockObject)
@@ -519,6 +546,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueResize(VecI newSize)
     {
         lock (lockObject)
@@ -557,6 +585,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         queuedOperations.Add((operation, chunks));
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void CancelChanges()
     {
         lock (lockObject)
@@ -593,6 +622,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void CommitChanges()
     {
         lock (lockObject)
@@ -626,7 +656,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     }
 
     /// <summary>
-    /// Does all necessery steps to convert latest chunks into committed ones. The latest chunk dictionary become empty after this function is called.
+    /// Does all necessary steps to convert latest chunks into committed ones. The latest chunk dictionary become empty after this function is called.
     /// </summary>
     private void CommitLatestChunks()
     {
@@ -733,6 +763,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// <returns>
     /// All chunks that have something in them, including latest (uncommitted) ones
     /// </returns>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public HashSet<VecI> FindAllChunks()
     {
         lock (lockObject)
@@ -748,6 +779,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public HashSet<VecI> FindCommittedChunks()
     {
         lock (lockObject)
@@ -760,6 +792,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// <returns>
     /// Chunks affected by operations that haven't been committed yet
     /// </returns>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public HashSet<VecI> FindAffectedChunks(int fromOperationIndex = 0)
     {
         lock (lockObject)
@@ -931,6 +964,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// Finds and deletes empty committed chunks. Returns true if all existing chunks were deleted.
     /// Note: this function modifies the internal state, it is not thread safe! Use it only in changes (same as all the other functions that change the image in some way).
     /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public bool CheckIfCommittedIsEmpty()
     {
         lock (lockObject)
@@ -983,7 +1017,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     /// </summary>
     private Chunk GetOrCreateCommittedChunk(VecI chunkPos, ChunkResolution resolution)
     {
-        // commited chunk of the same resolution exists
+        // committed chunk of the same resolution exists
         Chunk? targetChunk = MaybeGetCommittedChunk(chunkPos, resolution);
         if (targetChunk is not null)
             return targetChunk;
@@ -1025,8 +1059,7 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
     private Chunk GetOrCreateLatestChunk(VecI chunkPos, ChunkResolution resolution)
     {
         // latest chunk exists
-        Chunk? targetChunk;
-        targetChunk = MaybeGetLatestChunk(chunkPos, resolution);
+        Chunk? targetChunk = MaybeGetLatestChunk(chunkPos, resolution);
         if (targetChunk is not null)
             return targetChunk;
 

+ 2 - 0
src/ChunkyImageLib/IReadOnlyChunkyImage.cs

@@ -6,6 +6,8 @@ namespace ChunkyImageLib;
 public interface IReadOnlyChunkyImage
 {
     bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null);
+    SKColor GetCommittedPixel(VecI posOnImage);
+    SKColor GetMostUpToDatePixel(VecI posOnImage);
     bool LatestOrCommittedChunkExists(VecI chunkPos);
     HashSet<VecI> FindAffectedChunks(int fromOperationIndex = 0);
     HashSet<VecI> FindCommittedChunks();

+ 14 - 1
src/PixiEditor.ChangeableDocument/Changeables/Selection.cs

@@ -7,7 +7,20 @@ internal class Selection : IReadOnlySelection, IDisposable
 {
     public static SKColor SelectionColor { get; } = SKColors.CornflowerBlue;
     public SKPath SelectionPath { get; set; } = new();
-    SKPath IReadOnlySelection.SelectionPath => new SKPath(SelectionPath);
+    SKPath IReadOnlySelection.SelectionPath 
+    {
+        get {
+            try
+            {
+                // I think this might throw if another thread disposes SelectionPath at the wrong time?
+                return new SKPath(SelectionPath);
+            }
+            catch (Exception)
+            {
+                return new SKPath();
+            }
+        }
+    }
 
     public void Dispose()
     {

+ 90 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/DrawLine_UpdateableChange.cs

@@ -0,0 +1,90 @@
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+
+internal class DrawLine_UpdateableChange : UpdateableChange 
+{
+    private readonly Guid memberGuid;
+    private VecI from;
+    private VecI to;
+    private int strokeWidth;
+    private SKColor color;
+    private SKStrokeCap caps;
+    private readonly bool drawOnMask;
+    private CommittedChunkStorage? savedChunks;
+
+    [GenerateUpdateableChangeActions]
+    public DrawLine_UpdateableChange
+        (Guid memberGuid, VecI from, VecI to, int strokeWidth, SKColor color, SKStrokeCap caps, bool drawOnMask)
+    {
+        this.memberGuid = memberGuid;
+        this.from = from;
+        this.to = to;
+        this.strokeWidth = strokeWidth;
+        this.color = color;
+        this.caps = caps;
+        this.drawOnMask = drawOnMask;
+    }
+
+    [UpdateChangeMethod]
+    public void Update(VecI from, VecI to, int strokeWidth, SKColor color, SKStrokeCap caps)
+    {
+        this.from = from;
+        this.to = to;
+        this.color = color;
+        this.caps = caps;
+        this.strokeWidth = strokeWidth;
+    }
+    
+    public override OneOf<Success, Error> InitializeAndValidate(Document target)
+    {
+        if (!DrawingChangeHelper.IsValidForDrawing(target, memberGuid, drawOnMask))
+            return new Error();
+        return new Success();
+    }
+
+    private HashSet<VecI> CommonApply(Document target)
+    {
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+        var oldAffected = image.FindAffectedChunks();
+        image.CancelChanges();
+        DrawingChangeHelper.ApplyClipsSymmetriesEtc(target, image, memberGuid, drawOnMask);
+        if (strokeWidth == 1)
+            image.EnqueueDrawBresenhamLine(from, to, color, SKBlendMode.SrcOver);
+        else
+            image.EnqueueDrawSkiaLine(from, to, caps, strokeWidth, color, SKBlendMode.SrcOver);
+        var totalAffected = image.FindAffectedChunks();
+        totalAffected.UnionWith(oldAffected);
+        return totalAffected;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, CommonApply(target), drawOnMask);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, bool firstApply, out bool ignoreInUndo)
+    {
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+        var affected = CommonApply(target);
+        if (savedChunks is not null)
+            throw new InvalidOperationException("Trying to save chunks while there are saved chunks already");
+        savedChunks = new CommittedChunkStorage(image, image.FindAffectedChunks());
+        image.CommitChanges();
+        
+        ignoreInUndo = false;
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affected, drawOnMask);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        var affected = DrawingChangeHelper.ApplyStoredChunksDisposeAndSetToNull
+            (target, memberGuid, drawOnMask, ref savedChunks);
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, affected, drawOnMask);
+    }
+
+    public override void Dispose()
+    {
+        savedChunks?.Dispose();
+    }
+}

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

@@ -8,16 +8,16 @@ public static class ChunkRenderer
 {
     private static readonly SKPaint ClippingPaint = new SKPaint() { BlendMode = SKBlendMode.DstIn };
 
-    public static OneOf<Chunk, EmptyChunk> MergeWholeStructure(VecI pos, ChunkResolution resolution, IReadOnlyFolder root)
+    public static OneOf<Chunk, EmptyChunk> MergeWholeStructure(VecI chunkPos, ChunkResolution resolution, IReadOnlyFolder root)
     {
         using RenderingContext context = new();
-        return MergeFolderContents(context, pos, resolution, root, new All());
+        return MergeFolderContents(context, chunkPos, resolution, root, new All());
     }
 
-    public static OneOf<Chunk, EmptyChunk> MergeChosenMembers(VecI pos, ChunkResolution resolution, IReadOnlyFolder root, HashSet<Guid> members)
+    public static OneOf<Chunk, EmptyChunk> MergeChosenMembers(VecI chunkPos, ChunkResolution resolution, IReadOnlyFolder root, HashSet<Guid> members)
     {
         using RenderingContext context = new();
-        return MergeFolderContents(context, pos, resolution, root, members);
+        return MergeFolderContents(context, chunkPos, resolution, root, members);
     }
 
     private static OneOf<EmptyChunk, Chunk> RenderLayerWithMask

+ 3 - 0
src/PixiEditorPrototype/Models/DocumentUpdater.cs

@@ -24,6 +24,9 @@ internal class DocumentUpdater
         this.helper = helper;
     }
 
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
     public void ApplyChangeFromChangeInfo(IChangeInfo? arbitraryInfo)
     {
         if (arbitraryInfo is null)

+ 3 - 0
src/PixiEditorPrototype/Models/Rendering/WriteableBitmapUpdater.cs

@@ -55,6 +55,9 @@ internal class WriteableBitmapUpdater
         this.helpers = helpers;
     }
 
+    /// <summary>
+    /// Don't call this outside ActionAccumulator
+    /// </summary>
     public async Task<List<IRenderInfo>> UpdateGatheredChunks
         (AffectedChunkGatherer chunkGatherer, bool updateDelayed)
     {

+ 7 - 2
src/PixiEditorPrototype/Models/Tool.cs

@@ -2,15 +2,20 @@
 
 internal enum Tool
 {
+    // drawing
     Rectangle,
+    Line,
     Ellipse,
     PathBasedPen,
     LineBasedPen,
     PixelPerfectPen,
     Eraser,
+    ShiftLayer,
+    FloodFill,
+    // selection
     SelectRectangle,
     SelectEllipse,
     Lasso,
-    ShiftLayer,
-    FloodFill
+    //misc
+    Pipette,
 }

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

@@ -6,10 +6,13 @@ using System.Windows.Media;
 using System.Windows.Media.Imaging;
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
 using Microsoft.Win32;
+using OneOf;
 using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Enums;
+using PixiEditor.ChangeableDocument.Rendering;
 using PixiEditor.Parser;
 using PixiEditorPrototype.CustomControls.SymmetryOverlay;
 using PixiEditorPrototype.Models;
@@ -155,6 +158,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
     private bool selectingLasso = false;
     private bool drawingRectangle = false;
     private bool drawingEllipse = false;
+    private bool drawingLine = false;
     private bool drawingPathBasedPen = false;
     private bool drawingLineBasedPen = false;
     private bool drawingPixelPerfectPen = false;
@@ -402,6 +406,26 @@ internal class DocumentViewModel : INotifyPropertyChanged
         Helpers.ActionAccumulator.AddFinishedActions(new EndPixelPerfectPen_Action());
     }
 
+    public void StartUpdateLine(VecI from, VecI to, SKColor color, SKStrokeCap cap, int strokeWidth)
+    {
+        if (!CanStartUpdate())
+            return;
+        drawingLine = true;
+        updateableChangeActive = true;
+        var member = FindFirstSelectedMember();
+        Helpers.ActionAccumulator.AddActions(
+            new DrawLine_Action(member!.GuidValue, from, to ,strokeWidth, color, cap, member.ShouldDrawOnMask));
+    }
+
+    public void EndLine()
+    {
+        if (!drawingLine)
+            return;
+        drawingLine = false;
+        updateableChangeActive = false;
+        Helpers.ActionAccumulator.AddFinishedActions(new EndDrawLine_Action());
+    }
+    
     public void StartUpdateEllipse(RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth)
     {
         if (!CanStartUpdate())
@@ -500,6 +524,42 @@ internal class DocumentViewModel : INotifyPropertyChanged
         Helpers.ActionAccumulator.AddFinishedActions(new EndSelectLasso_Action());
     }
 
+    public SKColor PickColor(VecI pos, bool fromAllLayers)
+    {
+        // it might've been a better idea to implement this function
+        // via a passthrough action to avoid all the try catches
+        if (fromAllLayers)
+        {
+            VecI chunkPos = OperationHelper.GetChunkPos(pos, ChunkyImage.FullChunkSize); 
+            return ChunkRenderer.MergeWholeStructure(chunkPos, ChunkResolution.Full, Helpers.Tracker.Document.StructureRoot)
+                .Match<SKColor>(
+                    (Chunk chunk) =>
+                    {
+                        VecI posOnChunk = pos - chunkPos * ChunkyImage.FullChunkSize;
+                        var color = chunk.Surface.GetSRGBPixel(posOnChunk);
+                        chunk.Dispose();
+                        return color;
+                    },
+                    _ => SKColors.Transparent
+                );
+        }
+        
+        if (SelectedStructureMember is not LayerViewModel layerVm)
+            return SKColors.Transparent;
+        var maybeMember = Helpers.Tracker.Document.FindMember(layerVm.GuidValue);
+        if (maybeMember is not IReadOnlyLayer layer)
+            return SKColors.Transparent;
+        // there is a tiny chance that the image might get disposed by another thread
+        try
+        {
+            return layer.LayerImage.GetMostUpToDatePixel(pos);
+        }
+        catch (ObjectDisposedException)
+        {
+            return SKColors.Transparent;
+        }
+    }
+
     private void ApplyTransform(object? param)
     {
         if (!transformingRectangle && !pastingImage && !transformingSelectionPath && !transformingEllipse)

+ 58 - 20
src/PixiEditorPrototype/ViewModels/ViewModelMain.cs

@@ -25,16 +25,28 @@ internal class ViewModelMain : INotifyPropertyChanged
     public RelayCommand MouseUpCommand { get; }
     public RelayCommand ChangeActiveToolCommand { get; }
     public RelayCommand SetSelectionModeCommand { get; }
+    public RelayCommand SetLineCapCommand { get; }
     public RelayCommand SetResizeAnchorCommand { get; }
     public RelayCommand LoadDocumentCommand { get; }
 
-    public Color SelectedColor { get; set; } = Colors.Black;
     public bool KeepOriginalImageOnTransform { get; set; } = false;
-    public bool FillAllLayers { get; set; } = false;
+    public bool ReferenceAllLayers { get; set; } = false;
     public float StrokeWidth { get; set; } = 1f;
+    public SKStrokeCap LineStrokeCap { get; set; } = SKStrokeCap.Butt;
 
     public event PropertyChangedEventHandler? PropertyChanged;
 
+    private Color selectedColor = Colors.Black;
+    public Color SelectedColor
+    {
+        get => selectedColor;
+        set
+        {
+            selectedColor = value;
+            PropertyChanged?.Invoke(this, new(nameof(SelectedColor)));
+        }
+    }
+    
     public bool NormalZoombox
     {
         set
@@ -104,12 +116,20 @@ internal class ViewModelMain : INotifyPropertyChanged
         MouseUpCommand = new RelayCommand(MouseUp);
         ChangeActiveToolCommand = new RelayCommand(ChangeActiveTool);
         SetSelectionModeCommand = new RelayCommand(SetSelectionMode);
+        SetLineCapCommand = new RelayCommand(SetLineCap);
         SetResizeAnchorCommand = new RelayCommand(SetResizeAnchor);
         LoadDocumentCommand = new RelayCommand(LoadDocument);
 
         Documents.Add(new DocumentViewModel(this, "New Artwork"));
     }
 
+    private void SetLineCap(object? obj)
+    {
+        if (obj is not SKStrokeCap cap)
+            return;
+        LineStrokeCap = cap;
+    }
+
     private void SetResizeAnchor(object? obj)
     {
         if (obj is not ResizeAnchor anchor)
@@ -146,25 +166,27 @@ internal class ViewModelMain : INotifyPropertyChanged
 
     private void ProcessToolMouseDown(VecD pos)
     {
-        if (toolOnMouseDown is Tool.FloodFill)
-        {
-            ActiveDocument!.FloodFill((VecI)pos, new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A), FillAllLayers);
-        }
-        else if (toolOnMouseDown == Tool.PathBasedPen)
-        {
-            ActiveDocument!.StartUpdatePathBasedPen(pos);
-        }
-        else if (toolOnMouseDown == Tool.LineBasedPen)
-        {
-            ActiveDocument!.StartUpdateLineBasedPen((VecI)pos, new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A));
-        }
-        else if (toolOnMouseDown == Tool.PixelPerfectPen)
-        {
-            ActiveDocument!.StartUpdatePixelPerfectPen((VecI)pos, new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A));
-        }
-        else if (toolOnMouseDown == Tool.Eraser)
+        switch (toolOnMouseDown)
         {
-            ActiveDocument!.StartUpdateLineBasedPen((VecI)pos, SKColors.Transparent, true);
+            case Tool.FloodFill:
+                ActiveDocument!.FloodFill((VecI)pos, new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A), ReferenceAllLayers);
+                break;
+            case Tool.PathBasedPen:
+                ActiveDocument!.StartUpdatePathBasedPen(pos);
+                break;
+            case Tool.LineBasedPen:
+                ActiveDocument!.StartUpdateLineBasedPen((VecI)pos, new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A));
+                break;
+            case Tool.PixelPerfectPen:
+                ActiveDocument!.StartUpdatePixelPerfectPen((VecI)pos, new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A));
+                break;
+            case Tool.Eraser:
+                ActiveDocument!.StartUpdateLineBasedPen((VecI)pos, SKColors.Transparent, true);
+                break;
+            case Tool.Pipette:
+                var color = ActiveDocument!.PickColor((VecI)pos, ReferenceAllLayers);
+                SelectedColor = Color.FromArgb(color.Alpha, color.Red, color.Green, color.Blue);
+                break;
         }
     }
 
@@ -207,6 +229,15 @@ internal class ViewModelMain : INotifyPropertyChanged
                     (int)StrokeWidth);
                 break;
             }
+            case Tool.Line:
+            {
+                ActiveDocument!.StartUpdateLine(
+                    (VecI)mouseDownCanvasPos, (VecI)canvasPos,
+                    new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A),
+                    LineStrokeCap,
+                    (int)StrokeWidth);
+                break;
+            }
             case Tool.SelectRectangle:
                 ActiveDocument!.StartUpdateRectSelection(
                     RectI.FromTwoPixels((VecI)mouseDownCanvasPos, (VecI)canvasPos),
@@ -235,6 +266,10 @@ internal class ViewModelMain : INotifyPropertyChanged
             case Tool.Eraser:
                 ActiveDocument!.StartUpdateLineBasedPen((VecI)canvasPos, SKColors.Transparent, true);
                 break;
+            case Tool.Pipette:
+                var color = ActiveDocument!.PickColor((VecI)canvasPos, ReferenceAllLayers);
+                SelectedColor = Color.FromArgb(color.Alpha, color.Red, color.Green, color.Blue);
+                break;
         }
     }
 
@@ -259,6 +294,9 @@ internal class ViewModelMain : INotifyPropertyChanged
                 case Tool.Ellipse:
                     ActiveDocument!.EndEllipse();
                     break;
+                case Tool.Line:
+                    ActiveDocument!.EndLine();
+                    break;
                 case Tool.SelectRectangle:
                     ActiveDocument!.EndRectSelection();
                     break;

+ 46 - 11
src/PixiEditorPrototype/Views/MainWindow.xaml

@@ -13,6 +13,7 @@
     xmlns:controls="clr-namespace:PixiEditorPrototype.CustomControls"
     xmlns:to="clr-namespace:PixiEditorPrototype.CustomControls.TransformOverlay"
     xmlns:vp="clr-namespace:PixiEditorPrototype.UserControls.Viewport"
+    xmlns:sk="clr-namespace:SkiaSharp;assembly=SkiaSharp"
     xmlns:zoombox="clr-namespace:PixiEditor.Zoombox;assembly=PixiEditor.Zoombox"
     xmlns:models="clr-namespace:PixiEditorPrototype.Models"
     xmlns:chen="clr-namespace:PixiEditor.ChangeableDocument.Enums;assembly=PixiEditor.ChangeableDocument"
@@ -399,8 +400,7 @@
             BorderBrush="Black"
             DockPanel.Dock="Top"
             Margin="5">
-            <DockPanel>
-                <StackPanel
+                <WrapPanel
                     Orientation="Horizontal"
                     Background="White">
                     <Button
@@ -470,6 +470,33 @@
                             </i:EventTrigger>
                         </i:Interaction.Triggers>
                     </ComboBox>
+                    <ComboBox
+                        Width="70"
+                        Height="20"
+                        Margin="5"
+                        SelectedIndex="0"
+                        x:Name="lineCapComboBox">
+                        <ComboBoxItem
+                            Tag="{x:Static sk:SKStrokeCap.Butt}">
+                            Butt
+                        </ComboBoxItem>
+                        <ComboBoxItem
+                            Tag="{x:Static sk:SKStrokeCap.Round}">
+                            Round
+                        </ComboBoxItem>
+                        <ComboBoxItem
+                            Tag="{x:Static sk:SKStrokeCap.Square}">
+                            Square
+                        </ComboBoxItem>
+                        <i:Interaction.Triggers>
+                            <i:EventTrigger
+                                EventName="SelectionChanged">
+                                <i:InvokeCommandAction
+                                    Command="{Binding SetLineCapCommand}"
+                                    CommandParameter="{Binding SelectedItem.Tag, ElementName=lineCapComboBox}" />
+                            </i:EventTrigger>
+                        </i:Interaction.Triggers>
+                    </ComboBox>
                     <ComboBox
                         Width="70"
                         Height="20"
@@ -539,11 +566,6 @@
                         Command="{Binding ActiveDocument.ApplyTransformCommand}">
                         Apply Transform
                     </Button>
-                </StackPanel>
-                <StackPanel
-                    DockPanel.Dock="Right"
-                    Orientation="Horizontal"
-                    HorizontalAlignment="Right">
                     <Label>Pen size:</Label>
                     <TextBox
                         Width="30"
@@ -563,8 +585,7 @@
                         Command="{Binding ActiveDocument.ResizeCanvasCommand}">
                         Resize
                     </Button>
-                </StackPanel>
-            </DockPanel>
+                </WrapPanel>
         </Border>
         <Border
             BorderThickness="1"
@@ -582,6 +603,13 @@
                     CommandParameter="{x:Static models:Tool.Rectangle}">
                     Rect
                 </Button>
+                <Button
+                    Width="70"
+                    Margin="5"
+                    Command="{Binding ChangeActiveToolCommand}"
+                    CommandParameter="{x:Static models:Tool.Line}">
+                    Line
+                </Button>
                 <Button
                     Width="70"
                     Margin="5"
@@ -652,6 +680,13 @@
                     CommandParameter="{x:Static models:Tool.FloodFill}">
                     Fill
                 </Button>
+                <Button
+                    Width="70"
+                    Margin="5"
+                    Command="{Binding ChangeActiveToolCommand}"
+                    CommandParameter="{x:Static models:Tool.Pipette}">
+                    Pipette
+                </Button>
                 <colorpicker:PortableColorPicker
                     Margin="5"
                     SelectedColor="{Binding SelectedColor, Mode=TwoWay}"
@@ -693,8 +728,8 @@
                 </CheckBox>
                 <CheckBox
                     Margin="5, 0"
-                    IsChecked="{Binding FillAllLayers}">
-                    Fill all layers
+                    IsChecked="{Binding ReferenceAllLayers}">
+                    Ref all layers
                 </CheckBox>
                 <CheckBox
                     x:Name="horizontalSymmetryCheckbox"