Browse Source

Fix tests, Move away from SKRects, SkiaLineOperation

Equbuxu 3 years ago
parent
commit
da954350dd
24 changed files with 1285 additions and 97 deletions
  1. 13 3
      src/ChunkyImageLib/ChunkyImage.cs
  2. 4 4
      src/ChunkyImageLib/ChunkyImageEx.cs
  3. 1 1
      src/ChunkyImageLib/CommittedChunkStorage.cs
  4. 379 0
      src/ChunkyImageLib/DataHolders/RectD.cs
  5. 339 0
      src/ChunkyImageLib/DataHolders/RectI.cs
  6. 5 5
      src/ChunkyImageLib/DataHolders/ShapeCorners.cs
  7. 15 2
      src/ChunkyImageLib/DataHolders/VecD.cs
  8. 15 1
      src/ChunkyImageLib/DataHolders/VecI.cs
  9. 1 1
      src/ChunkyImageLib/Operations/ChunkyImageOperation.cs
  10. 10 13
      src/ChunkyImageLib/Operations/ClearRegionOperation.cs
  11. 20 7
      src/ChunkyImageLib/Operations/OperationHelper.cs
  12. 11 13
      src/ChunkyImageLib/Operations/PathOperation.cs
  13. 1 1
      src/ChunkyImageLib/Operations/RectangleOperation.cs
  14. 63 0
      src/ChunkyImageLib/Operations/SkiaLineOperation.cs
  15. 4 4
      src/ChunkyImageLibTest/ClearRegionOperationTests.cs
  16. 322 0
      src/ChunkyImageLibTest/RectITests.cs
  17. 1 3
      src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/LayerImageChunks_ChangeInfo.cs
  18. 49 0
      src/PixiEditor.ChangeableDocument/Changes/Drawing/BasicPen_UpdateableChange.cs
  19. 3 5
      src/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelectedArea_Change.cs
  20. 13 15
      src/PixiEditor.ChangeableDocument/Changes/Selection/SelectRectangle_UpdateableChange.cs
  21. 1 1
      src/PixiEditor.ChangeableDocument/Changes/Selection/TransformSelectionPath_UpdateableChange.cs
  22. 3 4
      src/PixiEditorPrototype/Models/ActionAccumulator.cs
  23. 11 12
      src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs
  24. 1 2
      src/PixiEditorPrototype/ViewModels/ViewModelMain.cs

+ 13 - 3
src/ChunkyImageLib/ChunkyImage.cs

@@ -365,7 +365,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>
-    public void EnqueueDrawPath(SKPath path, SKColor color, float strokeWidth, SKStrokeCap strokeCap, SKRect? customBounds = null)
+    public void EnqueueDrawPath(SKPath path, SKColor color, float strokeWidth, SKStrokeCap strokeCap, RectI? customBounds = null)
     {
         lock (lockObject)
         {
@@ -375,6 +375,16 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    public void EnqueueDrawSkiaLine(VecI from, VecI to, SKStrokeCap strokeCap, float strokeWidth, SKColor color)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            SkiaLineOperation operation = new(from, to, strokeCap, strokeWidth, color);
+            EnqueueOperation(operation);
+        }
+    }
+
     public void EnqueueDrawChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false)
     {
         lock (lockObject)
@@ -385,12 +395,12 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
-    public void EnqueueClearRegion(VecI pos, VecI size)
+    public void EnqueueClearRegion(RectI region)
     {
         lock (lockObject)
         {
             ThrowIfDisposed();
-            ClearRegionOperation operation = new(pos, size);
+            ClearRegionOperation operation = new(region);
             EnqueueOperation(operation);
         }
     }

+ 4 - 4
src/ChunkyImageLib/ChunkyImageEx.cs

@@ -6,11 +6,11 @@ namespace ChunkyImageLib;
 public static class IReadOnlyChunkyImageEx
 {
     public static void DrawMostUpToDateRegionOn
-        (this IReadOnlyChunkyImage image, SKRectI fullResRegion, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null)
+        (this IReadOnlyChunkyImage image, RectI fullResRegion, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null)
     {
-        VecI chunkTopLeft = OperationHelper.GetChunkPos(fullResRegion.Location, ChunkyImage.FullChunkSize);
-        VecI chunkBotRigth = OperationHelper.GetChunkPos(fullResRegion.Location + fullResRegion.Size, ChunkyImage.FullChunkSize);
-        VecI offsetFullRes = (chunkTopLeft * ChunkyImage.FullChunkSize) - (VecI)fullResRegion.Location;
+        VecI chunkTopLeft = OperationHelper.GetChunkPos(fullResRegion.TopLeft, ChunkyImage.FullChunkSize);
+        VecI chunkBotRigth = OperationHelper.GetChunkPos(fullResRegion.BottomRight, ChunkyImage.FullChunkSize);
+        VecI offsetFullRes = (chunkTopLeft * ChunkyImage.FullChunkSize) - fullResRegion.Pos;
         VecI offsetTargetRes = (VecI)(offsetFullRes * resolution.Multiplier());
 
         for (int j = chunkTopLeft.Y; j <= chunkBotRigth.Y; j++)

+ 1 - 1
src/ChunkyImageLib/CommittedChunkStorage.cs

@@ -31,7 +31,7 @@ public class CommittedChunkStorage : IDisposable
         foreach (var (pos, chunk) in savedChunks)
         {
             if (chunk is null)
-                image.EnqueueClearRegion(pos * ChunkPool.FullChunkSize, new(ChunkPool.FullChunkSize, ChunkPool.FullChunkSize));
+                image.EnqueueClearRegion(new(pos * ChunkPool.FullChunkSize, new(ChunkPool.FullChunkSize, ChunkPool.FullChunkSize)));
             else
                 image.EnqueueDrawImage(pos * ChunkPool.FullChunkSize, chunk.Surface, ReplacingPaint);
         }

+ 379 - 0
src/ChunkyImageLib/DataHolders/RectD.cs

@@ -0,0 +1,379 @@
+using SkiaSharp;
+
+namespace ChunkyImageLib.DataHolders;
+public struct RectD : IEquatable<RectD>
+{
+    public static RectD Empty { get; } = new RectD();
+
+    private double left;
+    private double top;
+    private double right;
+    private double bottom;
+
+    public double Left { readonly get => left; set => left = value; }
+    public double Top { readonly get => top; set => top = value; }
+    public double Right { readonly get => right; set => right = value; }
+    public double Bottom { readonly get => bottom; set => bottom = value; }
+    public double X { readonly get => left; set => left = value; }
+    public double Y { readonly get => top; set => top = value; }
+    public bool HasNaNOrInfinity =>
+        double.IsNaN(left) || double.IsInfinity(left) ||
+        double.IsNaN(right) || double.IsInfinity(right) ||
+        double.IsNaN(top) || double.IsInfinity(top) ||
+        double.IsNaN(bottom) || double.IsInfinity(bottom);
+
+    public VecD Pos
+    {
+        readonly get => new VecD(left, top);
+        set
+        {
+            right = (right - left) + value.X;
+            bottom = (bottom - top) + value.Y;
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecD TopLeft
+    {
+        readonly get => new VecD(left, top);
+        set
+        {
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecD TopRight
+    {
+        readonly get => new VecD(right, top);
+        set
+        {
+            right = value.X;
+            top = value.Y;
+        }
+    }
+    public VecD BottomLeft
+    {
+        readonly get => new VecD(left, bottom);
+        set
+        {
+            left = value.X;
+            bottom = value.Y;
+        }
+    }
+    public VecD BottomRight
+    {
+        readonly get => new VecD(right, bottom);
+        set
+        {
+            right = value.X;
+            bottom = value.Y;
+        }
+    }
+    public VecD Size
+    {
+        readonly get => new VecD(right - left, bottom - top);
+        set
+        {
+            right = left + value.X;
+            bottom = top + value.Y;
+        }
+    }
+    public double Width { readonly get => right - left; set => right = left + value; }
+    public double Height { readonly get => bottom - top; set => bottom = top + value; }
+    public readonly bool IsZeroArea => left == right || top == bottom;
+
+    public RectD()
+    {
+        left = 0d;
+        top = 0d;
+        right = 0d;
+        bottom = 0d;
+    }
+
+    public RectD(VecD pos, VecD size)
+    {
+        left = pos.X;
+        top = pos.Y;
+        right = pos.X + size.X;
+        bottom = pos.Y + size.Y;
+    }
+
+    public RectD(double x, double y, double width, double height)
+    {
+        left = x;
+        top = y;
+        right = x + width;
+        bottom = y + height;
+    }
+    public static RectD FromSides(double left, double right, double top, double bottom)
+    {
+        return new RectD()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static RectD FromTwoPoints(VecD point, VecD opposite)
+    {
+        return new RectD()
+        {
+            Left = Math.Min(point.X, opposite.X),
+            Right = Math.Max(point.X, opposite.X),
+            Top = Math.Min(point.Y, opposite.Y),
+            Bottom = Math.Max(point.Y, opposite.Y)
+        };
+    }
+
+    public static RectD FromCenterAndSize(VecD center, VecD size)
+    {
+        return new RectD()
+        {
+            Left = center.X - size.X / 2,
+            Right = center.X + size.X / 2,
+            Top = center.Y - size.Y / 2,
+            Bottom = center.Y + size.Y / 2
+        };
+    }
+
+    /// <summary>
+    /// Converts rectangles with negative dimensions into a normal one
+    /// </summary>
+    public readonly RectD Standardize()
+    {
+        (double newLeft, double newRight) = left > right ? (right, left) : (left, right);
+        (double newTop, double newBottom) = top > bottom ? (bottom, top) : (top, bottom);
+        return new RectD()
+        {
+            Left = newLeft,
+            Right = newRight,
+            Top = newTop,
+            Bottom = newBottom
+        };
+    }
+
+    public readonly RectD ReflectX(double verLineX)
+    {
+        return RectD.FromTwoPoints(Pos.ReflectX(verLineX), (Pos + Size).ReflectX(verLineX));
+    }
+
+    public readonly RectD ReflectY(double horLineY)
+    {
+        return RectD.FromTwoPoints(Pos.ReflectY(horLineY), (Pos + Size).ReflectY(horLineY));
+    }
+
+    public readonly RectD Offset(VecD offset) => Offset(offset.X, offset.Y);
+    public readonly RectD Offset(double x, double y)
+    {
+        return new RectD()
+        {
+            Left = left + x,
+            Right = right + x,
+            Top = top + y,
+            Bottom = bottom + y
+        };
+    }
+
+    public readonly RectD Inflate(VecD amount) => Inflate(amount.Y, amount.Y);
+    public readonly RectD Inflate(double x, double y)
+    {
+        return new RectD()
+        {
+            Left = left - x,
+            Right = right + x,
+            Top = top - y,
+            Bottom = bottom + y,
+        };
+    }
+
+    public readonly RectD Inflate(double amount)
+    {
+        return new RectD()
+        {
+            Left = left - amount,
+            Right = right + amount,
+            Top = top - amount,
+            Bottom = bottom + amount,
+        };
+    }
+
+    /// <summary>
+    /// Fits passed rectangle into this rectangle while maintaining aspect ratio
+    /// </summary>
+    public readonly RectD AspectFit(RectD rect)
+    {
+        double widthRatio = Width / rect.Width;
+        double heightRatio = Height / rect.Height;
+        if (widthRatio > heightRatio)
+        {
+            double newWidth = Height * rect.Width / rect.Height;
+            double newLeft = left + (Width - newWidth) / 2;
+            return new RectD(new(newLeft, top), new(newWidth, Height));
+        }
+        else
+        {
+            double newHeight = Width * rect.Height / rect.Width;
+            double newTop = top + (Height - newHeight) / 2;
+            return new RectD(new(left, newTop), new(Width, newHeight));
+        }
+    }
+
+    public readonly RectD RoundOutwards()
+    {
+        return new RectD()
+        {
+            Left = Math.Floor(left),
+            Right = Math.Ceiling(right),
+            Top = Math.Floor(top),
+            Bottom = Math.Ceiling(bottom)
+        };
+    }
+
+    public readonly RectD RoundInwards()
+    {
+        return new RectD()
+        {
+            Left = Math.Ceiling(left),
+            Right = Math.Floor(right),
+            Top = Math.Ceiling(top),
+            Bottom = Math.Floor(bottom)
+        };
+    }
+
+    public readonly bool ContainsInclusive(VecD point) => ContainsInclusive(point.X, point.Y);
+    public readonly bool ContainsInclusive(double x, double y)
+    {
+        return x >= left && x <= right && y >= top && y <= bottom;
+    }
+
+    public readonly bool ContainsExclusive(VecD point) => ContainsExclusive(point.X, point.Y);
+    public readonly bool ContainsExclusive(double x, double y)
+    {
+        return x > left && x < right && y > top && y < bottom;
+    }
+
+    public readonly bool IntersectsWithInclusive(RectD rect)
+    {
+        return left <= rect.right && right >= rect.left && top <= rect.bottom && bottom >= rect.top;
+    }
+
+    public readonly bool IntersectsWithExclusive(RectD rect)
+    {
+        return left < rect.right && right > rect.left && top < rect.bottom && bottom > rect.top;
+    }
+
+    public readonly RectD Intersect(RectD other)
+    {
+        double left = Math.Max(this.left, other.left);
+        double top = Math.Max(this.top, other.top);
+
+        double right = Math.Min(this.right, other.right);
+        double bottom = Math.Min(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectD.Empty;
+
+        return new RectD()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public readonly RectD Union(RectD other)
+    {
+        double left = Math.Min(this.left, other.left);
+        double top = Math.Min(this.top, other.top);
+
+        double right = Math.Max(this.right, other.right);
+        double bottom = Math.Max(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectD.Empty;
+
+        return new RectD()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static explicit operator RectI(RectD rect)
+    {
+        return new RectI()
+        {
+            Left = (int)rect.left,
+            Right = (int)rect.right,
+            Top = (int)rect.top,
+            Bottom = (int)rect.bottom
+        };
+    }
+
+    public static explicit operator SKRect(RectD rect)
+    {
+        return new SKRect((float)rect.left, (float)rect.top, (float)rect.right, (float)rect.bottom);
+    }
+
+    public static explicit operator SKRectI(RectD rect)
+    {
+        return new SKRectI((int)rect.left, (int)rect.top, (int)rect.right, (int)rect.bottom);
+    }
+
+    public static implicit operator RectD(SKRect rect)
+    {
+        return new RectD()
+        {
+            Left = rect.Left,
+            Right = rect.Right,
+            Top = rect.Top,
+            Bottom = rect.Bottom
+        };
+    }
+
+    public static implicit operator RectD(SKRectI rect)
+    {
+        return new RectD()
+        {
+            Left = rect.Left,
+            Right = rect.Right,
+            Top = rect.Top,
+            Bottom = rect.Bottom
+        };
+    }
+
+    public static bool operator ==(RectD left, RectD right)
+    {
+        return left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom;
+    }
+
+    public static bool operator !=(RectD left, RectD right)
+    {
+        return !(left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom);
+    }
+
+    public readonly override bool Equals(object? obj)
+    {
+        return obj is RectD rect && rect.left == left && rect.right == right && rect.top == top && rect.bottom == bottom;
+    }
+
+    public readonly bool Equals(RectD other)
+    {
+        return left == other.left && top == other.top && right == other.right && bottom == other.bottom;
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(left, top, right, bottom);
+    }
+
+    public override string ToString()
+    {
+        return $"{{X: {X}, Y: {Y}, W: {Width}, H: {Height}}}";
+    }
+}

+ 339 - 0
src/ChunkyImageLib/DataHolders/RectI.cs

@@ -0,0 +1,339 @@
+using SkiaSharp;
+
+namespace ChunkyImageLib.DataHolders;
+public struct RectI : IEquatable<RectI>
+{
+    public static RectI Empty { get; } = new RectI();
+
+    private int left;
+    private int top;
+    private int right;
+    private int bottom;
+
+    public int Left { readonly get => left; set => left = value; }
+    public int Top { readonly get => top; set => top = value; }
+    public int Right { readonly get => right; set => right = value; }
+    public int Bottom { readonly get => bottom; set => bottom = value; }
+    public int X { readonly get => left; set => left = value; }
+    public int Y { readonly get => top; set => top = value; }
+    public VecI Pos
+    {
+        readonly get => new VecI(left, top);
+        set
+        {
+            right = (right - left) + value.X;
+            bottom = (bottom - top) + value.Y;
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecI TopLeft
+    {
+        readonly get => new VecI(left, top);
+        set
+        {
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecI TopRight
+    {
+        readonly get => new VecI(right, top);
+        set
+        {
+            right = value.X;
+            top = value.Y;
+        }
+    }
+    public VecI BottomLeft
+    {
+        readonly get => new VecI(left, bottom);
+        set
+        {
+            left = value.X;
+            bottom = value.Y;
+        }
+    }
+    public VecI BottomRight
+    {
+        readonly get => new VecI(right, bottom);
+        set
+        {
+            right = value.X;
+            bottom = value.Y;
+        }
+    }
+
+    public VecI Size
+    {
+        readonly get => new VecI(right - left, bottom - top);
+        set
+        {
+            right = left + value.X;
+            bottom = top + value.Y;
+        }
+    }
+    public int Width { readonly get => right - left; set => right = left + value; }
+    public int Height { readonly get => bottom - top; set => bottom = top + value; }
+    public readonly bool IsZeroArea => left == right || top == bottom;
+
+    public RectI()
+    {
+        left = 0;
+        top = 0;
+        right = 0;
+        bottom = 0;
+    }
+
+    public RectI(int x, int y, int width, int height)
+    {
+        left = x;
+        top = y;
+        right = x + width;
+        bottom = y + height;
+    }
+
+    public RectI(VecI pos, VecI size)
+    {
+        left = pos.X;
+        top = pos.Y;
+        right = pos.X + size.X;
+        bottom = pos.Y + size.Y;
+    }
+
+    public static RectI FromSides(int left, int right, int top, int bottom)
+    {
+        return new RectI()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static RectI FromTwoPoints(VecI point, VecI opposite)
+    {
+        return new RectI()
+        {
+            Left = Math.Min(point.X, opposite.X),
+            Right = Math.Max(point.X, opposite.X),
+            Top = Math.Min(point.Y, opposite.Y),
+            Bottom = Math.Max(point.Y, opposite.Y)
+        };
+    }
+
+    /// <summary>
+    /// Converts rectangle with negative dimensions into a normal one
+    /// </summary>
+    public readonly RectI Standardize()
+    {
+        (int newLeft, int newRight) = left > right ? (right, left) : (left, right);
+        (int newTop, int newBottom) = top > bottom ? (bottom, top) : (top, bottom);
+        return new RectI()
+        {
+            Left = newLeft,
+            Right = newRight,
+            Top = newTop,
+            Bottom = newBottom
+        };
+    }
+
+    public readonly RectI ReflectX(int verLineX)
+    {
+        return RectI.FromTwoPoints(Pos.ReflectX(verLineX), (Pos + Size).ReflectX(verLineX));
+    }
+
+    public readonly RectI ReflectY(int horLineY)
+    {
+        return RectI.FromTwoPoints(Pos.ReflectY(horLineY), (Pos + Size).ReflectY(horLineY));
+    }
+
+    public readonly RectI Offset(VecI offset) => Offset(offset.X, offset.Y);
+    public readonly RectI Offset(int x, int y)
+    {
+        return new RectI()
+        {
+            Left = left + x,
+            Right = right + x,
+            Top = top + y,
+            Bottom = bottom + y
+        };
+    }
+
+    public readonly RectI Inflate(VecI amount) => Inflate(amount.Y, amount.Y);
+    public readonly RectI Inflate(int x, int y)
+    {
+        return new RectI()
+        {
+            Left = left - x,
+            Right = right + x,
+            Top = top - y,
+            Bottom = bottom + y,
+        };
+    }
+
+    public readonly RectI Inflate(int amount)
+    {
+        return new RectI()
+        {
+            Left = left - amount,
+            Right = right + amount,
+            Top = top - amount,
+            Bottom = bottom + amount,
+        };
+    }
+
+    /// <summary>
+    /// Fits passed rectangle into this rectangle while maintaining aspect ratio
+    /// </summary>
+    public readonly RectI AspectFit(RectI rect)
+    {
+        return (RectI)((RectD)this).AspectFit(rect);
+    }
+
+    public readonly bool ContainsInclusive(VecI point) => ContainsInclusive(point.X, point.Y);
+    public readonly bool ContainsInclusive(int x, int y)
+    {
+        return x >= left && x <= right && y >= top && y <= bottom;
+    }
+
+    public readonly bool ContainsExclusive(VecI point) => ContainsExclusive(point.X, point.Y);
+    public readonly bool ContainsExclusive(int x, int y)
+    {
+        return x > left && x < right && y > top && y < bottom;
+    }
+
+    public readonly bool ContainsPixel(VecI pixelTopLeft) => ContainsPixel(pixelTopLeft.X, pixelTopLeft.Y);
+    public readonly bool ContainsPixel(int pixelTopLeftX, int pixelTopLeftY)
+    {
+        return
+            pixelTopLeftX >= left &&
+            pixelTopLeftX < right &&
+            pixelTopLeftY >= top &&
+            pixelTopLeftY < bottom;
+    }
+
+    public readonly bool IntersectsWithInclusive(RectI rect)
+    {
+        return left <= rect.right && right >= rect.left && top <= rect.bottom && bottom >= rect.top;
+    }
+
+    public readonly bool IntersectsWithExclusive(RectI rect)
+    {
+        return left < rect.right && right > rect.left && top < rect.bottom && bottom > rect.top;
+    }
+
+    public readonly RectI Intersect(RectI other)
+    {
+        int left = Math.Max(this.left, other.left);
+        int top = Math.Max(this.top, other.top);
+
+        int right = Math.Min(this.right, other.right);
+        int bottom = Math.Min(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectI.Empty;
+
+        return new RectI()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public readonly RectI Union(RectI other)
+    {
+        int left = Math.Min(this.left, other.left);
+        int top = Math.Min(this.top, other.top);
+
+        int right = Math.Max(this.right, other.right);
+        int bottom = Math.Max(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectI.Empty;
+
+        return new RectI()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static implicit operator RectD(RectI rect)
+    {
+        return new RectD()
+        {
+            Left = rect.left,
+            Right = rect.right,
+            Top = rect.top,
+            Bottom = rect.bottom
+        };
+    }
+
+    public static implicit operator SKRect(RectI rect)
+    {
+        return new SKRect(rect.left, rect.top, rect.right, rect.bottom);
+    }
+
+    public static implicit operator SKRectI(RectI rect)
+    {
+        return new SKRectI(rect.left, rect.top, rect.right, rect.bottom);
+    }
+
+    public static explicit operator RectI(SKRect rect)
+    {
+        return new RectI()
+        {
+            Left = (int)rect.Left,
+            Right = (int)rect.Right,
+            Top = (int)rect.Top,
+            Bottom = (int)rect.Bottom
+        };
+    }
+
+    public static implicit operator RectI(SKRectI rect)
+    {
+        return new RectI()
+        {
+            Left = rect.Left,
+            Right = rect.Right,
+            Top = rect.Top,
+            Bottom = rect.Bottom
+        };
+    }
+
+    public static bool operator ==(RectI left, RectI right)
+    {
+        return left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom;
+    }
+
+    public static bool operator !=(RectI left, RectI right)
+    {
+        return !(left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom);
+    }
+
+    public readonly override bool Equals(object? obj)
+    {
+        return obj is RectI rect && rect.left == left && rect.right == right && rect.top == top && rect.bottom == bottom;
+    }
+
+    public readonly bool Equals(RectI other)
+    {
+        return left == other.left && top == other.top && right == other.right && bottom == other.bottom;
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(left, top, right, bottom);
+    }
+
+    public override string ToString()
+    {
+        return $"{{X: {X}, Y: {Y}, W: {Width}, H: {Height}}}";
+    }
+}

+ 5 - 5
src/ChunkyImageLib/DataHolders/ShapeCorners.cs

@@ -8,12 +8,12 @@ public struct ShapeCorners
         BottomRight = center + size / 2;
         BottomLeft = center + new VecD(-size.X / 2, size.Y / 2);
     }
-    public ShapeCorners(VecD topLeft, VecD size)
+    public ShapeCorners(RectD rect)
     {
-        TopLeft = topLeft;
-        TopRight = new(topLeft.X + size.X, topLeft.Y);
-        BottomRight = topLeft + size;
-        BottomLeft = new(topLeft.X, topLeft.Y + size.Y);
+        TopLeft = rect.TopLeft;
+        TopRight = rect.TopRight;
+        BottomRight = rect.BottomRight;
+        BottomLeft = rect.BottomLeft;
     }
     public VecD TopLeft { get; set; }
     public VecD TopRight { get; set; }

+ 15 - 2
src/ChunkyImageLib/DataHolders/VecD.cs

@@ -2,7 +2,7 @@
 
 namespace ChunkyImageLib.DataHolders;
 
-public struct VecD
+public struct VecD : IEquatable<VecD>
 {
     public double X { set; get; }
     public double Y { set; get; }
@@ -213,7 +213,15 @@ public struct VecD
     {
         return new SKSize((float)vec.X, (float)vec.Y);
     }
-
+    public static implicit operator VecD((double, double) tuple)
+    {
+        return new VecD(tuple.Item1, tuple.Item2);
+    }
+    public void Deconstruct(out double x, out double y)
+    {
+        x = X;
+        y = Y;
+    }
     public bool IsNaNOrInfinity()
     {
         return double.IsNaN(X) || double.IsNaN(Y) || double.IsInfinity(X) || double.IsInfinity(Y);
@@ -236,4 +244,9 @@ public struct VecD
     {
         return HashCode.Combine(X, Y);
     }
+
+    public bool Equals(VecD other)
+    {
+        return other.X == X && other.Y == Y;
+    }
 }

+ 15 - 1
src/ChunkyImageLib/DataHolders/VecI.cs

@@ -2,7 +2,7 @@
 
 namespace ChunkyImageLib.DataHolders;
 
-public struct VecI
+public struct VecI : IEquatable<VecI>
 {
     public int X { set; get; }
     public int Y { set; get; }
@@ -135,6 +135,15 @@ public struct VecI
     {
         return new SKSize(vec.X, vec.Y);
     }
+    public static implicit operator VecI((int, int) tuple)
+    {
+        return new VecI(tuple.Item1, tuple.Item2);
+    }
+    public void Deconstruct(out int x, out int y)
+    {
+        x = X;
+        y = Y;
+    }
 
     public override string ToString()
     {
@@ -153,4 +162,9 @@ public struct VecI
     {
         return HashCode.Combine(X, Y);
     }
+
+    public bool Equals(VecI other)
+    {
+        return other.X == X && other.Y == Y;
+    }
 }

+ 1 - 1
src/ChunkyImageLib/Operations/ChunkyImageOperation.cs

@@ -95,7 +95,7 @@ internal class ChunkyImageOperation : IDrawOperation
 
     public HashSet<VecI> FindAffectedChunks()
     {
-        return OperationHelper.FindChunksTouchingRectangle(GetTopLeft(), imageToDraw.CommittedSize, ChunkyImage.FullChunkSize);
+        return OperationHelper.FindChunksTouchingRectangle(new(GetTopLeft(), imageToDraw.CommittedSize), ChunkyImage.FullChunkSize);
     }
 
     private VecI GetTopLeft()

+ 10 - 13
src/ChunkyImageLib/Operations/ClearRegionOperation.cs

@@ -5,21 +5,19 @@ namespace ChunkyImageLib.Operations;
 
 internal class ClearRegionOperation : IDrawOperation
 {
-    VecI pos;
-    VecI size;
+    RectI rect;
 
     public bool IgnoreEmptyChunks => true;
 
-    public ClearRegionOperation(VecI pos, VecI size)
+    public ClearRegionOperation(RectI rect)
     {
-        this.pos = pos;
-        this.size = size;
+        this.rect = rect;
     }
 
     public void DrawOnChunk(Chunk chunk, VecI chunkPos)
     {
-        VecI convPos = OperationHelper.ConvertForResolution(pos, chunk.Resolution);
-        VecI convSize = OperationHelper.ConvertForResolution(size, chunk.Resolution);
+        VecI convPos = OperationHelper.ConvertForResolution(rect.Pos, chunk.Resolution);
+        VecI convSize = OperationHelper.ConvertForResolution(rect.Size, chunk.Resolution);
 
         chunk.Surface.SkiaSurface.Canvas.Save();
         chunk.Surface.SkiaSurface.Canvas.ClipRect(SKRect.Create(convPos - chunkPos.Multiply(chunk.PixelSize), convSize));
@@ -29,18 +27,17 @@ internal class ClearRegionOperation : IDrawOperation
 
     public HashSet<VecI> FindAffectedChunks()
     {
-        return OperationHelper.FindChunksFullyInsideRectangle(pos, size, ChunkPool.FullChunkSize);
+        return OperationHelper.FindChunksTouchingRectangle(rect, ChunkPool.FullChunkSize);
     }
     public void Dispose() { }
 
     public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
     {
-        if (verAxisX is not null && horAxisY is not null)
-            return new ClearRegionOperation((pos + size).ReflectX((int)verAxisX).ReflectY((int)horAxisY), size);
+        var newRect = rect;
         if (verAxisX is not null)
-            return new ClearRegionOperation(new VecI(pos.X + size.X, pos.Y).ReflectX((int)verAxisX), size);
+            newRect = newRect.ReflectX((int)verAxisX);
         if (horAxisY is not null)
-            return new ClearRegionOperation(new VecI(pos.X, pos.Y + size.Y).ReflectY((int)horAxisY), size);
-        return new ClearRegionOperation(pos, size);
+            newRect = newRect.ReflectY((int)horAxisY);
+        return new ClearRegionOperation(newRect);
     }
 }

+ 20 - 7
src/ChunkyImageLib/Operations/OperationHelper.cs

@@ -143,10 +143,13 @@ public static class OperationHelper
         return output;
     }
 
-    public static HashSet<VecI> FindChunksTouchingRectangle(VecI topLeft, VecI size, int chunkSize)
+    public static HashSet<VecI> FindChunksTouchingRectangle(RectI rect, int chunkSize)
     {
-        VecI min = GetChunkPos(topLeft, chunkSize);
-        VecI max = GetChunkPosBiased(topLeft + size, false, false, chunkSize);
+        if (rect.Width > chunkSize * 40 * 20 || rect.Height > chunkSize * 40 * 20)
+            return new HashSet<VecI>();
+
+        VecI min = GetChunkPos(rect.TopLeft, chunkSize);
+        VecI max = GetChunkPosBiased(rect.BottomRight, false, false, chunkSize);
         HashSet<VecI> output = new();
         for (int x = min.X; x <= max.X; x++)
         {
@@ -163,6 +166,8 @@ public static class OperationHelper
     /// </summary>
     public static HashSet<VecI> FindChunksTouchingRectangle(VecD center, VecD size, double angle, int chunkSize)
     {
+        if (angle == 0)
+            return FindChunksTouchingRectangle((RectI)RectD.FromCenterAndSize(center, size).RoundOutwards(), chunkSize);
         if (size.X == 0 || size.Y == 0 || center.IsNaNOrInfinity() || size.IsNaNOrInfinity() || double.IsNaN(angle) || double.IsInfinity(angle))
             return new HashSet<VecI>();
         if (size.X > chunkSize * 40 * 20 || size.Y > chunkSize * 40 * 20)
@@ -222,12 +227,12 @@ public static class OperationHelper
         return output;
     }
 
-    public static HashSet<VecI> FindChunksFullyInsideRectangle(VecI pos, VecI size, int chunkSize)
+    public static HashSet<VecI> FindChunksFullyInsideRectangle(RectI rect, int chunkSize)
     {
-        if (size.X > chunkSize * 40 * 20 || size.Y > chunkSize * 40 * 20)
+        if (rect.Width > chunkSize * 40 * 20 || rect.Height > chunkSize * 40 * 20)
             return new HashSet<VecI>();
-        VecI startChunk = GetChunkPos(pos, ChunkPool.FullChunkSize);
-        VecI endChunk = GetChunkPosBiased(pos + size, false, false, chunkSize);
+        VecI startChunk = GetChunkPosBiased(rect.TopLeft, false, false, ChunkPool.FullChunkSize) + new VecI(1, 1);
+        VecI endChunk = GetChunkPosBiased(rect.BottomRight, true, true, chunkSize) - new VecI(1, 1);
         HashSet<VecI> output = new();
         for (int x = startChunk.X; x <= endChunk.X; x++)
         {
@@ -241,6 +246,8 @@ public static class OperationHelper
 
     public static HashSet<VecI> FindChunksFullyInsideRectangle(VecD center, VecD size, double angle, int chunkSize)
     {
+        if (angle == 0)
+            return FindChunksFullyInsideRectangle((RectI)RectD.FromCenterAndSize(center, size).RoundOutwards(), chunkSize);
         if (size.X < chunkSize || size.Y < chunkSize || center.IsNaNOrInfinity() || size.IsNaNOrInfinity() || double.IsNaN(angle) || double.IsInfinity(angle))
             return new HashSet<VecI>();
         if (size.X > chunkSize * 40 * 20 || size.Y > chunkSize * 40 * 20)
@@ -416,6 +423,12 @@ public static class OperationHelper
         return m * x + b;
     }
 
+    /// <summary>
+    /// "Bias" specifies how to handle whole values. This function behaves the same as GetChunkPos for fractional values.
+    /// Examples if you pass (0, 0):
+    /// If both positiveX and positiveY are true it behaves like GetChunkPos, you get chunk (0, 0)
+    /// If both are false you'll get (-1, -1), because the right and bottom boundaries are now considered to be part of the chunk, and top and left aren't.
+    /// </summary>
     public static VecI GetChunkPosBiased(VecD pos, bool positiveX, bool positiveY, int chunkSize)
     {
         pos /= chunkSize;

+ 11 - 13
src/ChunkyImageLib/Operations/PathOperation.cs

@@ -7,20 +7,17 @@ internal class PathOperation : IDrawOperation
     private readonly SKPath path;
 
     private readonly SKPaint paint;
-    private readonly VecI boundsTopLeft;
-    private readonly VecI boundsSize;
+    private readonly RectI bounds;
 
     public bool IgnoreEmptyChunks => false;
 
-    public PathOperation(SKPath path, SKColor color, float strokeWidth, SKStrokeCap cap, SKRect? customBounds = null)
+    public PathOperation(SKPath path, SKColor color, float strokeWidth, SKStrokeCap cap, RectI? customBounds = null)
     {
         this.path = new SKPath(path);
         paint = new() { Color = color, Style = SKPaintStyle.Stroke, StrokeWidth = strokeWidth, StrokeCap = cap };
 
-        var floatBounds = customBounds ?? path.TightBounds;
-        floatBounds.Inflate(strokeWidth + 1, strokeWidth + 1);
-        boundsTopLeft = (VecI)floatBounds.Location;
-        boundsSize = (VecI)floatBounds.Size;
+        RectI floatBounds = customBounds ?? (RectI)((RectD)path.TightBounds).RoundOutwards();
+        bounds = floatBounds.Inflate((int)Math.Ceiling(strokeWidth) + 1);
     }
 
     public void DrawOnChunk(Chunk chunk, VecI chunkPos)
@@ -35,7 +32,7 @@ internal class PathOperation : IDrawOperation
 
     public HashSet<VecI> FindAffectedChunks()
     {
-        return OperationHelper.FindChunksTouchingRectangle(boundsTopLeft, boundsSize, ChunkyImage.FullChunkSize);
+        return OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize);
     }
 
     public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
@@ -44,11 +41,12 @@ internal class PathOperation : IDrawOperation
         using var copy = new SKPath(path);
         copy.Transform(matrix);
 
-        VecI p1 = (VecI)matrix.MapPoint(boundsTopLeft);
-        VecI p2 = (VecI)matrix.MapPoint(boundsTopLeft + boundsSize);
-        VecI topLeft = new(Math.Min(p1.X, p2.X), Math.Min(p1.Y, p2.Y));
-
-        return new PathOperation(copy, paint.Color, paint.StrokeWidth, paint.StrokeCap, SKRect.Create(topLeft, boundsSize));
+        RectI newBounds = bounds;
+        if (verAxisX is not null)
+            newBounds = newBounds.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newBounds = newBounds.ReflectY((int)horAxisY);
+        return new PathOperation(copy, paint.Color, paint.StrokeWidth, paint.StrokeCap, newBounds);
     }
 
     public void Dispose()

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

@@ -25,7 +25,7 @@ internal class RectangleOperation : IDrawOperation
         var convertedSize = OperationHelper.ConvertForResolution(Data.Size.Abs(), chunk.Resolution);
         int convertedStroke = (int)Math.Round(chunk.Resolution.Multiplier() * Data.StrokeWidth);
 
-        var rect = SKRect.Create((SKPoint)convertedPos, (SKSize)convertedSize);
+        var rect = (SKRect)new RectD(convertedPos, convertedSize);
 
         skiaSurf.Canvas.Translate((SKPoint)convertedCenter);
         skiaSurf.Canvas.RotateRadians((float)Data.Angle);

+ 63 - 0
src/ChunkyImageLib/Operations/SkiaLineOperation.cs

@@ -0,0 +1,63 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class SkiaLineOperation : IDrawOperation
+{
+    public bool IgnoreEmptyChunks => false;
+
+    private SKPaint paint;
+    private readonly VecI from;
+    private readonly VecI to;
+
+    public SkiaLineOperation(VecI from, VecI to, SKStrokeCap strokeCap, float strokeWidth, SKColor color)
+    {
+        paint = new()
+        {
+            StrokeCap = strokeCap,
+            StrokeWidth = strokeWidth,
+            Color = color,
+            Style = SKPaintStyle.Stroke,
+        };
+        this.from = from;
+        this.to = to;
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        var surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        surf.Canvas.DrawLine(from, to, paint);
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        RectI bounds = RectI.FromTwoPoints(from, to).Inflate((int)Math.Ceiling(paint.StrokeWidth));
+        return OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize);
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        VecI newFrom = from;
+        VecI newTo = to;
+        if (verAxisX is not null)
+        {
+            newFrom = newFrom.ReflectX((int)verAxisX);
+            newTo = newFrom.ReflectX((int)verAxisX);
+        }
+        if (horAxisY is not null)
+        {
+            newFrom = newFrom.ReflectY((int)horAxisY);
+            newTo = newFrom.ReflectY((int)horAxisY);
+        }
+        return new SkiaLineOperation(newFrom, newTo, paint.StrokeCap, paint.StrokeWidth, paint.Color);
+    }
+
+    public void Dispose()
+    {
+        paint.Dispose();
+    }
+}

+ 4 - 4
src/ChunkyImageLibTest/ClearRegionOperationTests.cs

@@ -1,7 +1,7 @@
-using ChunkyImageLib;
+using System.Collections.Generic;
+using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.Operations;
-using System.Collections.Generic;
 using Xunit;
 
 namespace ChunkyImageLibTest;
@@ -12,7 +12,7 @@ public class ClearRegionOperationTests
     [Fact]
     public void FindAffectedChunks_SingleChunk_ReturnsSingleChunk()
     {
-        ClearRegionOperation operation = new(new(chunkSize, chunkSize), new(chunkSize, chunkSize));
+        ClearRegionOperation operation = new(new(new(chunkSize, chunkSize), new(chunkSize, chunkSize)));
         var expected = new HashSet<VecI>() { new(1, 1) };
         var actual = operation.FindAffectedChunks();
         Assert.Equal(expected, actual);
@@ -25,7 +25,7 @@ public class ClearRegionOperationTests
     {
         int from = -chunkSize - chunkSize / 2;
         int to = chunkSize + chunkSize / 2;
-        ClearRegionOperation operation = new(new(from, from), new(to - from, to - from));
+        ClearRegionOperation operation = new(new(new(from, from), new(to - from, to - from)));
         var expected = new HashSet<VecI>() 
         { 
             new(-2, -2), new(-1, -2), new(0, -2), new(1, -2),

+ 322 - 0
src/ChunkyImageLibTest/RectITests.cs

@@ -0,0 +1,322 @@
+using System;
+using ChunkyImageLib.DataHolders;
+using Xunit;
+
+namespace ChunkyImageLibTest;
+public class RectITests
+{
+    [Fact]
+    public void EmptyConstructor_Call_ResultsInZeroVec()
+    {
+        RectI rect = new RectI();
+        Assert.Equal(0, rect.Left);
+        Assert.Equal(0, rect.Right);
+        Assert.Equal(0, rect.Top);
+        Assert.Equal(0, rect.Bottom);
+    }
+
+    [Fact]
+    public void RegularConstructor_WithBasicArgs_Works()
+    {
+        RectI rect = new RectI(800, 600, 200, 300);
+        Assert.Equal(800, rect.Left);
+        Assert.Equal(600, rect.Top);
+        Assert.Equal(800 + 200, rect.Right);
+        Assert.Equal(600 + 300, rect.Bottom);
+    }
+
+    [Fact]
+    public void FromTwoPoints_DiagonalsCombinations_ReturnsStandardizedRects()
+    {
+        RectI refR = new RectI(3, 4, 8 - 3, 9 - 4);
+        Span<RectI> rects = stackalloc RectI[] {
+            RectI.FromTwoPoints(new VecI(3, 4), new VecI(8, 9)),
+            RectI.FromTwoPoints(new VecI(8, 9), new VecI(3, 4)),
+            RectI.FromTwoPoints(new VecI(8, 9), new VecI(3, 4)),
+            RectI.FromTwoPoints(new VecI(8, 9), new VecI(3, 4)),
+        };
+        foreach (var rect in rects)
+        {
+            Assert.Equal(
+                (refR.Left, refR.Top, refR.Right, refR.Bottom),
+                (rect.Left, rect.Top, rect.Right, rect.Bottom));
+        }
+    }
+
+    [Fact]
+    public void Properties_OfStandardRectangle_ReturnCorrectValues()
+    {
+        RectI r = new(new VecI(2, 3), new VecI(4, 5));
+        Assert.Equal(2, r.Left);
+        Assert.Equal(3, r.Top);
+        Assert.Equal(2 + 4, r.Right);
+        Assert.Equal(3 + 5, r.Bottom);
+
+        Assert.Equal(r.Left, r.X);
+        Assert.Equal(r.Top, r.Y);
+        Assert.Equal(new VecI(r.Left, r.Top), r.Pos);
+        Assert.Equal(new VecI(r.Right - r.Left, r.Bottom - r.Top), r.Size);
+
+        Assert.Equal(new VecI(r.Left, r.Bottom), r.BottomLeft);
+        Assert.Equal(new VecI(r.Right, r.Bottom), r.BottomRight);
+        Assert.Equal(new VecI(r.Left, r.Top), r.TopLeft);
+        Assert.Equal(new VecI(r.Right, r.Top), r.TopRight);
+
+        Assert.Equal(r.Size.X, r.Width);
+        Assert.Equal(r.Size.Y, r.Height);
+
+        Assert.False(r.IsZeroArea);
+    }
+
+    [Fact]
+    public void PropertySetters_SetPlainValues_UpdateSidesCorrectly()
+    {
+        RectI r = new();
+        // left, top, right bottom
+        (r.Left, r.Top, r.Right, r.Bottom) = (2, 3, 6, 8);
+        Assert.Equal((2, 3, 6, 8), (r.Left, r.Top, r.Right, r.Bottom));
+
+        // x, y
+        (r.X, r.Y) = (4, 5);
+        Assert.Equal((4, 5), (r.Left, r.Top));
+
+        // pos
+        var oldSize = new VecI(r.Right - r.Left, r.Bottom - r.Top);
+        r.Pos = new VecI(5, 6);
+        var newSize = new VecI(r.Right - r.Left, r.Bottom - r.Top);
+        Assert.Equal((5, 6), (r.Left, r.Top));
+        Assert.Equal(oldSize, newSize);
+
+        // size
+        var oldPos = r.Pos;
+        r.Size = new(18, 14);
+        var newPos = r.Pos;
+        Assert.Equal(oldPos, newPos);
+        Assert.Equal((18, 14), (r.Right - r.Left, r.Bottom - r.Top));
+
+        // corners
+        r.BottomLeft = new VecI(-13, -14);
+        Assert.Equal((-13, -14), (r.Left, r.Bottom));
+        r.BottomRight = new VecI(46, -12);
+        Assert.Equal((46, -12), (r.Right, r.Bottom));
+        r.TopLeft = new VecI(-46, 24);
+        Assert.Equal((-46, 24), (r.Left, r.Top));
+        r.TopRight = new VecI(100, 101);
+        Assert.Equal((100, 101), (r.Right, r.Top));
+
+        // width, height
+        var oldPos2 = r.Pos;
+        (r.Width, r.Height) = (1, 2);
+        var newPos2 = r.Pos;
+        Assert.Equal(oldPos2, newPos2);
+        Assert.Equal((1, 2), (r.Right - r.Left, r.Bottom - r.Top));
+    }
+
+    [Fact]
+    public void IsZeroArea_NormalRectangles_ReturnsFalse()
+    {
+        Assert.False(new RectI(new(5, 6), new VecI(1, 1)).IsZeroArea);
+        Assert.False(new RectI(new(-5, -6), new VecI(-1, -1)).IsZeroArea);
+    }
+
+    [Fact]
+    public void IsZeroArea_ZeroAreaRectangles_ReturnsFalse()
+    {
+        Assert.True(new RectI(new(5, 6), new VecI(0, 10)).IsZeroArea);
+        Assert.True(new RectI(new(-5, -6), new VecI(10, 0)).IsZeroArea);
+        Assert.True(new RectI(new(-5, -6), new VecI(0, 0)).IsZeroArea);
+    }
+
+    [Fact]
+    public void Standardize_StandardRects_RemainUnchanged()
+    {
+        var rect1 = new RectI(new(4, 5), new(1, 1));
+        Assert.Equal(rect1, rect1.Standardize());
+        var rect2 = new RectI(new(-4, -5), new(1, 1));
+        Assert.Equal(rect1, rect1.Standardize());
+    }
+
+    [Fact]
+    public void Standardize_NonStandardRects_BecomeStandard()
+    {
+        var rect1 = new RectI(4, 5, -1, -1);
+        Assert.Equal(new RectI(3, 4, 1, 1), rect1.Standardize());
+        var rect2 = new RectI(-4, -5, -1, 1);
+        Assert.Equal(new RectI(-5, -5, 1, 1), rect2.Standardize());
+        var rect3 = new RectI(-4, -5, 1, -1);
+        Assert.Equal(new RectI(-4, -6, 1, 1), rect3.Standardize());
+    }
+
+    [Fact]
+    public void ReflectX_BasicRect_ReturnsReflected()
+    {
+        var rect = new RectI(4, 5, 6, 7);
+        Assert.Equal(new RectI(-4, 5, 6, 7), rect.ReflectX(3));
+    }
+
+    [Fact]
+    public void ReflectY_BasicRect_ReturnsReflected()
+    {
+        var rect = new RectI(4, 5, 6, 7);
+        Assert.Equal(new RectI(4, -6, 6, 7), rect.ReflectY(3));
+    }
+
+    [Fact]
+    public void Inflate_BasicRect_ReturnsInflated()
+    {
+        var rect = new RectI(4, 5, 6, 7);
+        var infInt = rect.Inflate(2);
+        var infVec = rect.Inflate(2, 3);
+        Assert.Equal(new RectI(2, 3, 10, 11), infInt);
+        Assert.Equal(new RectI(2, 2, 10, 13), infVec);
+    }
+
+    [Fact]
+    public void AspectFit_FitPortraitIntoLandscape_FitsCorrectly()
+    {
+        RectI landscape = new(-1, 4, 5, 3);
+        RectI portrait = new(32, -41, 41, 41 * 3);
+        RectI fitted = landscape.AspectFit(portrait);
+        Assert.Equal(new RectI(1, 4, 1, 3), fitted);
+    }
+
+    [Fact]
+    public void AspectFit_FitLandscapeIntoPortrait_FitsCorrectly()
+    {
+        RectI portrait = new(1, -10, 7, 15);
+        RectI landscape = new(-314, 1592, 23 * 7, 23 * 3);
+        RectI fitted = portrait.AspectFit(landscape);
+        Assert.Equal(new RectI(1, -4, 7, 3), fitted);
+    }
+
+    [Fact]
+    public void ContainsInclusive_BasicRect_DeterminedCorrectly()
+    {
+        RectI rect = new(5, 4, 10, 11);
+        Assert.True(rect.ContainsInclusive(5, 4));
+        Assert.True(rect.ContainsInclusive(5 + 10, 4 + 11));
+        Assert.True(rect.ContainsInclusive(5, 4 + 2));
+        Assert.True(rect.ContainsInclusive(5 + 2, 4));
+        Assert.True(rect.ContainsInclusive(6, 5));
+
+        Assert.False(rect.ContainsInclusive(0, 0));
+        Assert.False(rect.ContainsInclusive(6, 80));
+        Assert.False(rect.ContainsInclusive(80, 6));
+        Assert.False(rect.ContainsInclusive(5 + 11, 4 + 10));
+    }
+
+    [Fact]
+    public void ContainsExclusive_BasicRect_DeterminedCorrectly()
+    {
+        RectI rect = new(5, 4, 10, 11);
+        Assert.False(rect.ContainsExclusive(5, 4));
+        Assert.False(rect.ContainsExclusive(5 + 10, 4 + 11));
+        Assert.False(rect.ContainsExclusive(5, 4 + 2));
+        Assert.False(rect.ContainsExclusive(5 + 2, 4));
+
+        Assert.True(rect.ContainsExclusive(6, 5));
+        Assert.True(rect.ContainsExclusive(5 + 9, 4 + 10));
+
+        Assert.False(rect.ContainsExclusive(0, 0));
+        Assert.False(rect.ContainsExclusive(6, 80));
+        Assert.False(rect.ContainsExclusive(80, 6));
+        Assert.False(rect.ContainsExclusive(5 + 11, 4 + 10));
+    }
+
+    [Fact]
+    public void ContainsPixel_BasicRect_DeterminedCorrectly()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Assert.True(rect.ContainsPixel(960, 540));
+        Assert.True(rect.ContainsPixel(1920 - 1, 1080 - 1));
+        Assert.True(rect.ContainsPixel(960 + 960 / 2, 540 + 540 / 2));
+
+        Assert.False(rect.ContainsPixel(960 - 1, 540 - 1));
+        Assert.False(rect.ContainsPixel(960 + 1920, 540 + 1080));
+        Assert.False(rect.ContainsPixel(960 + 960, 1080 + 540));
+    }
+
+    [Fact]
+    public void IntersectsWithInclusive_BasicRects_ReturnsTrue()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1920, 1080),
+            rect.Offset(-1920, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1080).Inflate(-1).Offset(0, -1),
+            rect.Inflate(-1),
+            rect.Inflate(1),
+        };
+        foreach (var testRect in rects)
+            Assert.True(rect.IntersectsWithInclusive(testRect));
+    }
+
+    [Fact]
+    public void IntersectsWithInclusive_BasicRects_ReturnsFalse()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1921, 1080),
+            rect.Offset(-1921, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1081).Inflate(-1).Offset(0, -1)
+        };
+        foreach (var testRect in rects)
+            Assert.False(rect.IntersectsWithInclusive(testRect));
+    }
+
+    [Fact]
+    public void IntersectsWithExclusive_BasicRects_ReturnsTrue()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1920 - 1, 1080 - 1),
+            rect.Offset(-1920, 0).Inflate(-1).Offset(2, 0),
+            rect.Offset(0, 1080).Inflate(-1).Offset(0, -2),
+            rect.Inflate(-1),
+            rect.Inflate(1),
+        };
+        foreach (var testRect in rects)
+            Assert.True(rect.IntersectsWithExclusive(testRect));
+    }
+
+    [Fact]
+    public void IntersectsWithExclusive_BasicRects_ReturnsFalse()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1920, 1080),
+            rect.Offset(-1920, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1080).Inflate(-1).Offset(0, -1),
+            rect.Offset(1921, 1080),
+            rect.Offset(-1921, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1081).Inflate(-1).Offset(0, -1)
+        };
+        foreach (var testRect in rects)
+            Assert.False(rect.IntersectsWithExclusive(testRect));
+    }
+
+    [Fact]
+    public void Intersect_IntersectingRectangles_ReturnsIntersection()
+    {
+        Assert.Equal(
+            new RectI(400, 300, 400, 300),
+            new RectI(400, 300, 800, 600).Intersect(new RectI(0, 0, 800, 600)));
+    }
+
+    [Fact]
+    public void Intersect_NonIntersectingRectangles_ReturnsEmpty()
+    {
+        Assert.Equal(
+            RectI.Empty,
+            new RectI(-123, -456, 78, 10).Intersect(new RectI(123, 456, 789, 101)));
+    }
+
+    [Fact]
+    public void Union_BasicRectangles_ReturnsUnion()
+    {
+        var rect1 = new RectI(4, 5, 1, 1);
+        var rect2 = new RectI(-4, -5, 1, 1);
+        Assert.Equal(new RectI(-4, -5, 9, 11), rect1.Union(rect2));
+        Assert.Equal(new RectI(-4, -5, 9, 11), rect2.Union(rect1));
+    }
+}

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

@@ -1,6 +1,4 @@
-using ChunkyImageLib.DataHolders;
-
-namespace PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
 
 public record class LayerImageChunks_ChangeInfo : IChangeInfo
 {

+ 49 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/BasicPen_UpdateableChange.cs

@@ -0,0 +1,49 @@
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+internal class BasicPen_UpdateableChange : UpdateableChange
+{
+    private readonly Guid memberGuid;
+    private readonly int strokeWidth;
+    private readonly SKColor color;
+    private readonly bool drawOnMask;
+    private readonly List<VecI> points = new();
+
+    [GenerateUpdateableChangeActions]
+    public BasicPen_UpdateableChange(Guid memberGuid, int strokeWidth, SKColor color, VecI point, bool drawOnMask)
+    {
+        this.memberGuid = memberGuid;
+        this.strokeWidth = strokeWidth;
+        this.color = color;
+        this.drawOnMask = drawOnMask;
+        points.Add(point);
+    }
+
+    [UpdateChangeMethod]
+    public void Update(VecI point)
+    {
+        points.Add(point);
+    }
+
+    public override OneOf<Success, Error> InitializeAndValidate(Document target)
+    {
+        if (!DrawingChangeHelper.IsValidForDrawing(target, memberGuid, drawOnMask))
+            return new Error();
+        return new Success();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
+    {
+        throw new NotImplementedException();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        throw new NotImplementedException();
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        throw new NotImplementedException();
+    }
+}

+ 3 - 5
src/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelectedArea_Change.cs

@@ -30,13 +30,11 @@ internal class ClearSelectedArea_Change : Change
 
         var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
 
-        SKRect bounds = target.Selection.SelectionPath.Bounds;
-        bounds.Intersect(SKRect.Create(0, 0, target.Size.X, target.Size.Y));
-        VecI pixelTopLeft = (VecI)((VecD)bounds.Location).Floor();
-        VecI pixelSize = (VecI)((VecD)bounds.Location + (VecD)bounds.Size - pixelTopLeft).Ceiling();
+        RectD bounds = target.Selection.SelectionPath.Bounds;
+        RectI intBounds = (RectI)bounds.Intersect(SKRect.Create(0, 0, target.Size.X, target.Size.Y)).RoundOutwards();
 
         image.SetClippingPath(target.Selection.SelectionPath);
-        image.EnqueueClearRegion(pixelTopLeft, pixelSize);
+        image.EnqueueClearRegion(intBounds);
         var affChunks = image.FindAffectedChunks();
         savedChunks = new(image, affChunks);
         image.CommitChanges();

+ 13 - 15
src/PixiEditor.ChangeableDocument/Changes/Selection/SelectRectangle_UpdateableChange.cs

@@ -5,15 +5,14 @@ namespace PixiEditor.ChangeableDocument.Changes.Selection;
 
 internal class SelectRectangle_UpdateableChange : UpdateableChange
 {
-    private VecI pos;
-    private VecI size;
     private SKPath? originalPath;
+    private RectI rect;
     private readonly SelectionMode mode;
 
     [GenerateUpdateableChangeActions]
-    public SelectRectangle_UpdateableChange(VecI pos, VecI size, SelectionMode mode)
+    public SelectRectangle_UpdateableChange(RectI rect, SelectionMode mode)
     {
-        Update(pos, size);
+        this.rect = rect;
         this.mode = mode;
     }
     public override OneOf<Success, Error> InitializeAndValidate(Document target)
@@ -23,26 +22,25 @@ internal class SelectRectangle_UpdateableChange : UpdateableChange
     }
 
     [UpdateChangeMethod]
-    public void Update(VecI pos, VecI size)
+    public void Update(RectI rect)
     {
-        this.pos = pos;
-        this.size = size;
+        this.rect = rect;
     }
 
     private Selection_ChangeInfo CommonApply(Document target)
     {
-        using var rect = new SKPath() { FillType = SKPathFillType.EvenOdd };
-        rect.MoveTo(pos);
-        rect.LineTo(pos.X + size.X, pos.Y);
-        rect.LineTo(pos + size);
-        rect.LineTo(pos.X, pos.Y + size.Y);
-        rect.LineTo(pos);
+        using var rectPath = new SKPath() { FillType = SKPathFillType.EvenOdd };
+        rectPath.MoveTo(rect.TopLeft);
+        rectPath.LineTo(rect.TopRight);
+        rectPath.LineTo(rect.BottomRight);
+        rectPath.LineTo(rect.BottomLeft);
+        rectPath.Close();
 
         var toDispose = target.Selection.SelectionPath;
         if (mode == SelectionMode.New)
-            target.Selection.SelectionPath = new(rect);
+            target.Selection.SelectionPath = new(rectPath);
         else
-            target.Selection.SelectionPath = originalPath!.Op(rect, mode.ToSKPathOp());
+            target.Selection.SelectionPath = originalPath!.Op(rectPath, mode.ToSKPathOp());
         toDispose.Dispose();
 
         return new Selection_ChangeInfo();

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changes/Selection/TransformSelectionPath_UpdateableChange.cs

@@ -26,7 +26,7 @@ internal class TransformSelectionPath_UpdateableChange : UpdateableChange
             return new Error();
         originalPath = new(target.Selection.SelectionPath);
         var bounds = originalPath.TightBounds;
-        originalCorners = new(bounds.Location, bounds.Size);
+        originalCorners = new(bounds);
         return new Success();
     }
 

+ 3 - 4
src/PixiEditorPrototype/Models/ActionAccumulator.cs

@@ -1,13 +1,13 @@
 using System.Collections.Generic;
 using System.Linq;
 using System.Windows;
+using ChunkyImageLib.DataHolders;
 using PixiEditor.ChangeableDocument.Actions;
 using PixiEditor.ChangeableDocument.Actions.Undo;
 using PixiEditor.ChangeableDocument.ChangeInfos;
 using PixiEditorPrototype.Models.Rendering;
 using PixiEditorPrototype.Models.Rendering.RenderInfos;
 using PixiEditorPrototype.ViewModels;
-using SkiaSharp;
 
 namespace PixiEditorPrototype.Models;
 
@@ -143,10 +143,9 @@ internal class ActionAccumulator
                 case DirtyRect_RenderInfo info:
                     {
                         var bitmap = document.Bitmaps[info.Resolution];
-                        SKRectI finalRect = SKRectI.Create(0, 0, bitmap.PixelWidth, bitmap.PixelHeight);
+                        RectI finalRect = new RectI(VecI.Zero, new(bitmap.PixelWidth, bitmap.PixelHeight));
 
-                        SKRectI dirtyRect = SKRectI.Create(info.Pos, info.Size);
-                        dirtyRect.Intersect(finalRect);
+                        RectI dirtyRect = new RectI(info.Pos, info.Size).Intersect(finalRect);
                         bitmap.AddDirtyRect(new(dirtyRect.Left, dirtyRect.Top, dirtyRect.Width, dirtyRect.Height));
                     }
                     break;

+ 11 - 12
src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs

@@ -149,17 +149,16 @@ internal class DocumentViewModel : INotifyPropertyChanged
 
         // find area location and size
         using SKPath path = SelectionPath;
-        var bounds = path.TightBounds;
-        bounds.Intersect(SKRect.Create(0, 0, Width, Height));
-        VecI pixelTopLeft = (VecI)((VecD)bounds.Location).Floor();
-        VecI pixelSize = (VecI)((VecD)bounds.Location + (VecD)bounds.Size - pixelTopLeft).Ceiling();
+        var bounds = (RectD)path.TightBounds;
+        bounds = bounds.Intersect(new RectD(VecD.Zero, new(Width, Height)));
+        var intBounds = (RectI)bounds.RoundOutwards();
 
         // extract surface to be transformed
-        path.Transform(SKMatrix.CreateTranslation(-pixelTopLeft.X, -pixelTopLeft.Y));
-        Surface surface = new(pixelSize);
+        path.Transform(SKMatrix.CreateTranslation(-intBounds.X, -intBounds.Y));
+        Surface surface = new(intBounds.Size);
         surface.SkiaSurface.Canvas.Save();
         surface.SkiaSurface.Canvas.ClipPath(path);
-        layerImage.DrawMostUpToDateRegionOn(SKRectI.Create(pixelTopLeft, pixelSize), ChunkResolution.Full, surface.SkiaSurface, VecI.Zero);
+        layerImage.DrawMostUpToDateRegionOn(intBounds, ChunkResolution.Full, surface.SkiaSurface, VecI.Zero);
         surface.SkiaSurface.Canvas.Restore();
 
         // clear area
@@ -172,7 +171,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
         // initiate transform using paste image logic
         pastedImage = surface;
         pastingImage = true;
-        ShapeCorners corners = new(pixelTopLeft, pixelSize);
+        ShapeCorners corners = new(intBounds);
         Helpers.ActionAccumulator.AddActions(new PasteImage_Action(pastedImage, corners, layer.GuidValue, false));
         TransformViewModel.ShowFreeTransform(corners);
     }
@@ -300,7 +299,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
         updateableChangeActive = true;
         transformingSelectionPath = true;
         var bounds = path.TightBounds;
-        initialSelectionCorners = new ShapeCorners(bounds.Location, bounds.Size);
+        initialSelectionCorners = new ShapeCorners(bounds);
         TransformViewModel.ShowShapeTransform(initialSelectionCorners);
         Helpers.ActionAccumulator.AddActions(new TransformSelectionPath_Action(initialSelectionCorners));
     }
@@ -349,11 +348,11 @@ internal class DocumentViewModel : INotifyPropertyChanged
         updateableChangeActive = false;
     }
 
-    public void StartUpdateRectSelection(VecI pos, VecI size, SelectionMode mode)
+    public void StartUpdateRectSelection(RectI rect, SelectionMode mode)
     {
         selectingRect = true;
         updateableChangeActive = true;
-        Helpers.ActionAccumulator.AddActions(new SelectRectangle_Action(pos, size, mode));
+        Helpers.ActionAccumulator.AddActions(new SelectRectangle_Action(rect, mode));
     }
 
     public void EndRectSelection()
@@ -417,7 +416,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
 
         pastedImage = Surface.Load(dialog.FileName);
         pastingImage = true;
-        ShapeCorners corners = new(new(), pastedImage.Size);
+        ShapeCorners corners = new ShapeCorners(new RectD(VecD.Zero, pastedImage.Size));
         Helpers.ActionAccumulator.AddActions(new PasteImage_Action(pastedImage, corners, SelectedStructureMember.GuidValue, false));
         TransformViewModel.ShowFreeTransform(corners);
     }

+ 1 - 2
src/PixiEditorPrototype/ViewModels/ViewModelMain.cs

@@ -166,8 +166,7 @@ internal class ViewModelMain : INotifyPropertyChanged
         else if (toolOnMouseDown == Tool.Select)
         {
             ActiveDocument!.StartUpdateRectSelection(
-                (VecI)mouseDownCanvasPos,
-                (VecI)canvasPos - (VecI)mouseDownCanvasPos,
+                new RectI((VecI)mouseDownCanvasPos, (VecI)canvasPos - (VecI)mouseDownCanvasPos),
                 selectionMode);
         }
         else if (toolOnMouseDown == Tool.ShiftLayer)