Browse Source

Polish rectangle drawing, add ellipse drawing

Equbuxu 3 years ago
parent
commit
a7c83fc12f

+ 10 - 0
src/ChunkyImageLib/ChunkyImage.cs

@@ -332,6 +332,16 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
         }
     }
 
+    public void EnqueueDrawEllipse(RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            EllipseOperation operation = new(location, strokeColor, fillColor, strokeWidth);
+            EnqueueOperation(operation);
+        }
+    }
+
     /// <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). 

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

@@ -78,6 +78,7 @@ public struct RectD : IEquatable<RectD>
             bottom = top + value.Y;
         }
     }
+    public VecD Center { get => new VecD((left + right) / 2.0, (top + bottom) / 2.0); }
     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;

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

@@ -73,6 +73,7 @@ public struct RectI : IEquatable<RectI>
             bottom = top + value.Y;
         }
     }
+    public VecD Center { get => new VecD((left + right) / 2.0, (top + bottom) / 2.0); }
     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;
@@ -124,6 +125,11 @@ public struct RectI : IEquatable<RectI>
         };
     }
 
+    public static RectI FromTwoPixels(VecI pixel, VecI oppositePixel)
+    {
+        return new RectI(pixel, new(1, 1)).Union(new RectI(oppositePixel, new(1, 1)));
+    }
+
     /// <summary>
     /// Converts rectangle with negative dimensions into a normal one
     /// </summary>

+ 175 - 0
src/ChunkyImageLib/Operations/EllipseHelper.cs

@@ -0,0 +1,175 @@
+using ChunkyImageLib.DataHolders;
+
+namespace ChunkyImageLib.Operations;
+public class EllipseHelper
+{
+    public static (List<VecI> lines, RectI rect) SplitEllipseIntoRegions(List<VecI> ellipse, RectI ellipseBounds)
+    {
+        if (ellipse.Count == 0)
+            return (new(), RectI.Empty);
+        List<VecI> lines = new();
+
+        VecD ellipseCenter = ellipseBounds.Center;
+        VecD inscribedRectSize = ellipseBounds.Size * Math.Sqrt(2) / 2;
+        inscribedRectSize.X -= 2;
+        inscribedRectSize.Y -= 2;
+        RectI inscribedRect = (RectI)RectD.FromCenterAndSize(ellipseCenter, inscribedRectSize).RoundInwards();
+        if (inscribedRect.IsZeroOrNegativeArea)
+            inscribedRect = RectI.Empty;
+
+        bool[] added = new bool[ellipseBounds.Height];
+        for (var i = 0; i < ellipse.Count; i++)
+        {
+            var point = ellipse[i];
+            if (!added[point.Y - ellipseBounds.Top] && i > 0 && ellipse[i - 1].Y == point.Y && point.X - ellipse[i - 1].X > 1)
+            {
+                int fromX = ellipse[i - 1].X + 1;
+                int toX = point.X;
+                int y = ellipse[i - 1].Y;
+                added[point.Y - ellipseBounds.Top] = true;
+                if (y >= inscribedRect.Top && y < inscribedRect.Bottom)
+                {
+                    lines.Add(new VecI(fromX, y));
+                    lines.Add(new VecI(inscribedRect.Left, y));
+                    lines.Add(new VecI(inscribedRect.Right, y));
+                    lines.Add(new VecI(toX, y));
+                }
+                else
+                {
+                    lines.Add(new VecI(fromX, y));
+                    lines.Add(new VecI(toX, y));
+                }
+            }
+        }
+        return (lines, inscribedRect);
+    }
+    public static List<VecI> GenerateEllipseFromRect(RectI rect)
+    {
+        if (rect.IsZeroOrNegativeArea)
+            return new();
+        float radiusX = (rect.Width - 1) / 2.0f;
+        float radiusY = (rect.Height - 1) / 2.0f;
+        return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y);
+    }
+
+    /// <summary>
+    /// Draws an ellipse using it's center and radii
+    ///
+    /// Here is a usage example:
+    /// Let's say you want an ellipse that's 3 pixels wide and 3 pixels tall located in the top right corner of the canvas
+    /// It's center is at (1.5; 1.5). That's in the middle of a pixel
+    /// The radii are both equal to 1. Notice that it's 1 and not 1.5, since we want the ellipse to land in the middle of the pixel, not outside of it.
+    /// See desmos (note the inverted y axis): https://www.desmos.com/calculator/tq9uqg0hcq
+    ///
+    /// Another example:
+    /// 4x4 ellipse in the top right corner of the canvas
+    /// Center is at (2; 2). It's a place where 4 pixels meet
+    /// Both radii are 1.5. Making them 2 would make the ellipse touch the edges of pixels, whereas we want it to stay in the middle
+    /// </summary>
+    public static List<VecI> GenerateMidpointEllipse(
+        double halfWidth,
+        double halfHeight,
+        double centerX,
+        double centerY,
+        List<VecI>? listToFill = null)
+    {
+        listToFill ??= new List<VecI>();
+        if (halfWidth < 1 || halfHeight < 1)
+        {
+            AddFallbackRectangle(halfWidth, halfHeight, centerX, centerY, listToFill);
+            return listToFill;
+        }
+
+        // ellipse formula: halfHeight^2 * x^2 + halfWidth^2 * y^2 - halfHeight^2 * halfWidth^2 = 0
+
+        // Make sure we are always at the center of a pixel
+        double currentX = Math.Ceiling(centerX - 0.5) + 0.5;
+        double currentY = centerY + halfHeight;
+
+
+        double currentSlope;
+
+        // from PI/2 to PI/4
+        do
+        {
+            AddRegionPoints(listToFill, currentX, centerX, currentY, centerY);
+
+            // calculate next pixel coords
+            currentX++;
+
+            if ((Math.Pow(halfHeight, 2) * Math.Pow(currentX - centerX, 2)) +
+                (Math.Pow(halfWidth, 2) * Math.Pow(currentY - centerY - 0.5, 2)) -
+                (Math.Pow(halfWidth, 2) * Math.Pow(halfHeight, 2)) >= 0)
+            {
+                currentY--;
+            }
+
+            // calculate how far we've advanced
+            double derivativeX = 2 * Math.Pow(halfHeight, 2) * (currentX - centerX);
+            double derivativeY = 2 * Math.Pow(halfWidth, 2) * (currentY - centerY);
+            currentSlope = -(derivativeX / derivativeY);
+        }
+        while (currentSlope > -1 && currentY - centerY > 0.5);
+
+        // from PI/4 to 0
+        while (currentY - centerY >= 0)
+        {
+            AddRegionPoints(listToFill, currentX, centerX, currentY, centerY);
+
+            currentY--;
+            if ((Math.Pow(halfHeight, 2) * Math.Pow(currentX - centerX + 0.5, 2)) +
+                (Math.Pow(halfWidth, 2) * Math.Pow(currentY - centerY, 2)) -
+                (Math.Pow(halfWidth, 2) * Math.Pow(halfHeight, 2)) < 0)
+            {
+                currentX++;
+            }
+        }
+
+        return listToFill;
+    }
+
+    private static void AddFallbackRectangle(double halfWidth, double halfHeight, double centerX, double centerY, List<VecI> coordinates)
+    {
+        int left = (int)Math.Floor(centerX - halfWidth);
+        int top = (int)Math.Floor(centerY - halfHeight);
+        int right = (int)Math.Floor(centerX + halfWidth);
+        int bottom = (int)Math.Floor(centerY + halfHeight);
+
+        for (int x = left; x <= right; x++)
+        {
+            coordinates.Add(new VecI(x, top));
+            if (top != bottom)
+                coordinates.Add(new VecI(x, bottom));
+        }
+
+        for (int y = top + 1; y < bottom; y++)
+        {
+            coordinates.Add(new VecI(left, y));
+            if (left != right)
+                coordinates.Add(new VecI(right, y));
+        }
+    }
+
+    private static void AddRegionPoints(List<VecI> coordinates, double x, double xc, double y, double yc)
+    {
+        int xFloor = (int)Math.Floor(x);
+        int yFloor = (int)Math.Floor(y);
+        int xFloorInv = (int)Math.Floor(-x + 2 * xc);
+        int yFloorInv = (int)Math.Floor(-y + 2 * yc);
+
+        //top and bottom or left and right
+        if (xFloor == xFloorInv || yFloor == yFloorInv)
+        {
+            coordinates.Add(new VecI(xFloorInv, yFloorInv));
+            coordinates.Add(new VecI(xFloor, yFloor));
+        }
+        //part of the arc
+        else
+        {
+            coordinates.Add(new VecI(xFloorInv, yFloor));
+            coordinates.Add(new VecI(xFloor, yFloor));
+            coordinates.Add(new VecI(xFloorInv, yFloorInv));
+            coordinates.Add(new VecI(xFloor, yFloorInv));
+        }
+    }
+}

+ 117 - 0
src/ChunkyImageLib/Operations/EllipseOperation.cs

@@ -0,0 +1,117 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class EllipseOperation : IDrawOperation
+{
+    public bool IgnoreEmptyChunks => false;
+
+    public readonly RectI location;
+    private readonly SKColor strokeColor;
+    private readonly SKColor fillColor;
+    private readonly int strokeWidth;
+    private bool init = false;
+    private SKPaint paint = new();
+    private SKPath? outerPath;
+    private SKPath? innerPath;
+    private SKPoint[]? ellipse;
+    private SKPoint[]? ellipseFill;
+    private RectI? ellipseFillRect;
+
+    public EllipseOperation(RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth)
+    {
+        this.location = location;
+        this.strokeColor = strokeColor;
+        this.fillColor = fillColor;
+        this.strokeWidth = strokeWidth;
+    }
+
+    private void Init()
+    {
+        init = true;
+        if (strokeWidth == 1)
+        {
+            var ellipseList = EllipseHelper.GenerateEllipseFromRect(location);
+            ellipse = ellipseList.Select(a => (SKPoint)a).ToArray();
+            if (fillColor.Alpha > 0)
+            {
+                (var fill, ellipseFillRect) = EllipseHelper.SplitEllipseIntoRegions(ellipseList, location);
+                ellipseFill = fill.Select(a => (SKPoint)a).ToArray();
+            }
+        }
+        else
+        {
+            outerPath = new SKPath();
+            outerPath.ArcTo(location, 0, 359, true);
+            innerPath = new SKPath();
+            innerPath.ArcTo(location.Inflate(-strokeWidth), 0, 359, true);
+        }
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        if (!init)
+            Init();
+        var surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+
+        if (strokeWidth == 1)
+        {
+            if (fillColor.Alpha > 0)
+            {
+                paint.Color = fillColor;
+                surf.Canvas.DrawPoints(SKPointMode.Lines, ellipseFill, paint);
+                surf.Canvas.DrawRect((SKRect)ellipseFillRect!, paint);
+            }
+            paint.Color = strokeColor;
+            surf.Canvas.DrawPoints(SKPointMode.Points, ellipse, paint);
+        }
+        else
+        {
+            if (fillColor.Alpha > 0)
+            {
+                surf.Canvas.Save();
+                surf.Canvas.ClipPath(innerPath);
+                surf.Canvas.DrawColor(fillColor, SKBlendMode.SrcOver);
+                surf.Canvas.Restore();
+            }
+            surf.Canvas.Save();
+            surf.Canvas.ClipPath(outerPath);
+            surf.Canvas.ClipPath(innerPath, SKClipOperation.Difference);
+            surf.Canvas.DrawColor(strokeColor, SKBlendMode.SrcOver);
+            surf.Canvas.Restore();
+        }
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        var chunks = OperationHelper.FindChunksTouchingEllipse
+            (location.Center, location.Width / 2.0, location.Height / 2.0, ChunkyImage.FullChunkSize);
+        if (fillColor.Alpha == 0)
+        {
+            chunks.ExceptWith(OperationHelper.FindChunksFullyInsideEllipse
+                (location.Center, location.Width / 2.0 - strokeWidth * 2, location.Height / 2.0 - strokeWidth * 2, ChunkyImage.FullChunkSize));
+        }
+        return chunks;
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        RectI newLocation = location;
+        if (verAxisX is not null)
+            newLocation = newLocation.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newLocation = newLocation.ReflectY((int)horAxisY);
+        return new EllipseOperation(newLocation, strokeColor, fillColor, strokeWidth);
+    }
+
+    public void Dispose()
+    {
+        paint?.Dispose();
+        outerPath?.Dispose();
+        innerPath?.Dispose();
+    }
+}

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

@@ -100,6 +100,49 @@ public static class OperationHelper
         return new SKMatrix(scaleX, skewX, transX, skewY, scaleY, transY, persp0, persp1, persp2);
     }
 
+    public static (ShapeCorners, ShapeCorners) CreateStretchedHexagon(VecD centerPos, double hexagonSide, double stretchX)
+    {
+        ShapeCorners left = new ShapeCorners()
+        {
+            TopLeft = centerPos + VecD.FromAngleAndLength(Math.PI * 7 / 6, hexagonSide),
+            TopRight = new VecD(centerPos.X, centerPos.Y - hexagonSide),
+            BottomRight = new VecD(centerPos.X, centerPos.Y + hexagonSide),
+            BottomLeft = centerPos + VecD.FromAngleAndLength(Math.PI * 5 / 6, hexagonSide),
+        };
+        left.TopLeft = new VecD((left.TopLeft.X - centerPos.X) * stretchX + centerPos.X, left.TopLeft.Y);
+        left.BottomLeft = new VecD((left.BottomLeft.X - centerPos.X) * stretchX + centerPos.X, left.BottomLeft.Y);
+        ShapeCorners right = new ShapeCorners()
+        {
+            TopRight = centerPos + VecD.FromAngleAndLength(Math.PI * 11 / 6, hexagonSide),
+            TopLeft = new VecD(centerPos.X, centerPos.Y - hexagonSide),
+            BottomLeft = new VecD(centerPos.X, centerPos.Y + hexagonSide),
+            BottomRight = centerPos + VecD.FromAngleAndLength(Math.PI * 1 / 6, hexagonSide),
+        };
+        right.TopRight = new VecD((right.TopRight.X - centerPos.X) * stretchX + centerPos.X, right.TopRight.Y);
+        right.BottomRight = new VecD((right.BottomRight.X - centerPos.X) * stretchX + centerPos.X, right.BottomRight.Y);
+        return (left, right);
+    }
+
+    public static HashSet<VecI> FindChunksTouchingEllipse(VecD pos, double radiusX, double radiusY, int chunkSize)
+    {
+        const double sqrt3 = 1.73205080757;
+        double hexagonSide = 2.0 / sqrt3 * radiusY;
+        double stretchX = radiusX / radiusY;
+        var (left, right) = CreateStretchedHexagon(pos, hexagonSide, stretchX);
+        var chunks = FindChunksTouchingQuadrilateral(left, chunkSize);
+        chunks.UnionWith(FindChunksTouchingQuadrilateral(right, chunkSize));
+        return chunks;
+    }
+
+    public static HashSet<VecI> FindChunksFullyInsideEllipse(VecD pos, double radiusX, double radiusY, int chunkSize)
+    {
+        double stretchX = radiusX / radiusY;
+        var (left, right) = CreateStretchedHexagon(pos, radiusY, stretchX);
+        var chunks = FindChunksFullyInsideQuadrilateral(left, chunkSize);
+        chunks.UnionWith(FindChunksFullyInsideQuadrilateral(right, chunkSize));
+        return chunks;
+    }
+
     public static HashSet<VecI> FindChunksTouchingQuadrilateral(ShapeCorners corners, int chunkSize)
     {
         if (corners.IsRect && Math.Abs(corners.RectRotation) < 0.0001)

+ 21 - 27
src/ChunkyImageLib/Operations/RectangleOperation.cs

@@ -17,42 +17,36 @@ internal class RectangleOperation : IDrawOperation
     public void DrawOnChunk(Chunk chunk, VecI chunkPos)
     {
         var skiaSurf = chunk.Surface.SkiaSurface;
-        skiaSurf.Canvas.Save();
-
-        var convertedPos = OperationHelper.ConvertForResolution(-Data.Size.Abs() / 2, chunk.Resolution);
-        var convertedCenter = OperationHelper.ConvertForResolution(Data.Center, chunk.Resolution) - chunkPos.Multiply(chunk.PixelSize);
-
-        var convertedSize = OperationHelper.ConvertForResolution(Data.Size.Abs(), chunk.Resolution);
-        int convertedStroke = (int)Math.Round(chunk.Resolution.Multiplier() * Data.StrokeWidth);
 
-        var rect = (SKRect)new RectD(convertedPos, convertedSize);
+        var surf = chunk.Surface.SkiaSurface;
 
-        skiaSurf.Canvas.Translate((SKPoint)convertedCenter);
-        skiaSurf.Canvas.RotateRadians((float)Data.Angle);
+        var rect = RectD.FromCenterAndSize(Data.Center, Data.Size);
+        var innerRect = rect.Inflate(-Data.StrokeWidth);
+        if (innerRect.IsZeroOrNegativeArea)
+            innerRect = RectD.Empty;
 
-        // use a clipping rectangle with 2x stroke width to make sure stroke doesn't stick outside rect bounds
-        skiaSurf.Canvas.ClipRect(rect);
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        skiaSurf.Canvas.RotateRadians((float)Data.Angle, (float)rect.Center.X, (float)rect.Center.Y);
 
         // draw fill
-        using SKPaint paint = new()
-        {
-            Color = Data.FillColor,
-            Style = SKPaintStyle.Fill,
-            BlendMode = Data.BlendMode,
-        };
-
         if (Data.FillColor.Alpha > 0)
-            skiaSurf.Canvas.DrawRect(rect, paint);
+        {
+            skiaSurf.Canvas.Save();
+            skiaSurf.Canvas.ClipRect((SKRect)innerRect);
+            skiaSurf.Canvas.DrawColor(Data.FillColor, Data.BlendMode);
+            skiaSurf.Canvas.Restore();
+        }
 
         // draw stroke
-        paint.Color = Data.StrokeColor;
-        paint.Style = SKPaintStyle.Stroke;
-        paint.StrokeWidth = convertedStroke * 2;
-
-        skiaSurf.Canvas.DrawRect(rect, paint);
-
-        // get rid of the clipping rectangle
+        skiaSurf.Canvas.Save();
+        skiaSurf.Canvas.ClipRect((SKRect)rect);
+        skiaSurf.Canvas.ClipRect((SKRect)innerRect, SKClipOperation.Difference);
+        skiaSurf.Canvas.DrawColor(Data.StrokeColor, Data.BlendMode);
         skiaSurf.Canvas.Restore();
+
+        surf.Canvas.Restore();
     }
 
     public HashSet<VecI> FindAffectedChunks()

+ 65 - 0
src/ChunkyImageLibTest/OperationHelperTests.cs

@@ -1,3 +1,6 @@
+using System;
+using System.Collections.Generic;
+using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.Operations;
 using Xunit;
@@ -34,4 +37,66 @@ public class OperationHelperTests
         Assert.Equal(expX, act.X);
         Assert.Equal(expY, act.Y);
     }
+
+    [Fact]
+    public void CreateStretchedHexagon_NonStretched_ReturnsCorrectQuads()
+    {
+        var (left, right) = OperationHelper.CreateStretchedHexagon((-3, 5), 10 / Math.Sqrt(3), 1);
+        Assert.Equal(right.TopLeft.X, left.TopRight.X, 6);
+        Assert.Equal(right.BottomLeft.X, left.BottomRight.X, 6);
+
+        Assert.Equal(-3, right.BottomLeft.X, 2);
+        Assert.Equal(10.774, right.BottomLeft.Y, 2);
+
+        Assert.Equal(2, right.BottomRight.X, 2);
+        Assert.Equal(7.887, right.BottomRight.Y, 2);
+
+        Assert.Equal(2, right.TopRight.X, 2);
+        Assert.Equal(2.113, right.TopRight.Y, 2);
+
+        Assert.Equal(-3, right.TopLeft.X, 2);
+        Assert.Equal(-0.774, right.TopLeft.Y, 2);
+
+        Assert.Equal(-8, left.TopLeft.X, 2);
+        Assert.Equal(2.113, left.TopLeft.Y, 2);
+
+        Assert.Equal(-8, left.BottomLeft.X, 2);
+        Assert.Equal(7.887, left.BottomLeft.Y, 2);
+    }
+
+    [Fact]
+    public void CreateStretchedHexagon_Stretched_ReturnsCorrectQuads()
+    {
+        const double x = -7;
+        const double stretch = 4;
+        var (left, right) = OperationHelper.CreateStretchedHexagon((x, 1), 12 / Math.Sqrt(3), stretch);
+        Assert.Equal(right.TopLeft.X, left.TopRight.X, 6);
+        Assert.Equal(right.BottomLeft.X, left.BottomRight.X, 6);
+
+        Assert.Equal(-7, right.BottomLeft.X, 2);
+        Assert.Equal(7.928, right.BottomLeft.Y, 2);
+
+        Assert.Equal((-1 - x) * stretch + x, right.BottomRight.X, 2);
+        Assert.Equal(4.464, right.BottomRight.Y, 2);
+
+        Assert.Equal((-1 - x) * stretch + x, right.TopRight.X, 2);
+        Assert.Equal(-2.464, right.TopRight.Y, 2);
+
+        Assert.Equal(-7, right.TopLeft.X, 2);
+        Assert.Equal(-5.928, right.TopLeft.Y, 2);
+
+        Assert.Equal((-13 - x) * stretch + x, left.TopLeft.X, 2);
+        Assert.Equal(-2.464, left.TopLeft.Y, 2);
+
+        Assert.Equal((-13 - x) * stretch + x, left.BottomLeft.X, 2);
+        Assert.Equal(4.464, left.BottomLeft.Y, 2);
+    }
+
+    [Fact]
+    public void FindChunksTouchingEllipse_EllipseSpanningTwoChunks_FindsChunks()
+    {
+        int cS = ChunkyImage.FullChunkSize;
+        var chunks = OperationHelper.FindChunksTouchingEllipse((cS, cS / 2), cS / 2, cS / 4, cS);
+        Assert.Equal(new HashSet<VecI>() { (0, 0), (1, 0) }, chunks);
+    }
 }

+ 86 - 0
src/PixiEditor.ChangeableDocument/Changes/Drawing/DrawEllipse_UpdateableChange.cs

@@ -0,0 +1,86 @@
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.Changes.Drawing;
+internal class DrawEllipse_UpdateableChange : UpdateableChange
+{
+    private readonly Guid memberGuid;
+    private RectI location;
+    private readonly SKColor strokeColor;
+    private readonly SKColor fillColor;
+    private readonly int strokeWidth;
+    private readonly bool drawOnMask;
+
+    private CommittedChunkStorage? storedChunks;
+
+    [GenerateUpdateableChangeActions]
+    public DrawEllipse_UpdateableChange(Guid memberGuid, RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth, bool drawOnMask)
+    {
+        this.memberGuid = memberGuid;
+        this.location = location;
+        this.strokeColor = strokeColor;
+        this.fillColor = fillColor;
+        this.strokeWidth = strokeWidth;
+        this.drawOnMask = drawOnMask;
+    }
+
+    [UpdateChangeMethod]
+    public void Update(RectI location)
+    {
+        this.location = location;
+    }
+
+    public override OneOf<Success, Error> InitializeAndValidate(Document target)
+    {
+        if (!DrawingChangeHelper.IsValidForDrawing(target, memberGuid, drawOnMask))
+            return new Error();
+        return new Success();
+    }
+
+    private HashSet<VecI> UpdateEllipse(Document target, ChunkyImage targetImage)
+    {
+        var oldAffectedChunks = targetImage.FindAffectedChunks();
+
+        targetImage.CancelChanges();
+        DrawingChangeHelper.ApplyClipsSymmetriesEtc(target, targetImage, memberGuid, drawOnMask);
+        targetImage.EnqueueDrawEllipse(location, strokeColor, fillColor, strokeWidth);
+
+        var affectedChunks = targetImage.FindAffectedChunks();
+        affectedChunks.UnionWith(oldAffectedChunks);
+
+        return affectedChunks;
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Apply(Document target, out bool ignoreInUndo)
+    {
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+        var chunks = UpdateEllipse(target, image);
+        storedChunks = new CommittedChunkStorage(image, image.FindAffectedChunks());
+        image.CommitChanges();
+        ignoreInUndo = false;
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, chunks, drawOnMask);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> ApplyTemporarily(Document target)
+    {
+        var image = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+        var chunks = UpdateEllipse(target, image);
+        return DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, chunks, drawOnMask);
+    }
+
+    public override OneOf<None, IChangeInfo, List<IChangeInfo>> Revert(Document target)
+    {
+        ChunkyImage targetImage = DrawingChangeHelper.GetTargetImageOrThrow(target, memberGuid, drawOnMask);
+        storedChunks!.ApplyChunksToImage(targetImage);
+        storedChunks.Dispose();
+        storedChunks = null;
+
+        var changes = DrawingChangeHelper.CreateChunkChangeInfo(memberGuid, targetImage.FindAffectedChunks(), drawOnMask);
+        targetImage.CommitChanges();
+        return changes;
+    }
+
+    public override void Dispose()
+    {
+        storedChunks?.Dispose();
+    }
+}

+ 1 - 0
src/PixiEditorPrototype/Models/Tool.cs

@@ -3,6 +3,7 @@
 internal enum Tool
 {
     Rectangle,
+    Ellipse,
     PathBasedPen,
     LineBasedPen,
     Select,

+ 73 - 20
src/PixiEditorPrototype/ViewModels/DocumentViewModel.cs

@@ -128,8 +128,8 @@ internal class DocumentViewModel : INotifyPropertyChanged
     public Dictionary<ChunkResolution, SKSurface> Surfaces { get; set; } = new();
     public FolderViewModel StructureRoot { get; }
     public DocumentTransformViewModel TransformViewModel { get; }
-    public int ResizeWidth { get; set; }
-    public int ResizeHeight { get; set; }
+    public int ResizeWidth { get; set; } = 1024;
+    public int ResizeHeight { get; set; } = 1024;
 
 
     private DocumentHelpers Helpers { get; }
@@ -141,9 +141,11 @@ internal class DocumentViewModel : INotifyPropertyChanged
     private bool selectingRect = false;
     private bool selectingLasso = false;
     private bool drawingRectangle = false;
+    private bool drawingEllipse = false;
     private bool drawingPathBasedPen = false;
     private bool drawingLineBasedPen = false;
     private bool transformingRectangle = false;
+    private bool transformingEllipse = false;
     private bool shiftingLayer = false;
 
     private bool transformingSelectionPath = false;
@@ -155,6 +157,11 @@ internal class DocumentViewModel : INotifyPropertyChanged
     private ShapeCorners lastShape = new ShapeCorners();
     private ShapeData lastShapeData = new();
 
+    private SKColor lastEllipseStrokeColor = SKColors.Empty;
+    private SKColor lastEllipseFillColor = SKColors.Empty;
+    private int lastEllipseStrokeWidth = 0;
+    private RectI lastEllipseLocation = RectI.Empty;
+
     public DocumentViewModel(ViewModelMain owner)
     {
         this.owner = owner;
@@ -199,6 +206,23 @@ internal class DocumentViewModel : INotifyPropertyChanged
             (new CreateStructureMember_Action(StructureRoot.GuidValue, Guid.NewGuid(), 0, StructureMemberType.Layer));
     }
 
+    private bool CanStartUpdate()
+    {
+        if (SelectedStructureMember is null)
+            return false;
+        bool drawOnMask = SelectedStructureMember.ShouldDrawOnMask;
+        if (!drawOnMask)
+        {
+            if (SelectedStructureMember is FolderViewModel)
+                return false;
+            if (SelectedStructureMember is LayerViewModel)
+                return true;
+        }
+
+        if (!SelectedStructureMember.HasMaskBindable)
+            return false;
+        return true;
+    }
     private void TransformSelectedArea(object? obj)
     {
         if (updateableChangeActive || SelectedStructureMember is not LayerViewModel layer || SelectionPathBindable.IsEmpty)
@@ -268,19 +292,16 @@ internal class DocumentViewModel : INotifyPropertyChanged
 
     public void StartUpdatePathBasedPen(VecD pos)
     {
-        if (SelectedStructureMember is null)
-            return;
-        bool drawOnMask = SelectedStructureMember.HasMaskBindable && SelectedStructureMember.ShouldDrawOnMask;
-        if (SelectedStructureMember is not LayerViewModel && !drawOnMask)
+        if (!CanStartUpdate())
             return;
         updateableChangeActive = true;
         drawingPathBasedPen = true;
         Helpers.ActionAccumulator.AddActions(new PathBasedPen_Action(
-            SelectedStructureMember.GuidValue,
+            SelectedStructureMember!.GuidValue,
             pos,
             new SKColor(owner.SelectedColor.R, owner.SelectedColor.G, owner.SelectedColor.B, owner.SelectedColor.A),
             owner.StrokeWidth,
-            drawOnMask));
+            SelectedStructureMember.ShouldDrawOnMask));
     }
 
     public void EndPathBasedPen()
@@ -294,19 +315,16 @@ internal class DocumentViewModel : INotifyPropertyChanged
 
     public void StartUpdateLineBasedPen(VecI pos)
     {
-        if (SelectedStructureMember is null)
-            return;
-        bool drawOnMask = SelectedStructureMember.HasMaskBindable && SelectedStructureMember.ShouldDrawOnMask;
-        if (SelectedStructureMember is not LayerViewModel && !drawOnMask)
+        if (!CanStartUpdate())
             return;
         updateableChangeActive = true;
         drawingLineBasedPen = true;
         Helpers.ActionAccumulator.AddActions(new LineBasedPen_Action(
-            SelectedStructureMember.GuidValue,
+            SelectedStructureMember!.GuidValue,
             new SKColor(owner.SelectedColor.R, owner.SelectedColor.G, owner.SelectedColor.B, owner.SelectedColor.A),
             pos,
             (int)owner.StrokeWidth,
-            drawOnMask));
+            SelectedStructureMember.ShouldDrawOnMask));
     }
 
     public void EndLineBasedPen()
@@ -318,16 +336,41 @@ internal class DocumentViewModel : INotifyPropertyChanged
         Helpers.ActionAccumulator.AddFinishedActions(new EndLineBasedPen_Action());
     }
 
-    public void StartUpdateRectangle(ShapeData data)
+    public void StartUpdateEllipse(RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth)
     {
-        if (SelectedStructureMember is null)
+        if (!CanStartUpdate())
             return;
-        bool drawOnMask = SelectedStructureMember.HasMaskBindable && SelectedStructureMember.ShouldDrawOnMask;
-        if (SelectedStructureMember is not LayerViewModel && !drawOnMask)
+        drawingEllipse = true;
+        updateableChangeActive = true;
+        lastEllipseFillColor = fillColor;
+        lastEllipseStrokeWidth = strokeWidth;
+        lastEllipseStrokeColor = strokeColor;
+        lastEllipseLocation = location;
+        Helpers.ActionAccumulator.AddActions(new DrawEllipse_Action(
+            SelectedStructureMember!.GuidValue,
+            location,
+            strokeColor,
+            fillColor,
+            strokeWidth,
+            SelectedStructureMember.ShouldDrawOnMask));
+    }
+
+    public void EndEllipse()
+    {
+        if (!drawingEllipse)
+            return;
+        drawingEllipse = false;
+        TransformViewModel.ShowShapeTransform(new ShapeCorners(lastEllipseLocation));
+        transformingEllipse = true;
+    }
+
+    public void StartUpdateRectangle(ShapeData data)
+    {
+        if (!CanStartUpdate())
             return;
         updateableChangeActive = true;
         drawingRectangle = true;
-        Helpers.ActionAccumulator.AddActions(new DrawRectangle_Action(SelectedStructureMember.GuidValue, data, drawOnMask));
+        Helpers.ActionAccumulator.AddActions(new DrawRectangle_Action(SelectedStructureMember!.GuidValue, data, SelectedStructureMember.ShouldDrawOnMask));
         lastShape = new ShapeCorners(data.Center, data.Size, data.Angle);
         lastShapeData = data;
     }
@@ -391,7 +434,7 @@ internal class DocumentViewModel : INotifyPropertyChanged
 
     public void ApplyTransform(object? param)
     {
-        if (!transformingRectangle && !pastingImage && !transformingSelectionPath)
+        if (!transformingRectangle && !pastingImage && !transformingSelectionPath && !transformingEllipse)
             return;
 
         if (transformingRectangle)
@@ -400,6 +443,12 @@ internal class DocumentViewModel : INotifyPropertyChanged
             TransformViewModel.HideTransform();
             Helpers.ActionAccumulator.AddFinishedActions(new EndDrawRectangle_Action());
         }
+        else if (transformingEllipse)
+        {
+            transformingEllipse = false;
+            TransformViewModel.HideTransform();
+            Helpers.ActionAccumulator.AddFinishedActions(new EndDrawEllipse_Action());
+        }
         else if (pastingImage)
         {
             pastingImage = false;
@@ -446,6 +495,10 @@ internal class DocumentViewModel : INotifyPropertyChanged
                 lastShapeData.FillColor,
                 lastShapeData.BlendMode));
         }
+        else if (transformingEllipse)
+        {
+            StartUpdateEllipse(RectI.FromTwoPoints((VecI)newCorners.TopLeft, (VecI)newCorners.BottomRight), lastEllipseStrokeColor, lastEllipseFillColor, lastEllipseStrokeWidth);
+        }
         else if (pastingImage)
         {
             if (SelectedStructureMember is null || pastedImage is null)

+ 42 - 30
src/PixiEditorPrototype/ViewModels/ViewModelMain.cs

@@ -156,38 +156,47 @@ internal class ViewModelMain : INotifyPropertyChanged
 
     private void ProcessToolMouseMove(VecD canvasPos)
     {
-        if (toolOnMouseDown == Tool.Rectangle)
+        switch (toolOnMouseDown)
         {
-            VecI size = (VecI)canvasPos - (VecI)mouseDownCanvasPos;
-            ActiveDocument!.StartUpdateRectangle(new ShapeData(
-                        (VecI)mouseDownCanvasPos + (VecD)size / 2,
-                        size,
-                        0,
-                        90,
+            case Tool.Rectangle:
+                {
+                    var size = (VecI)canvasPos - (VecI)mouseDownCanvasPos;
+                    var rect = RectI.FromTwoPixels((VecI)mouseDownCanvasPos, (VecI)canvasPos);
+                    ActiveDocument!.StartUpdateRectangle(new ShapeData(
+                                rect.Center,
+                                rect.Size,
+                                0,
+                                (int)StrokeWidth,
+                                new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A),
+                                new SKColor(0, 0, 255, 128)));
+                    break;
+                }
+            case Tool.Ellipse:
+                {
+                    ActiveDocument!.StartUpdateEllipse(
+                        RectI.FromTwoPixels((VecI)mouseDownCanvasPos, (VecI)canvasPos),
                         new SKColor(SelectedColor.R, SelectedColor.G, SelectedColor.B, SelectedColor.A),
-                        SKColors.Transparent));
-        }
-        else if (toolOnMouseDown == Tool.Select)
-        {
-            ActiveDocument!.StartUpdateRectSelection(
-                new RectI((VecI)mouseDownCanvasPos, (VecI)canvasPos - (VecI)mouseDownCanvasPos),
-                selectionMode);
-        }
-        else if (toolOnMouseDown == Tool.ShiftLayer)
-        {
-            ActiveDocument!.StartUpdateShiftLayer((VecI)canvasPos - (VecI)mouseDownCanvasPos);
-        }
-        else if (toolOnMouseDown == Tool.Lasso)
-        {
-            ActiveDocument!.StartUpdateLassoSelection((VecI)canvasPos, selectionMode);
-        }
-        else if (toolOnMouseDown == Tool.PathBasedPen)
-        {
-            ActiveDocument!.StartUpdatePathBasedPen(canvasPos);
-        }
-        else if (toolOnMouseDown == Tool.LineBasedPen)
-        {
-            ActiveDocument!.StartUpdateLineBasedPen((VecI)canvasPos);
+                        new SKColor(0, 0, 255, 128),
+                        (int)StrokeWidth);
+                    break;
+                }
+            case Tool.Select:
+                ActiveDocument!.StartUpdateRectSelection(
+                            RectI.FromTwoPixels((VecI)mouseDownCanvasPos, (VecI)canvasPos),
+                            selectionMode);
+                break;
+            case Tool.ShiftLayer:
+                ActiveDocument!.StartUpdateShiftLayer((VecI)canvasPos - (VecI)mouseDownCanvasPos);
+                break;
+            case Tool.Lasso:
+                ActiveDocument!.StartUpdateLassoSelection((VecI)canvasPos, selectionMode);
+                break;
+            case Tool.PathBasedPen:
+                ActiveDocument!.StartUpdatePathBasedPen(canvasPos);
+                break;
+            case Tool.LineBasedPen:
+                ActiveDocument!.StartUpdateLineBasedPen((VecI)canvasPos);
+                break;
         }
     }
 
@@ -209,6 +218,9 @@ internal class ViewModelMain : INotifyPropertyChanged
                 case Tool.Rectangle:
                     ActiveDocument!.EndRectangleDrawing();
                     break;
+                case Tool.Ellipse:
+                    ActiveDocument!.EndEllipse();
+                    break;
                 case Tool.Select:
                     ActiveDocument!.EndRectSelection();
                     break;

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

@@ -212,6 +212,7 @@
         <Border BorderThickness="1" Background="White" BorderBrush="Black" DockPanel.Dock="Left" Margin="5">
             <StackPanel Orientation="Vertical" Background="White">
                 <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.Rectangle}">Rect</Button>
+                <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.Ellipse}">Ellipse</Button>
                 <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.PathBasedPen}">Path Pen</Button>
                 <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.LineBasedPen}">Line Pen</Button>
                 <Button Width="70" Margin="5" Command="{Binding ChangeActiveToolCommand}" CommandParameter="{x:Static models:Tool.Select}">Select</Button>